|
|
@@ -53,7 +53,10 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
subject TEXT NOT NULL,
|
|
|
status TEXT NOT NULL,
|
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
|
+ queue_id TEXT NOT NULL DEFAULT '',
|
|
|
delivery_log_json TEXT NOT NULL DEFAULT '[]',
|
|
|
+ delivery_attempts_json TEXT NOT NULL DEFAULT '[]',
|
|
|
+ delivered_at TEXT,
|
|
|
created_at TEXT NOT NULL,
|
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
|
|
|
);
|
|
|
@@ -105,12 +108,17 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
ensureColumn('domains', 'user_id', 'INTEGER');
|
|
|
ensureColumn('domains', 'dns_credential_id', 'INTEGER');
|
|
|
ensureColumn('send_events', 'user_id', 'INTEGER');
|
|
|
+ ensureColumn('send_events', 'queue_id', "TEXT NOT NULL DEFAULT ''");
|
|
|
ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
|
|
|
+ ensureColumn('send_events', 'delivery_attempts_json', "TEXT NOT NULL DEFAULT '[]'");
|
|
|
+ ensureColumn('send_events', 'delivered_at', 'TEXT');
|
|
|
ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
|
|
|
db.exec(`
|
|
|
CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_events_queue_id ON send_events(queue_id);
|
|
|
`);
|
|
|
+ normalizeSendEventQueueIds();
|
|
|
normalizeDkimPublicKeys();
|
|
|
return db;
|
|
|
}
|
|
|
@@ -326,10 +334,14 @@ export function deleteDomain(id, userId) {
|
|
|
}
|
|
|
|
|
|
export function logSendEvent(event) {
|
|
|
+ const queueId = normalizeQueueId(event.queueId || extractQueueIdFromText(event.detail));
|
|
|
const result = requireDb()
|
|
|
.prepare(`
|
|
|
- INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, delivery_log_json, created_at)
|
|
|
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ INSERT INTO send_events (
|
|
|
+ user_id, domain_id, sender, recipients, subject, status, detail, queue_id,
|
|
|
+ delivery_log_json, delivery_attempts_json, delivered_at, created_at
|
|
|
+ )
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
`)
|
|
|
.run(
|
|
|
event.userId ?? null,
|
|
|
@@ -339,12 +351,37 @@ export function logSendEvent(event) {
|
|
|
event.subject,
|
|
|
event.status,
|
|
|
event.detail ?? '',
|
|
|
+ queueId,
|
|
|
JSON.stringify(Array.isArray(event.deliveryLog) ? event.deliveryLog : []),
|
|
|
+ JSON.stringify(Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : []),
|
|
|
+ event.deliveredAt ?? null,
|
|
|
now()
|
|
|
);
|
|
|
return result.lastInsertRowid;
|
|
|
}
|
|
|
|
|
|
+export function updateSendEventDelivery(queueId, attempt) {
|
|
|
+ const cleanQueueId = normalizeQueueId(queueId || attempt?.queueId);
|
|
|
+ if (!cleanQueueId) return false;
|
|
|
+ const row = requireDb().prepare('SELECT * FROM send_events WHERE queue_id = ? ORDER BY id DESC LIMIT 1').get(cleanQueueId);
|
|
|
+ if (!row) return false;
|
|
|
+ const normalizedAttempt = normalizeDeliveryAttempt(attempt, cleanQueueId);
|
|
|
+ const attempts = safeJson(row.delivery_attempts_json, []);
|
|
|
+ if (attempts.some((item) => deliveryAttemptKey(item) === deliveryAttemptKey(normalizedAttempt))) return true;
|
|
|
+ const nextAttempts = [...attempts, normalizedAttempt];
|
|
|
+ const recipients = safeJson(row.recipients, []);
|
|
|
+ const nextStatus = deliveryStatusForEvent(recipients, nextAttempts, row.status);
|
|
|
+ const deliveredAt = nextStatus === 'sent' ? normalizedAttempt.at : row.delivered_at;
|
|
|
+ requireDb()
|
|
|
+ .prepare(`
|
|
|
+ UPDATE send_events
|
|
|
+ SET status = ?, detail = ?, delivery_attempts_json = ?, delivered_at = ?
|
|
|
+ WHERE id = ?
|
|
|
+ `)
|
|
|
+ .run(nextStatus, deliveryAttemptDetail(normalizedAttempt), JSON.stringify(nextAttempts), deliveredAt, row.id);
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
export function listSendEvents(userId, limit = 30) {
|
|
|
return requireDb()
|
|
|
.prepare(`
|
|
|
@@ -366,7 +403,10 @@ export function listSendEvents(userId, limit = 30) {
|
|
|
subject: row.subject,
|
|
|
status: row.status,
|
|
|
detail: row.detail,
|
|
|
+ queueId: row.queue_id,
|
|
|
deliveryLog: safeJson(row.delivery_log_json, []),
|
|
|
+ deliveryAttempts: safeJson(row.delivery_attempts_json, []),
|
|
|
+ deliveredAt: row.delivered_at,
|
|
|
createdAt: row.created_at
|
|
|
}));
|
|
|
}
|
|
|
@@ -414,7 +454,7 @@ export function getSendAnalytics(userId, { days = 30 } = {}) {
|
|
|
const createdAt = new Date(row.created_at);
|
|
|
const dayKey = row.created_at.slice(0, 10);
|
|
|
const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
|
|
|
- const isQueued = status === 'queued';
|
|
|
+ const isQueued = ['queued', 'sent'].includes(status);
|
|
|
|
|
|
recipients += recipientCount;
|
|
|
queued += isQueued ? 1 : 0;
|
|
|
@@ -451,7 +491,7 @@ export function getSendAnalytics(userId, { days = 30 } = {}) {
|
|
|
|
|
|
const recentFailures = [...rows]
|
|
|
.reverse()
|
|
|
- .filter((row) => row.status !== 'queued')
|
|
|
+ .filter((row) => isDeliveryFailureStatus(row.status))
|
|
|
.slice(0, 8)
|
|
|
.map((row) => ({
|
|
|
id: row.id,
|
|
|
@@ -723,6 +763,18 @@ function normalizeDkimPublicKeys() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function normalizeSendEventQueueIds() {
|
|
|
+ if (!tableExists('send_events') || !columnExists('send_events', 'queue_id')) return;
|
|
|
+ const rows = requireDb()
|
|
|
+ .prepare("SELECT id, detail FROM send_events WHERE queue_id = '' OR queue_id IS NULL")
|
|
|
+ .all();
|
|
|
+ const update = requireDb().prepare('UPDATE send_events SET queue_id = ? WHERE id = ?');
|
|
|
+ for (const row of rows) {
|
|
|
+ const queueId = extractQueueIdFromText(row.detail);
|
|
|
+ if (queueId) update.run(queueId, row.id);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
function publicUser(row) {
|
|
|
if (!row) return null;
|
|
|
return {
|
|
|
@@ -877,6 +929,67 @@ function safeJson(value, fallback) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function normalizeQueueId(value) {
|
|
|
+ return String(value || '').trim().toUpperCase();
|
|
|
+}
|
|
|
+
|
|
|
+function extractQueueIdFromText(value) {
|
|
|
+ return String(value || '').match(/\bqueued as\s+([A-Z0-9]{5,})\b/i)?.[1]?.toUpperCase() || '';
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeDeliveryAttempt(attempt, queueId) {
|
|
|
+ return {
|
|
|
+ at: attempt?.at || now(),
|
|
|
+ source: attempt?.source || 'postfix',
|
|
|
+ queueId,
|
|
|
+ recipient: String(attempt?.recipient || '').toLowerCase(),
|
|
|
+ relay: String(attempt?.relay || ''),
|
|
|
+ dsn: String(attempt?.dsn || ''),
|
|
|
+ status: String(attempt?.status || 'unknown').toLowerCase(),
|
|
|
+ response: String(attempt?.response || ''),
|
|
|
+ raw: String(attempt?.raw || '')
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function deliveryAttemptKey(attempt) {
|
|
|
+ return attempt.raw || [
|
|
|
+ attempt.queueId,
|
|
|
+ attempt.recipient,
|
|
|
+ attempt.status,
|
|
|
+ attempt.dsn,
|
|
|
+ attempt.response
|
|
|
+ ].join('|');
|
|
|
+}
|
|
|
+
|
|
|
+function deliveryStatusForEvent(recipients, attempts, currentStatus) {
|
|
|
+ const byRecipient = new Map();
|
|
|
+ for (const attempt of attempts) {
|
|
|
+ if (attempt.recipient) byRecipient.set(String(attempt.recipient).toLowerCase(), attempt.status);
|
|
|
+ }
|
|
|
+ const normalizedRecipients = recipients.map((recipient) => String(recipient || '').toLowerCase()).filter(Boolean);
|
|
|
+ const statuses = normalizedRecipients.map((recipient) => byRecipient.get(recipient)).filter(Boolean);
|
|
|
+ if (normalizedRecipients.length && statuses.length === normalizedRecipients.length && statuses.every((status) => status === 'sent')) {
|
|
|
+ return 'sent';
|
|
|
+ }
|
|
|
+ if (statuses.includes('deferred')) return 'deferred';
|
|
|
+ if (statuses.includes('bounced')) return 'bounced';
|
|
|
+ return currentStatus || attempts.at(-1)?.status || 'queued';
|
|
|
+}
|
|
|
+
|
|
|
+function deliveryAttemptDetail(attempt) {
|
|
|
+ const parts = [
|
|
|
+ attempt.status,
|
|
|
+ attempt.recipient ? `to ${attempt.recipient}` : '',
|
|
|
+ attempt.relay ? `via ${attempt.relay}` : '',
|
|
|
+ attempt.dsn ? `dsn=${attempt.dsn}` : ''
|
|
|
+ ].filter(Boolean);
|
|
|
+ return `${parts.join(' ')}${attempt.response ? `; ${attempt.response}` : ''}`;
|
|
|
+}
|
|
|
+
|
|
|
+function isDeliveryFailureStatus(status) {
|
|
|
+ return ['deferred', 'bounced', 'failed'].includes(String(status || '').toLowerCase());
|
|
|
+}
|
|
|
+
|
|
|
function now() {
|
|
|
return new Date().toISOString();
|
|
|
}
|