Files
wekan/server/lib/syncRuleEmailReport.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

34 lines
2.3 KiB
JavaScript

'use strict';
const PAGE_SIZE = 10;
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}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
const date = value => value instanceof Date && Number.isFinite(value.getTime());
// Metadata only. A sending row may still be in flight; the report cannot infer
// SMTP delivery or authorize a retry from elapsed time. No command/mail read.
async function syncRuleEmailReport(attempts, { search, page, status }) {
if (typeof search !== 'string' || search.length > 128 || !Number.isSafeInteger(page) || page < 0 ||
!['all', 'unconfirmed', 'sent', 'dropped'].includes(status)) throw new Error('sync-rule-email-report-invalid');
const query = {};
if (status !== 'all') query.state = status === 'unconfirmed' ? 'sending' : status;
if (search.trim()) {
const pattern = new RegExp(search.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
query.$or = ['_id', 'invocationId', 'attemptId'].map(key => ({ [key]: pattern }));
}
const total = await attempts.countDocuments(query);
page = Math.min(page, Math.max(0, Math.ceil(total / PAGE_SIZE) - 1));
const saved = await attempts.find(query, { projection: { _id: 1, version: 1, commandHash: 1,
invocationId: 1, attemptId: 1, state: 1, startedAt: 1, finishedAt: 1 } })
.sort({ startedAt: -1, _id: 1 }).skip(page * PAGE_SIZE).limit(PAGE_SIZE).toArray();
const rows = saved.map(row => {
const valid = hash(row._id) && hash(row.commandHash) && hash(row.invocationId) && uuid(row.attemptId) &&
row.version === 1 && date(row.startedAt) && ['sending', 'sent', 'dropped'].includes(row.state) &&
(row.state !== 'sending' ? date(row.finishedAt) : row.finishedAt === undefined);
return { commandId: hash(row._id) ? row._id : null, invocationId: hash(row.invocationId) ? row.invocationId : null,
attemptId: uuid(row.attemptId) ? row.attemptId : null,
status: valid ? (row.state === 'sending' ? 'unconfirmed' : row.state) : 'invalid',
startedAt: date(row.startedAt) ? row.startedAt : null, finishedAt: date(row.finishedAt) ? row.finishedAt : null };
});
return { total, page, pageSize: PAGE_SIZE, rows };
}
module.exports = { syncRuleEmailReport };