mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52: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 !
34 lines
2.3 KiB
JavaScript
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 };
|