Files
wekan/server/lib/syncRuleEmailDispatch.js
Lauri Ojansivu 252f27986b Let administrators resolve partially accepted rule emails.
When SMTP accepts a rule email for only some recipients, or its answer is
lost, the attempt stays unconfirmed and WeKan never sends it again by
itself. Admin Panel -> Problems -> Recovery now shows each recipient as
accepted or not confirmed, and an administrator can resend to the
unconfirmed recipients only (same headers, narrowed SMTP envelope), mark
the attempt sent, or drop it. A drop completes the rule stage without
mail. Decisions wait ten minutes after the attempt started, longer than
any SMTP send can last, are one immutable record per attempt, and are
serialized per command across server processes. A resend records its
intent before SMTP, so an interrupted one is shown as in progress rather
than repeated silently. The offline acceptance tool and the online
decision exclude each other.

Unit, MongoDB integration and source tests cover resend, partial resend,
mark sent, drop, the quarantine and the refusals; a Playwright case
covers review and drop in the Admin Panel. New interface text is in
English, pending translation.

Thanks to xet7 !
2026-09-30 01:46:04 +03:00

86 lines
4.4 KiB
JavaScript

'use strict';
const { randomUUID } = require('node:crypto');
const { EJSON } = require('bson');
const { canonical } = require('../../models/lib/changeHistoryIntegrity');
const { validateRuleEmailCommand } = require('./syncRuleEmailCommand');
const { ruleEmailRecipients, confirmRuleEmailAcceptance } = require('./syncRuleEmailAcceptance');
const { recordRuleEmailOutcome } = require('./syncRuleEmailResolution');
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
// The owner supplies the live journal/policy/configuration guard, operation
// lease and cancellable SMTP capacity slot. Never automatically repeat an
// existing sending attempt: a crash may have happened after SMTP acceptance.
async function dispatchRuleEmail({ command, plan, activity, effectId, index,
attempts, outcomes, MailComposer, send, assertCurrent, now = () => new Date() }) {
command = validateRuleEmailCommand(command, { plan, activity, effectId, index });
if (typeof send !== 'function' || typeof assertCurrent !== 'function' ||
!['findOne', 'insertOne', 'replaceOne'].every(key => typeof attempts?.[key] === 'function')) {
throw new Error('sync-rule-email-dispatch-invalid');
}
const date = () => {
const value = now();
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new Error('sync-rule-email-clock-invalid');
return new Date(value);
};
const base = { _id: command._id, version: 1, commandHash: command.checksum, invocationId: command.invocationId };
const validate = row => {
if (!row || Object.keys(base).some(key => row[key] !== base[key]) ||
!['sending', 'sent', 'dropped'].includes(row.state) ||
typeof row.attemptId !== 'string' || !/^[a-f0-9-]{36}$/.test(row.attemptId) ||
!(row.startedAt instanceof Date) || !Number.isFinite(row.startedAt.getTime()) ||
(row.state !== 'sending' && (!(row.finishedAt instanceof Date) || !Number.isFinite(row.finishedAt.getTime()))) ||
Object.keys(row).sort().join(',') !== (row.state !== 'sending'
? '_id,attemptId,commandHash,finishedAt,invocationId,startedAt,state,version'
: '_id,attemptId,commandHash,invocationId,startedAt,state,version')) {
throw new Error('sync-rule-email-attempt-invalid');
}
return row;
};
const read = async () => {
await assertCurrent();
const row = await attempts.findOne({ _id: base._id });
await assertCurrent();
return row ? validate(row) : null;
};
const existing = await read();
if (existing) {
// An administrator's drop (#2713) completes the stage without mail.
if (existing.state === 'sent' || existing.state === 'dropped') return command.invocationId;
throw new Error('sync-rule-email-delivery-uncertain');
}
const recipients = ruleEmailRecipients(command.mail, MailComposer);
const pending = { ...base, state: 'sending', attemptId: randomUUID(), startedAt: date() };
await assertCurrent();
let failure;
try { await attempts.insertOne(copy(pending)); } catch (error) { failure = error; }
const inserted = await read();
if (!inserted) throw failure || new Error('sync-rule-email-attempt-unconfirmed');
if (canonical(inserted) !== canonical(pending)) throw new Error('sync-rule-email-delivery-uncertain');
await assertCurrent();
// Any rejection, partial acceptance or ownership loss retains sending state.
// Do not persist SMTP error text, which can expose addresses or credentials.
const result = await send(copy(command.mail), { assertCurrent });
try {
confirmRuleEmailAcceptance(recipients, result);
} catch (error) {
// Partial acceptance: keep who DID accept, so an administrator can resend
// to the rest only. Recording it must never turn into a completion.
if (typeof outcomes?.updateOne === 'function') {
try {
await recordRuleEmailOutcome({ outcomes, commandId: pending._id, attemptId: pending.attemptId,
recipients, result, now: date });
} catch (_) { /* the attempt stays uncertain either way */ }
}
throw error;
}
await assertCurrent();
const sent = { ...pending, state: 'sent', finishedAt: date() };
failure = undefined;
try { await attempts.replaceOne(pending, copy(sent)); } catch (error) { failure = error; }
const stored = await read();
if (!stored || canonical(stored) !== canonical(sent)) {
throw failure || new Error('sync-rule-email-completion-unconfirmed');
}
return command.invocationId;
}
module.exports = { dispatchRuleEmail };