Files
wekan/server/lib/syncNotificationPlan.js
Lauri Ojansivu e85eaa3ad3 Sync notifications are planned again with the clear e-mail layout.
v12.44 made the clear HTML layout the default for notification e-mail
(#4434), and with it every e-mail job carries `text`, its plain-text part.
The Sync notification plan validator accepted exactly seven job fields, so
it refused every plan: a rule or Sync run's notifications failed with
sync-notification-plan-invalid. groupKey and deliverAt, from non-default
delivery settings, were refused the same way before that.

The validator now takes what emailOutbox.enqueue takes - the seven fields
and the three optional ones, checked as enqueue checks them - so a plan
never holds a job its replay would refuse. tests/syncNotificationPlan
.test.cjs plans a job with all three and refuses bad ones, and pins that
the optional list is exactly enqueue's parameters; the full Meteor run
found it (Shared notification preparation, Stored Sync notification
delivery).

Thanks to xet7 !
2026-10-14 17:48:45 +03:00

153 lines
7.7 KiB
JavaScript

'use strict';
const { EJSON, calculateObjectSize } = require('bson');
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
const { idFor: emailId } = require('./emailOutbox');
const { receiptFor } = require('./trayDelivery');
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
const fail = () => { throw new Error('sync-notification-plan-invalid'); };
const text = value => typeof value === 'string' && value.length > 0 && value.length <= 1024;
const keys = (value, expected) => value && !Array.isArray(value) &&
Object.keys(value).sort().join(',') === expected.split(',').sort().join(',');
const MAX_BYTES = 15 * 1024 * 1024;
// A board-level activity (2026-10-03: createSwimlane, moveCardBoard's peers)
// names no card; its plan's cardId is null, as its email jobs' are.
function identity(activity) {
if (!activity || !['_id','boardId','userId'].every(key => text(activity[key])) ||
(activity.cardId !== undefined && activity.cardId !== null && !text(activity.cardId))) fail();
return { activityId: activity._id, activityHash: sha256(canonical(activity)),
boardId: activity.boardId, cardId: activity.cardId ?? null, actorId: activity.userId };
}
// The e-mail job is what emailOutbox.enqueue takes: seven fields, and the
// three it accepts optionally (#5171) - `text` (the clear layout's plain-text
// part, the default since #4434), `groupKey` and `deliverAt`. Checked as
// enqueue checks them, so a plan never holds a job the replay would refuse.
const EMAIL_JOB_KEYS = ['userId', 'eventId', 'subject', 'html', 'language', 'cardId', 'boardId'];
const EMAIL_JOB_OPTIONAL = ['text', 'groupKey', 'deliverAt'];
function emailJobShape(job) {
if (!job || Array.isArray(job) || typeof job !== 'object') return false;
const names = Object.keys(job);
return EMAIL_JOB_KEYS.every(key => names.includes(key)) &&
names.every(key => EMAIL_JOB_KEYS.includes(key) || EMAIL_JOB_OPTIONAL.includes(key)) &&
(job.text === undefined || typeof job.text === 'string') &&
(job.groupKey === undefined || (typeof job.groupKey === 'string' && job.groupKey.length > 0 && job.groupKey.length <= 300)) &&
(job.deliverAt === undefined || Number.isFinite(job.deliverAt));
}
function validateRecipient(row, plan) {
if (!keys(row,'userId,tray,email') || !text(row.userId) || typeof row.tray !== 'boolean') fail();
const job = row.email;
if (job === null) return;
if (!emailJobShape(job) || job.userId !== row.userId ||
job.eventId !== plan.activityId || job.cardId !== plan.cardId || job.boardId !== plan.boardId ||
!text(job.language) || typeof job.subject !== 'string' || job.subject.length > 10000 ||
/[\r\n]/.test(job.subject) || typeof job.html !== 'string') fail();
}
function validateNotificationPlan(plan, activity) {
const expected = identity(activity);
if (!keys(plan,'version,activityId,activityHash,boardId,cardId,actorId,recipients') || plan.version !== 1 ||
Object.entries(expected).some(([key,value]) => plan[key] !== value) ||
!Array.isArray(plan.recipients) || plan.recipients.length > 10000) fail();
const seen = new Set();
for (const row of plan.recipients) {
validateRecipient(row, plan);
if (seen.has(row.userId)) fail();
seen.add(row.userId);
}
if (calculateObjectSize(plan) > MAX_BYTES) fail();
return true;
}
// The caller selects authorized recipients once, from the ordinary activity
// payload builder. Service preparation reads preferences/renders content but
// must not write or deliver. Replays use the persisted plan, not these readers.
async function prepareNotificationPlan({ activity, recipientIds, getUser, prepareEmail, prepareTray }) {
activity = copy(activity);
const plan = { version: 1, ...identity(activity), recipients: [] };
if (!Array.isArray(recipientIds) || recipientIds.length > 10000 ||
!recipientIds.every(text) || new Set(recipientIds).size !== recipientIds.length ||
![getUser, prepareEmail, prepareTray].every(fn => typeof fn === 'function')) fail();
const ids = [...recipientIds];
let bytes = calculateObjectSize(plan);
for (const userId of ids) {
const user = await getUser(userId);
if (user?._id !== userId) fail();
const [email, tray] = await Promise.all([
Promise.resolve(prepareEmail(user, copy(activity))).then(copy), prepareTray(user, copy(activity)),
]);
const row = copy({ userId, email, tray });
validateRecipient(row, plan);
bytes += calculateObjectSize(row) + 32;
if (bytes > MAX_BYTES) fail();
plan.recipients.push(row);
}
validateNotificationPlan(plan, activity);
return plan;
}
const planId = activityId => sha256(canonical(['sync-notifications', activityId]));
// With `receipts`, a plan compacted after delivery (syncNotificationRetention.js)
// is returned as { compacted: true, id }: it was delivered and is never rebuilt.
async function ensureNotificationPlan({ plans, activity, build, assertCurrent, receipts = null }) {
activity = copy(activity);
const { activityHash } = identity(activity);
if (typeof build !== 'function' || typeof assertCurrent !== 'function') fail();
const _id = planId(activity._id);
async function read() {
const row = await plans.findOne({ _id });
if (!row) return null;
if (row.compactReceiptVersion !== undefined) {
if (!receipts || row._id !== _id) fail();
const { readCompactedNotification } = require('./syncNotificationRetention');
return { compacted: true, id: await readCompactedNotification({ receipts, row, activityHash }) };
}
if (!keys(row,'_id,plan,checksum') || row._id !== _id) fail();
validateNotificationPlan(row.plan, activity);
if (row.checksum !== sha256(canonical(row.plan))) fail();
return copy(row.plan);
}
await assertCurrent();
let plan = await read();
if (!plan) {
const candidate = copy(await build(copy(activity)));
validateNotificationPlan(candidate, activity);
await assertCurrent();
let failure;
try { await plans.insertOne({ _id, plan: candidate, checksum: sha256(canonical(candidate)) }); }
catch (error) { failure = error; }
plan = await read();
if (!plan) throw failure || new Error('sync-notification-plan-unconfirmed');
}
await assertCurrent();
return plan;
}
// Confirms tray receipt and durable email enqueue only, NOT SMTP acceptance,
// rule actions or webhooks. A caller must not treat this as their completion.
async function deliverNotificationPlan({ plan, activity, tray, email, assertCurrent, assertRecipient }) {
plan = copy(plan); activity = copy(activity);
validateNotificationPlan(plan, activity);
if (![assertCurrent, assertRecipient].every(fn => typeof fn === 'function') ||
(plan.recipients.some(row => row.tray) && typeof tray?.deliver !== 'function') ||
(plan.recipients.some(row => row.email) && typeof email?.enqueue !== 'function')) fail();
async function guard(row) {
await assertCurrent();
if (await assertRecipient(row.userId, copy(activity)) !== true) throw new Error('sync-notification-recipient-denied');
await assertCurrent();
}
await assertCurrent();
for (const row of plan.recipients) {
await guard(row);
if (row.tray) {
if (await tray.deliver(row.userId, plan.activityId) !== receiptFor(row.userId, plan.activityId)._id) {
throw new Error('sync-notification-tray-unconfirmed');
}
await guard(row);
}
if (row.email) {
if (await email.enqueue(copy(row.email)) !== emailId(row.userId, plan.activityId)) {
throw new Error('sync-notification-email-unconfirmed');
}
await guard(row);
}
}
await assertCurrent();
return planId(plan.activityId);
}
module.exports = { notificationActivityIdentity: identity, prepareNotificationPlan, validateNotificationPlan, ensureNotificationPlan, deliverNotificationPlan, planId };