delivery-tracker.js 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { open, stat } from 'node:fs/promises';
  2. import { updateSendEventDelivery } from './db.js';
  3. const queueIdPattern = /\bqueued as\s+([A-Z0-9]{5,})\b/i;
  4. const deliveryServicePattern = /postfix\/(?:smtp|lmtp|local|virtual|pipe)\[\d+\]:\s+([A-Z0-9]{5,}):\s+(.+)$/i;
  5. export function extractQueueIdFromSmtpResponse(message) {
  6. const match = String(message || '').match(queueIdPattern);
  7. return match ? match[1].toUpperCase() : '';
  8. }
  9. export function parsePostfixLogLine(line) {
  10. const serviceMatch = String(line || '').match(deliveryServicePattern);
  11. if (!serviceMatch) return null;
  12. const queueId = serviceMatch[1].toUpperCase();
  13. const body = serviceMatch[2] || '';
  14. const status = fieldValue(body, 'status');
  15. if (!status) return null;
  16. const recipient = bracketFieldValue(body, 'to');
  17. const relay = fieldValue(body, 'relay');
  18. const dsn = fieldValue(body, 'dsn');
  19. const response = body.match(/\bstatus=[a-z]+\s+\((.*)\)\s*$/i)?.[1] || '';
  20. return {
  21. at: new Date().toISOString(),
  22. source: 'postfix',
  23. queueId,
  24. recipient,
  25. relay,
  26. dsn,
  27. status: normalizePostfixStatus(status),
  28. response,
  29. raw: String(line || '')
  30. };
  31. }
  32. export function startPostfixDeliveryTracker({
  33. enabled = true,
  34. logFile,
  35. pollIntervalMs = 5000,
  36. onDelivery = updateSendEventDelivery,
  37. logger = console
  38. } = {}) {
  39. if (!enabled || !logFile) return null;
  40. const state = {
  41. offset: 0,
  42. carry: '',
  43. stopped: false,
  44. polling: false
  45. };
  46. async function poll() {
  47. if (state.stopped || state.polling) return;
  48. state.polling = true;
  49. try {
  50. const chunk = await readNewChunk(logFile, state);
  51. if (!chunk) return;
  52. const lines = `${state.carry}${chunk}`.split(/\r?\n/);
  53. state.carry = lines.pop() || '';
  54. for (const line of lines) {
  55. const event = parsePostfixLogLine(line);
  56. if (event) onDelivery(event.queueId, event);
  57. }
  58. } catch (error) {
  59. logger.warn?.(`Delivery tracker could not read Postfix log: ${error.message}`);
  60. } finally {
  61. state.polling = false;
  62. }
  63. }
  64. const timer = setInterval(poll, pollIntervalMs);
  65. timer.unref?.();
  66. poll();
  67. return {
  68. stop() {
  69. state.stopped = true;
  70. clearInterval(timer);
  71. },
  72. poll
  73. };
  74. }
  75. async function readNewChunk(logFile, state) {
  76. const info = await stat(logFile).catch((error) => {
  77. if (error.code === 'ENOENT') return null;
  78. throw error;
  79. });
  80. if (!info || !info.isFile()) return '';
  81. if (info.size < state.offset) state.offset = 0;
  82. if (info.size === state.offset) return '';
  83. const length = info.size - state.offset;
  84. const buffer = Buffer.alloc(length);
  85. const handle = await open(logFile, 'r');
  86. try {
  87. await handle.read(buffer, 0, length, state.offset);
  88. } finally {
  89. await handle.close();
  90. }
  91. state.offset = info.size;
  92. return buffer.toString('utf8');
  93. }
  94. function fieldValue(body, key) {
  95. return String(body || '').match(new RegExp(`\\b${key}=([^,\\s]+)`, 'i'))?.[1] || '';
  96. }
  97. function bracketFieldValue(body, key) {
  98. return String(body || '').match(new RegExp(`\\b${key}=<([^>]+)>`, 'i'))?.[1] || '';
  99. }
  100. function normalizePostfixStatus(status) {
  101. const value = String(status || '').toLowerCase();
  102. if (['sent', 'deferred', 'bounced'].includes(value)) return value;
  103. return value || 'unknown';
  104. }