mikser-io-post-email 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
3
4
  import nodemailer from 'nodemailer'
4
5
  import {
5
6
  runtime,
@@ -14,6 +15,10 @@ import {
14
15
  humanizeMs,
15
16
  resolveAddresses,
16
17
  decideTiming,
18
+ deliveryHash,
19
+ markerName,
20
+ formatMarker,
21
+ isDelivered,
17
22
  } from './lib/pure.js'
18
23
 
19
24
  // Re-export pure helpers so callers (and tests) can import them
@@ -36,6 +41,7 @@ registerSchema('post_email', `
36
41
  CREATE TABLE IF NOT EXISTS mikser_post_email_queue (
37
42
  id TEXT PRIMARY KEY REFERENCES mikser_entities(id) ON DELETE CASCADE,
38
43
  eml_path TEXT NOT NULL,
44
+ eml_hash TEXT,
39
45
  send_at INTEGER NOT NULL,
40
46
  sent_at INTEGER,
41
47
  expired_at INTEGER,
@@ -68,20 +74,64 @@ async function composeEml({ from, to, cc, bcc, subject, html }) {
68
74
  return built.message
69
75
  }
70
76
 
77
+ // ---------- send-once ledger -----------------------------------------
78
+ //
79
+ // Durable on-disk delivery markers, so a rebuild never re-sends an email
80
+ // already delivered. The queue table can't gate this: mikser wipes its
81
+ // cache on a config change, and the queue rows cascade off
82
+ // mikser_entities — both drop sent state, and every submission then
83
+ // re-renders and re-fires. So the marker lives OUTSIDE the cache, in a
84
+ // dedicated folder (default `emails/` under the working folder,
85
+ // overridable via `sentFolder`). Mirrors the assets plugin's `.md5`
86
+ // sidecars. Keep this folder out of source control and out of the
87
+ // deploy's delete set.
88
+ const DEFAULT_SENT_FOLDER = 'emails'
89
+
90
+ function sentFolder(config) {
91
+ const folder = config.sentFolder ?? DEFAULT_SENT_FOLDER
92
+ return path.isAbsolute(folder)
93
+ ? folder
94
+ : path.join(runtime.options.workingFolder, folder)
95
+ }
96
+
97
+ function markerFile(config, id) {
98
+ return path.join(sentFolder(config), markerName(id))
99
+ }
100
+
101
+ // Has this exact content (at/above the current revision) already been
102
+ // delivered for this id? Disk is the source of truth — it survives the
103
+ // cache-wipe that the DB does not.
104
+ async function alreadySent(config, id, hash) {
105
+ const file = markerFile(config, id)
106
+ if (!existsSync(file)) return false
107
+ try {
108
+ return isDelivered(await readFile(file, 'utf8'), config.revision ?? 1, hash)
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ async function recordSent(config, id, hash) {
115
+ const file = markerFile(config, id)
116
+ await mkdir(path.dirname(file), { recursive: true })
117
+ await writeFile(file, formatMarker(config.revision ?? 1, hash))
118
+ }
119
+
71
120
  // ---------- queue ops ------------------------------------------------
72
121
 
73
- function upsertQueueRow({ id, emlPath, sendAt }) {
122
+ function upsertQueueRow({ id, emlPath, emlHash, sendAt }) {
74
123
  useDatabase().handle.prepare(`
75
- INSERT INTO mikser_post_email_queue (id, eml_path, send_at)
76
- VALUES (?, ?, ?)
124
+ INSERT INTO mikser_post_email_queue (id, eml_path, eml_hash, send_at)
125
+ VALUES (?, ?, ?, ?)
77
126
  ON CONFLICT(id) DO UPDATE SET
78
127
  send_at = excluded.send_at,
79
128
  eml_path = excluded.eml_path,
129
+ eml_hash = excluded.eml_hash,
80
130
  sent_at = NULL,
81
131
  expired_at = NULL,
82
132
  attempts = 0,
83
133
  last_error = NULL
84
- `).run(id, emlPath, sendAt)
134
+ `).run(id, emlPath, emlHash, sendAt)
85
135
  }
86
136
 
87
137
  function recordExpiredInBand({ id, emlPath, sendAt, reason }) {
@@ -120,7 +170,7 @@ async function drain({ config, logger }) {
120
170
  `).run(now - retentionMs, now - retentionMs)
121
171
 
122
172
  const due = db.handle.prepare(`
123
- SELECT id, eml_path, send_at FROM mikser_post_email_queue
173
+ SELECT id, eml_path, eml_hash, send_at FROM mikser_post_email_queue
124
174
  WHERE sent_at IS NULL AND expired_at IS NULL AND send_at <= ?
125
175
  ORDER BY send_at
126
176
  `).all(now)
@@ -139,6 +189,15 @@ async function drain({ config, logger }) {
139
189
  continue
140
190
  }
141
191
 
192
+ // Durable send-once guard: an on-disk marker means this exact
193
+ // content was already delivered (survives the cache-wipe that
194
+ // reset the row's sent_at). Mark sent and skip re-delivery.
195
+ if (row.eml_hash && await alreadySent(config, row.id, row.eml_hash)) {
196
+ markSent(row.id)
197
+ logger.info('postEmail: %s already delivered, skipping', row.id)
198
+ continue
199
+ }
200
+
142
201
  try {
143
202
  const emlAbs = path.isAbsolute(row.eml_path)
144
203
  ? row.eml_path
@@ -148,6 +207,7 @@ async function drain({ config, logger }) {
148
207
  logger.info('postEmail: [dryRun] would deliver %s', row.id)
149
208
  } else {
150
209
  await transport.sendMail({ raw })
210
+ if (row.eml_hash) await recordSent(config, row.id, row.eml_hash)
151
211
  }
152
212
  markSent(row.id)
153
213
  logger.info('postEmail: delivered %s', row.id)
@@ -188,23 +248,37 @@ export async function postprocess({ entity, options, config, logger }) {
188
248
  const eml = await composeEml({ from, to, cc, bcc, subject, html })
189
249
  await writeFile(outputPath, eml)
190
250
 
251
+ // Send-once identity for this delivery (semantic fields, not the .eml
252
+ // bytes — those carry a fresh Message-ID/Date every compose).
253
+ const hash = deliveryHash({ from, to, cc, bcc, subject, html })
254
+
255
+ // Already delivered this exact content? Skip delivery — whatever the
256
+ // timing. The .eml audit file above is still refreshed; only the send
257
+ // is suppressed. This is what stops a rebuild (e.g. after a
258
+ // config-change cache-wipe) from re-firing the whole submission backlog.
259
+ if (await alreadySent(config, entity.id, hash)) {
260
+ logger.info('postEmail: %s already delivered, skipping', entity.id)
261
+ return { success: true, result: entity.destination }
262
+ }
263
+
191
264
  const maxDelayMs = parseDuration(entity.meta?.maxDelay ?? config.maxDelay, DEFAULT_MAX_DELAY_MS)
192
265
  const timing = decideTiming({ meta: entity.meta ?? {}, maxDelayMs })
193
266
 
194
267
  if (timing.mode === 'queue') {
195
- upsertQueueRow({ id: entity.id, emlPath: entity.destination, sendAt: timing.sendAt })
268
+ upsertQueueRow({ id: entity.id, emlPath: entity.destination, emlHash: hash, sendAt: timing.sendAt })
196
269
  logger.info('postEmail: queued %s for %s', entity.id, new Date(timing.sendAt).toISOString())
197
270
  } else if (timing.mode === 'expired') {
198
271
  const reason = `overdue by ${humanizeMs(timing.overdueMs)}, past maxDelay ${humanizeMs(maxDelayMs)}`
199
272
  recordExpiredInBand({ id: entity.id, emlPath: entity.destination, sendAt: timing.sendAt, reason })
200
273
  logger.warn('postEmail: %s expired in-band — %s', entity.id, reason)
201
274
  } else {
202
- // mode === 'now' — deliver synchronously.
275
+ // mode === 'now' — deliver synchronously, once.
203
276
  try {
204
277
  if (config.dryRun) {
205
278
  logger.info('postEmail: [dryRun] would deliver %s', entity.id)
206
279
  } else {
207
280
  await transport.sendMail({ from, to, cc, bcc, subject, html })
281
+ await recordSent(config, entity.id, hash)
208
282
  }
209
283
  logger.info('postEmail: delivered %s', entity.id)
210
284
  } catch (err) {
@@ -225,6 +299,13 @@ export function postEmail(config = {}) {
225
299
  const logger = useLogger()
226
300
  transport = nodemailer.createTransport(config.transport ?? { jsonTransport: true })
227
301
 
302
+ // Migrate pre-1.1 installs: the queue table predates eml_hash, and
303
+ // CREATE TABLE IF NOT EXISTS won't add a column to an existing one.
304
+ try {
305
+ const db = useDatabase()
306
+ if (db?.isOpen) db.handle.exec(`ALTER TABLE mikser_post_email_queue ADD COLUMN eml_hash TEXT`)
307
+ } catch { /* column already present */ }
308
+
228
309
  onFinalized(async () => {
229
310
  try {
230
311
  await drain({ config, logger })
@@ -252,22 +333,20 @@ export function postEmail(config = {}) {
252
333
  }
253
334
  })
254
335
 
336
+ // No `teardown` in the descriptor — deliberately. The engine calls
337
+ // a postprocessor's teardown in a `finally` at the END OF EVERY
338
+ // postprocess phase (once per cycle), which is right for plugins
339
+ // that acquire per-cycle resources in a matching setup() (post-pdf's
340
+ // Puppeteer browser). This plugin's `transport` + `drainTimer` are
341
+ // PROCESS-LIFETIME — created once at onLoaded, no setup() to
342
+ // recreate them — so a per-cycle teardown would null the transport
343
+ // after the first cycle and every subsequent send would crash on a
344
+ // null handle. Both resources die cleanly at process exit
345
+ // (drainTimer is unref'd; the socket closes with the process).
255
346
  return {
256
347
  name: config.name ?? 'email',
257
348
  output,
258
349
  options: config,
259
350
  postprocess,
260
- teardown,
261
- }
262
- }
263
-
264
- export async function teardown() {
265
- if (drainTimer) {
266
- clearInterval(drainTimer)
267
- drainTimer = null
268
- }
269
- if (transport?.close) {
270
- try { transport.close() } catch { /* not all transports expose close */ }
271
351
  }
272
- transport = null
273
352
  }
package/lib/pure.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Pure helpers — no mikser-io, no nodemailer, no sqlite imports.
2
2
  // Kept separate so unit tests don't have to load the engine.
3
+ // (node:crypto is a built-in and deterministic, so it stays "pure".)
4
+ import { createHash } from 'node:crypto'
3
5
 
4
6
  export function parseDuration(value, fallback) {
5
7
  if (value == null) return fallback
@@ -89,3 +91,49 @@ export function decideTiming({ meta, maxDelayMs, now = Date.now() }) {
89
91
  if (now - ts <= maxDelayMs) return { mode: 'now', sendAt: ts }
90
92
  return { mode: 'expired', sendAt: ts, overdueMs: now - ts }
91
93
  }
94
+
95
+ // ---------- send-once ledger (pure parts) ----------------------------
96
+ //
97
+ // A durable on-disk marker lets postEmail skip re-sending an email it
98
+ // already delivered — even after a mikser cache-wipe, which clears the
99
+ // queue table (its rows cascade off mikser_entities). Without this a
100
+ // rebuild re-renders every email document and re-fires it. These are the
101
+ // pure pieces; the fs lives in index.js (see sentFolder handling).
102
+
103
+ // Stable content hash identifying a delivery. Deliberately NOT the
104
+ // composed .eml bytes: nodemailer stamps a fresh Message-ID and Date on
105
+ // every compose, so hashing the .eml would change on every build and
106
+ // defeat the guard. Hash the semantic fields — same submission ⇒ same
107
+ // hash, a genuinely edited one ⇒ new hash ⇒ resend.
108
+ export function deliveryHash({ from, to, cc, bcc, subject, html }) {
109
+ const norm = v => Array.isArray(v) ? v.join(',') : (v ?? '')
110
+ const payload = JSON.stringify({
111
+ from: norm(from), to: norm(to), cc: norm(cc), bcc: norm(bcc),
112
+ subject: subject ?? '', html: html ?? '',
113
+ })
114
+ return createHash('sha256').update(payload).digest('hex')
115
+ }
116
+
117
+ // One marker file per delivery identity. Ids carry slashes and other
118
+ // path-hostile characters, so collapse anything non-portable to '_'.
119
+ export function markerName(id) {
120
+ return `${String(id).replace(/[^A-Za-z0-9._-]/g, '_')}.sent`
121
+ }
122
+
123
+ // Marker body: "<revision>:<hash>". Bumping config.revision invalidates
124
+ // every marker at once (force-resend), mirroring the assets preset's
125
+ // `revision` export.
126
+ export function formatMarker(revision, hash) {
127
+ return `${revision}:${hash}`
128
+ }
129
+
130
+ // "Already delivered this exact content, at or above the current
131
+ // revision?" — the decision a marker body encodes.
132
+ export function isDelivered(markerBody, revision, hash) {
133
+ if (typeof markerBody !== 'string') return false
134
+ const idx = markerBody.indexOf(':')
135
+ if (idx === -1) return false
136
+ const rev = Number.parseInt(markerBody.slice(0, idx), 10)
137
+ const stored = markerBody.slice(idx + 1)
138
+ return Number.isFinite(rev) && rev >= revision && stored === hash
139
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-post-email",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Email postprocessor for mikser-io — sends rendered output via SMTP and writes .eml audit files. Composes after post-mjml in a chain.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/test/unit.test.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  parseDuration, humanizeMs,
8
8
  resolveSpec, dedupe, resolveAddresses,
9
9
  decideTiming,
10
+ deliveryHash, markerName, formatMarker, isDelivered,
10
11
  } from '../lib/pure.js'
11
12
 
12
13
  describe('parseDuration', () => {
@@ -173,3 +174,53 @@ describe('decideTiming', () => {
173
174
  assert.throws(() => decideTiming({ meta: { sendAt: 'soon' }, maxDelayMs: oneHour, now }))
174
175
  })
175
176
  })
177
+
178
+ describe('deliveryHash', () => {
179
+ const base = { from: 'me@x.com', to: 'a@x.com', subject: 'Hi', html: '<p>hi</p>' }
180
+ it('is deterministic for the same content', () => {
181
+ assert.equal(deliveryHash(base), deliveryHash({ ...base }))
182
+ })
183
+ it('changes when subject or body changes', () => {
184
+ assert.notEqual(deliveryHash(base), deliveryHash({ ...base, subject: 'Hi!' }))
185
+ assert.notEqual(deliveryHash(base), deliveryHash({ ...base, html: '<p>bye</p>' }))
186
+ })
187
+ it('treats array and joined-string recipients the same', () => {
188
+ assert.equal(
189
+ deliveryHash({ ...base, to: ['a@x.com', 'b@x.com'] }),
190
+ deliveryHash({ ...base, to: 'a@x.com,b@x.com' }),
191
+ )
192
+ })
193
+ it('returns a 64-char hex sha256', () => {
194
+ assert.match(deliveryHash(base), /^[0-9a-f]{64}$/)
195
+ })
196
+ })
197
+
198
+ describe('markerName', () => {
199
+ it('collapses path-hostile characters to _', () => {
200
+ assert.equal(markerName('/franchise/123-request'), '_franchise_123-request.sent')
201
+ })
202
+ it('keeps portable characters', () => {
203
+ assert.equal(markerName('a.B_9-x'), 'a.B_9-x.sent')
204
+ })
205
+ })
206
+
207
+ describe('formatMarker / isDelivered', () => {
208
+ const h = 'a'.repeat(64)
209
+ it('round-trips a delivered marker', () => {
210
+ assert.equal(isDelivered(formatMarker(1, h), 1, h), true)
211
+ })
212
+ it('treats a higher stored revision as delivered', () => {
213
+ assert.equal(isDelivered(formatMarker(3, h), 2, h), true)
214
+ })
215
+ it('rejects a lower stored revision (force-resend on bump)', () => {
216
+ assert.equal(isDelivered(formatMarker(1, h), 2, h), false)
217
+ })
218
+ it('rejects a different hash (content changed)', () => {
219
+ assert.equal(isDelivered(formatMarker(1, h), 1, 'b'.repeat(64)), false)
220
+ })
221
+ it('rejects malformed or missing markers', () => {
222
+ assert.equal(isDelivered('', 1, h), false)
223
+ assert.equal(isDelivered('garbage', 1, h), false)
224
+ assert.equal(isDelivered(undefined, 1, h), false)
225
+ })
226
+ })