Files
wekan/server/lib/webhookOutbox.js
Lauri Ojansivu ab785acd1e Choose what notifications contain, and group and schedule them, for e-mail, the tray and webhooks.
One delivery model (models/lib/notificationDelivery.js) for every channel -
e-mail, the notification tray and outgoing webhooks - set in the
Notifications popup at Admin Panel, board and Member Settings scope, and in
each one-way webhook's form:

- Content. Webhooks: the text on or off, and which property groups are sent
  (standard, ids, names, links, actor, people, details), with checklist,
  list, swimlane and board names, before/after values, the card description
  and links added as opt-in groups. E-mail and tray: the classic sentence or
  a clear layout - the board as a link first, the actor and card in bold -
  and which parts are shown.
- Grouping: one message per event, per card, per board, or all together.
- Scheduling: immediately, every 5 minutes to 24 hours, or once a day, with
  optional quiet hours, in the time zone of whoever set it.

The most specific explicit setting wins: member, then board, then Admin
Panel for e-mail and tray; the webhook, then its board, then Admin Panel for
webhooks. A member can only leave their own name out of outgoing webhooks.
The built-in defaults keep today's behaviour. Grouped and scheduled webhooks
and e-mail go through durable queues that survive restarts; queued webhooks
use the SSRF guard and log a blocked address in Admin Panel - Problems.
Values of admin-only custom fields now reach only board admins in e-mail and
tray, and never a webhook.

The new strings are in English in every locale, pending Transifex.

Thanks to HaKePlan, Meeques and xet7 !

Fixes #3695,
Fixes #5171.
2026-10-10 15:28:26 +03:00

129 lines
5.8 KiB
JavaScript

'use strict';
// #3695: the durable queue for outgoing webhooks whose delivery settings group
// or schedule them (models/lib/notificationDelivery.js). A webhook with the
// built-in settings never enters it: it is still POSTed at once, as before.
//
// Each queued notification is one job, stored before anything is sent, with
// the time it is due. A pass takes, per webhook, the due jobs of one group,
// POSTs them as one request (models/lib/webhookPayload.js
// combineWebhookBodies), and marks them sent only after a 2xx answer, so a
// restart in the middle loses nothing; a job that failed is retried with
// backoff. Like e-mail, delivery is at-least-once: a crash between the HTTP
// answer and the acknowledgement repeats that one request.
//
// The request goes to the webhook as it is AT DELIVERY: a deleted, disabled
// or two-way webhook cancels its queued jobs, and the current URL and token
// are used - a queued job never carries a credential of its own.
const { createHash, randomUUID } = require('node:crypto');
const { withSyncLease } = require('./syncLease');
const { combineWebhookBodies } = require('../../models/lib/webhookPayload');
const MAX_ATTEMPTS = 10;
const MAX_BATCH = 100;
const MAX_BODY_BYTES = 1024 * 1024;
const jobIdFor = (integrationId, eventId) =>
createHash('sha256').update(JSON.stringify(['webhook-job', integrationId, eventId])).digest('hex');
function retryDelayMs(attempts) {
return Math.min(60 * 60000, 30000 * 2 ** Math.max(0, attempts - 1));
}
function createWebhookOutbox({ jobs, leases, getIntegration, post, now = () => new Date(), oneWayType = 'outgoing-webhooks',
leaseOptions = {} }) {
// Store one prepared body. `deliverAt` is the time from the webhook's
// schedule; `groupKey` the group it is sent with.
async function enqueue({ integrationId, eventId, groupKey, body, deliverAt }) {
if (![integrationId, eventId, groupKey].every(v => typeof v === 'string' && v && v.length <= 300) ||
!body || typeof body !== 'object' || Array.isArray(body) || !Number.isFinite(deliverAt)) {
throw new Error('invalid-webhook-job');
}
if (Buffer.byteLength(JSON.stringify(body)) > MAX_BODY_BYTES) throw new Error('webhook-job-too-large');
const createdAt = now();
const _id = jobIdFor(integrationId, eventId);
const job = { _id, integrationId, eventId, groupKey, body, state: 'pending', attempts: 0, createdAt,
nextAttemptAt: new Date(Math.max(deliverAt, createdAt.getTime())) };
try {
await jobs.insertOne(job);
} catch (error) {
// The same event for the same webhook is stored once, never overwritten.
const existing = await jobs.findOne({ _id });
if (!existing || existing.integrationId !== integrationId || existing.eventId !== eventId) throw error;
}
return _id;
}
async function finish(ids, attemptId, state, extra = {}) {
await jobs.updateMany({ _id: { $in: ids }, state: 'pending', attemptId },
{ $set: { state, finishedAt: now(), ...extra }, $unset: { body: '', nextAttemptAt: '' } });
}
async function drainIntegration(integrationId) {
try {
return await withSyncLease(leases, `webhook:${integrationId}`, async ({ assertCurrent }) => {
const due = await jobs.find({ integrationId, state: 'pending', nextAttemptAt: { $lte: now() } })
.sort({ nextAttemptAt: 1, createdAt: 1, _id: 1 }).limit(MAX_BATCH).toArray();
if (!due.length) return;
const groupKey = due[0].groupKey;
const batch = due.filter(job => job.groupKey === groupKey);
const attemptId = randomUUID();
const ids = batch.map(job => job._id);
await assertCurrent();
await jobs.updateMany({ _id: { $in: ids }, state: 'pending' }, { $set: { attemptId } });
const integration = await getIntegration(integrationId);
await assertCurrent();
if (!integration || integration.enabled === false || integration.type !== oneWayType || !integration.url) {
await finish(ids, attemptId, 'cancelled');
return;
}
const headers = { 'Content-Type': 'application/json' };
if (integration.token) headers['X-Wekan-Token'] = integration.token;
let ok = false;
try {
const response = await post(integration.url, {
method: 'POST', headers, body: JSON.stringify(combineWebhookBodies(batch.map(job => job.body))),
});
ok = !!response && response.status >= 200 && response.status < 300;
} catch (error) {
ok = false;
}
await assertCurrent();
if (ok) {
await finish(ids, attemptId, 'sent');
return;
}
for (const job of batch) {
const attempts = (job.attempts || 0) + 1;
if (attempts >= MAX_ATTEMPTS) {
await finish([job._id], attemptId, 'failed', { attempts });
} else {
await jobs.updateOne({ _id: job._id, state: 'pending', attemptId }, {
$set: { attempts, nextAttemptAt: new Date(now().getTime() + retryDelayMs(attempts)) },
});
}
}
}, { ...leaseOptions, now });
} catch (error) {
if (['sync-busy', 'sync-lease-lost'].includes(error.code)) return undefined;
throw error;
}
}
async function drain() {
const due = await jobs.aggregate([
{ $match: { state: 'pending', nextAttemptAt: { $lte: now() } } },
{ $group: { _id: '$integrationId', nextAttemptAt: { $min: '$nextAttemptAt' } } },
{ $sort: { nextAttemptAt: 1, _id: 1 } },
{ $limit: 100 },
]).toArray();
for (const row of due) {
try { await drainIntegration(row._id); }
catch (error) { console.error('Webhook queue: delivery failed; queued jobs retained'); }
}
}
return { enqueue, drain, drainIntegration };
}
module.exports = { createWebhookOutbox, jobIdFor, retryDelayMs, MAX_ATTEMPTS };