fix(email): show a queue nobody is working instead of reporting all-clear

Closes #1262.

"Gallery email queued" reads as a delivery confirmation, and System Health
agreed with it: "No stuck or failed emails -- all clear", while not one email
had gone out.

Both statements were true and neither was the one the admin needed. Queueing
writes an email_queue row at status='pending', retry_count 0 -- nothing more.
/failures matched only status='failed' or pending-with-retry_count>=3, so it
matched none of those rows, and there are two ordinary ways they never leave
that state:

- startEmailQueueProcessor() was never reached, so nothing polls the queue.
- Every pass returns early. processEmailQueue bails when the transporter will
  not initialise, before it touches a single row, so retry_count stays 0 and
  no error_message is ever written. A working SMTP test button does not
  contradict this: that path builds its own transport.

adminSystem.js made it worse by reporting `emailProcessor: { status: 'active' }`
as a literal, so the one place that named the worker always said it was fine.

- emailProcessor records what each pass did -- started, lastRunAt, lastResult,
  lastError -- and exports getQueueProcessorStatus(). The transporter bail and
  the queue-query failure, the two silent early returns, both write lastError.
- /failures gains `waitingEmails`: pending, under the retry cap, past any
  scheduled_at, and queued more than 10 minutes ago. The predicate mirrors the
  processor's own pickup query, so a row listed there is one it should already
  have taken; rows over the cap stay in `stuckEmails` and are not counted
  twice. A future scheduled_at is left alone -- split-payment invoices and the
  business-hours floor park rows deliberately.
- System Health leads with the processor's state (running / stopped /
  degraded) and lists waiting emails in their own table. The all-clear now
  needs both buckets empty.
- adminSystem reports the real processor state instead of the literal.
- The two "queued" toasts say the queue processor is what sends it and where
  to look if it doesn't arrive.

8 route tests, all 8 failing before the change.
This commit is contained in:
Paul Nothaft
2026-09-02 13:49:22 +02:00
parent f722bdaf4b
commit 73d867521a
10 changed files with 480 additions and 64 deletions
+12 -1
View File
@@ -9,6 +9,7 @@ const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { resolveSqlitePath } = require('../utils/databaseEngine');
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
const { getQueueProcessorStatus } = require('../services/emailProcessor');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { parseWhatsNew } = require('../utils/whatsNew');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
@@ -346,7 +347,17 @@ router.get('/status', adminAuth, requirePermission(['settings.view', 'system.vie
services: {
fileWatcher: { status: 'active' }, // These would ideally check actual service status
expirationChecker: { status: 'active' },
emailProcessor: { status: 'active' }
// #1262 — this used to be hardcoded 'active', which reported a healthy
// worker on a deployment whose queue had never been touched. Report
// what the processor itself recorded instead.
emailProcessor: (() => {
const p = getQueueProcessorStatus();
return {
status: p.started && !p.lastError ? 'active' : (p.started ? 'degraded' : 'stopped'),
lastRunAt: p.lastRunAt,
lastError: p.lastError,
};
})()
},
timestamp: new Date()
};
+51 -10
View File
@@ -23,6 +23,7 @@ const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
const { getCoverageReport } = require('../services/backupCoverageService');
const { getQueueProcessorStatus } = require('../services/emailProcessor');
const { db } = require('../database/db');
const router = express.Router();
@@ -85,6 +86,24 @@ router.get(
}),
);
/**
* How long an email may sit due-but-unsent before it counts as waiting rather
* than merely in flight. The processor wakes every 60s and takes 10 rows a
* pass, so a genuine backlog of ~6000 clears inside this window — anything
* still here has not been worked.
*/
const WAITING_EMAIL_GRACE_MS = 10 * 60 * 1000;
const mapEmailRow = (r) => ({
id: r.id,
recipientEmail: r.recipient_email,
emailType: r.email_type,
status: r.status,
retryCount: r.retry_count,
errorMessage: r.error_message,
createdAt: r.created_at,
});
/**
* GET /api/admin/system-health/failures
*
@@ -94,6 +113,14 @@ router.get(
* (status='pending' AND retry_count >= 3 — the processor only picks up
* retry_count < 3). Trigger: a 14h window where 'quote_sent' template
* errors left invoices unsent with no admin-visible signal.
*
* #1262 added the other half. A queue nobody is working produces no failures
* at all: the rows sit at status='pending' with retry_count 0, matching
* neither branch above, and the page reported "all clear" while not one email
* had gone out. That happens whenever the processor never started, or every
* pass returns early because the transport will not initialise. So the
* response also carries emails that are DUE and still unsent
* (`waitingEmails`), plus what the processor itself last did (`processor`).
*/
router.get(
'/failures',
@@ -110,17 +137,31 @@ router.get(
.limit(200)
.select('id', 'recipient_email', 'email_type', 'status', 'retry_count', 'error_message', 'created_at');
// Deliberately mirrors the processor's own pickup predicate — pending,
// under the retry cap, and past any scheduled_at — so a row listed here is
// one it should already have taken. Rows over the cap are the `stuckEmails`
// set above and must not be counted twice.
const now = new Date();
const dueBefore = new Date(now.getTime() - WAITING_EMAIL_GRACE_MS);
const waitingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.where('created_at', '<=', dueBefore.toISOString())
.andWhere(function () {
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now.toISOString());
})
.orderBy('created_at', 'asc')
.limit(200)
.select('id', 'recipient_email', 'email_type', 'status', 'retry_count', 'error_message', 'created_at');
return successResponse(res, {
stuckEmails: stuckEmails.map((r) => ({
id: r.id,
recipientEmail: r.recipient_email,
emailType: r.email_type,
status: r.status,
retryCount: r.retry_count,
errorMessage: r.error_message,
createdAt: r.created_at,
})),
counts: { stuckEmails: stuckEmails.length },
stuckEmails: stuckEmails.map(mapEmailRow),
waitingEmails: waitingEmails.map(mapEmailRow),
processor: getQueueProcessorStatus(),
counts: {
stuckEmails: stuckEmails.length,
waitingEmails: waitingEmails.length,
},
});
}),
);
+34
View File
@@ -922,9 +922,32 @@ async function renderQueuedEmail(templateKey, variables = {}, to = '') {
// because ignoreSchedule also bypasses that cap.
//
// Returns { processed, sent, failed }.
// What the last pass actually did, so System Health can say whether the queue
// is being worked at all (#1262). "Queued" is not "delivered", and the two
// ways a queue silently stops -- the processor never started, or every pass
// returns early because the transport will not initialise -- both leave rows
// at status='pending' with retry_count 0, which no failure query matches.
const processorStatus = {
started: false,
lastRunAt: null,
lastResult: null,
lastError: null,
};
function getQueueProcessorStatus() {
return {
started: processorStatus.started,
lastRunAt: processorStatus.lastRunAt,
lastResult: processorStatus.lastResult,
lastError: processorStatus.lastError,
};
}
async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId = null } = {}) {
logger.info('Email queue processor: Checking for pending emails...');
const result = { processed: 0, sent: 0, failed: 0 };
processorStatus.lastRunAt = new Date().toISOString();
processorStatus.lastError = null;
try {
// Try to initialize transporter if it's null (in case it failed at startup).
@@ -937,6 +960,10 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
// #1262 — the row stays pending with retry_count 0, so nothing in the
// queue itself records that this pass did nothing. Say so here.
processorStatus.lastError = 'Email transporter could not be initialised — check the SMTP settings';
processorStatus.lastResult = result;
return result;
}
}
@@ -969,6 +996,8 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
.limit(limit);
} catch (dbError) {
logger.error('Failed to query email queue:', dbError);
processorStatus.lastError = dbError.message;
processorStatus.lastResult = result;
return result;
}
@@ -1043,8 +1072,10 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
}
} catch (error) {
logger.error('Error processing email queue:', error);
processorStatus.lastError = error.message;
}
processorStatus.lastResult = result;
return result;
}
@@ -1203,6 +1234,7 @@ function startEmailQueueProcessor() {
});
}, 60000);
processorStatus.started = true;
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
@@ -1213,6 +1245,7 @@ function stopEmailQueueProcessor() {
if (emailQueueInterval) {
clearInterval(emailQueueInterval);
emailQueueInterval = null;
processorStatus.started = false;
logger.info('Email queue processor stopped');
}
}
@@ -1231,6 +1264,7 @@ module.exports = {
sendRawEmail,
renderQueuedEmail,
processEmailQueue,
getQueueProcessorStatus,
queueEmail,
stopEmailQueueProcessor,
testEmailConnection,