mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
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 !
83 lines
4.8 KiB
JavaScript
83 lines
4.8 KiB
JavaScript
'use strict';
|
|
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
|
|
const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
const uuid = value => typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value);
|
|
const date = value => value instanceof Date && Number.isFinite(+value);
|
|
const label = (value, max) => typeof value === 'string' && value.trim() === value && value.length > 0 && value.length <= max && !/[\r\n\0]/.test(value);
|
|
const fail = reason => { throw new Error(`sync-rule-email-recovery-${reason}`); };
|
|
|
|
// Offline only: the operator has independently verified acceptance for EVERY
|
|
// recipient. Never infer that from age, send mail, change content or reset an
|
|
// attempt to make it retryable. The immutable decision precedes receipt repair.
|
|
async function confirmAcceptedRuleEmail({ attempts, commands, resolutions, commandId, attemptId,
|
|
operator, evidence, assertOffline, now = () => new Date() }) {
|
|
if (!hash(commandId) || !uuid(attemptId) || !label(operator, 200) || !label(evidence, 500) ||
|
|
typeof assertOffline !== 'function') fail('invalid');
|
|
await assertOffline();
|
|
const readAttempt = async () => {
|
|
await assertOffline();
|
|
const row = await attempts.findOne({ _id: commandId });
|
|
await assertOffline();
|
|
if (!row || row._id !== commandId || row.version !== 1 || row.attemptId !== attemptId ||
|
|
!hash(row.commandHash) || !hash(row.invocationId) || !date(row.startedAt) ||
|
|
!['sending', 'sent'].includes(row.state) ||
|
|
(row.state === 'sent' && !date(row.finishedAt)) ||
|
|
Object.keys(row).sort().join(',') !== (row.state === 'sent'
|
|
? '_id,attemptId,commandHash,finishedAt,invocationId,startedAt,state,version'
|
|
: '_id,attemptId,commandHash,invocationId,startedAt,state,version')) fail('attempt-changed');
|
|
return row;
|
|
};
|
|
const attempt = await readAttempt();
|
|
const command = await commands.findOne({ _id: commandId }, { projection: { _id: 1, checksum: 1, invocationId: 1 } });
|
|
await assertOffline();
|
|
if (!command || command._id !== commandId || command.checksum !== attempt.commandHash || command.invocationId !== attempt.invocationId) fail('command-changed');
|
|
const base = { _id: sha256(canonical(['rule-email-accepted', commandId, attemptId])), version: 1,
|
|
decision: 'accepted', commandId, commandHash: attempt.commandHash, invocationId: attempt.invocationId,
|
|
attemptId, startedAt: attempt.startedAt, operator, evidence };
|
|
const validate = row => {
|
|
if (!row || !date(row.confirmedAt) || +row.confirmedAt < +attempt.startedAt ||
|
|
Object.keys(row).sort().join(',') !== [...Object.keys(base), 'confirmedAt', 'checksum'].sort().join(',') ||
|
|
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
|
|
row.checksum !== sha256(canonical({ ...base, confirmedAt: row.confirmedAt }))) fail('decision-conflict');
|
|
return row;
|
|
};
|
|
const readDecision = async () => {
|
|
await assertOffline();
|
|
const row = await resolutions.findOne({ _id: base._id });
|
|
await assertOffline();
|
|
return row ? validate(row) : null;
|
|
};
|
|
// An online administrator decision (#2713) for this attempt excludes this one.
|
|
await assertOffline();
|
|
const onlineId = sha256(canonical(['rule-email-resolution', commandId, attemptId]));
|
|
if ((await resolutions.findOne({ _id: onlineId }))?._id === onlineId) fail('decision-conflict');
|
|
let decision = await readDecision();
|
|
if (!decision && attempt.state === 'sent') return { commandId, attemptId, status: 'already-sent' };
|
|
if (!decision) {
|
|
const confirmedAt = now();
|
|
if (!date(confirmedAt) || +confirmedAt < +attempt.startedAt) fail('clock-invalid');
|
|
const candidate = { ...base, confirmedAt };
|
|
candidate.checksum = sha256(canonical(candidate));
|
|
await assertOffline();
|
|
let error;
|
|
try { await resolutions.insertOne(candidate); } catch (failure) { error = failure; }
|
|
decision = await readDecision();
|
|
if (!decision) throw error || new Error('sync-rule-email-recovery-decision-unconfirmed');
|
|
}
|
|
const { finishedAt, ...identity } = attempt;
|
|
const pending = { ...identity, state: 'sending' };
|
|
const sent = { ...pending, state: 'sent', finishedAt: decision.confirmedAt };
|
|
const current = await readAttempt();
|
|
if (canonical(current) !== canonical(sent)) {
|
|
if (canonical(current) !== canonical(pending)) fail('attempt-changed');
|
|
await assertOffline();
|
|
let error;
|
|
try { await attempts.replaceOne(pending, sent); } catch (failure) { error = failure; }
|
|
const saved = await readAttempt();
|
|
if (canonical(saved) !== canonical(sent)) throw error || new Error('sync-rule-email-recovery-receipt-unconfirmed');
|
|
}
|
|
await readDecision();
|
|
return { commandId, attemptId, resolutionId: base._id, status: 'confirmed-accepted' };
|
|
}
|
|
module.exports = { confirmAcceptedRuleEmail };
|