mikser-io-post-email 1.1.0 → 2.0.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/README.md CHANGED
@@ -168,7 +168,59 @@ With no `transport` provided, the plugin builds a JSON transport — useful for
168
168
  postEmail({ dryRun: process.env.NODE_ENV !== 'production' })
169
169
  ```
170
170
 
171
- `dryRun: true` writes every `.eml` (so authors can review them in the output folder) but skips `transport.sendMail()`. The queue still records deliveries (with `sent_at`) for observability.
171
+ `dryRun: true` writes every `.eml` (so authors can review them in the output folder) but skips `transport.sendMail()`. The queue still records deliveries (with `sent_at`) for observability. No send-once marker is written, so a dry run never suppresses a later real delivery.
172
+
173
+ ## Send-once — durable delivery markers
174
+
175
+ An email is delivered **once**, even across rebuilds. After a successful send
176
+ the plugin writes a marker file into `sentFolder` (default `emails/` in the
177
+ working folder); on every later build the marker suppresses re-delivery.
178
+
179
+ This can't live in the queue table: mikser wipes its cache whenever
180
+ `mikser.config.js` changes, and the queue rows cascade off `mikser_entities`.
181
+ Both drop the sent state, so before markers existed a rebuild re-rendered every
182
+ email document and re-sent the lot — a whole backlog of form submissions at
183
+ once. The marker is on disk for the same reason the assets plugin keeps `.md5`
184
+ sidecars there: it has to outlive the cache.
185
+
186
+ ```js
187
+ postEmail({
188
+ from: 'Site <info@example.com>',
189
+ sentFolder: 'emails', // default; relative to the working folder, or absolute
190
+ revision: 1, // bump to invalidate every marker and allow a resend
191
+ })
192
+ ```
193
+
194
+ **Keep `sentFolder` out of source control and out of any deploy that deletes
195
+ what it doesn't ship.** It is runtime delivery state, not build cache — unlike
196
+ `assets/`, "who we already emailed" must never be committed, and if a deploy's
197
+ `rsync --delete` removes it the backlog re-sends. Treat it like `runtime/`:
198
+ add it to `.gitignore` and to the deploy's exclude list.
199
+
200
+ ### What counts as "the same email"
201
+
202
+ The delivery identity is a hash of `from`, `to`, `cc`, `bcc`, `subject`, the
203
+ rendered body, and `sendAt` — not the composed `.eml` bytes, which carry a
204
+ fresh `Message-ID` and `Date` on every compose and would never match.
205
+
206
+ `sendAt` is part of it on purpose: a recurring email keeps its id and its body
207
+ and only moves its send time, so without it every occurrence after the first
208
+ would look already-delivered and be silently dropped.
209
+
210
+ **Volatile bodies.** If a template renders something that changes every build —
211
+ a timestamp, a random token, a cache-buster — its hash changes too and the
212
+ guard never matches. Name a stable identity in the entity's frontmatter
213
+ instead, and the body stops being part of it:
214
+
215
+ ```yaml
216
+ to: client@example.com
217
+ subject: Your receipt
218
+ deliveryKey: receipt-42
219
+ ```
220
+
221
+ ### Forcing a resend
222
+
223
+ Delete the entity's marker file, or bump `revision` to invalidate all of them.
172
224
 
173
225
  ## Options reference
174
226
 
@@ -182,7 +234,9 @@ postEmail({ dryRun: process.env.NODE_ENV !== 'production' })
182
234
  | `transport` | nodemailer config | JSON transport | Delivery target |
183
235
  | `maxDelay` | duration string | `'1h'` | How late past `sendAt` is still acceptable |
184
236
  | `retention` | duration string | `'90d'` | How long delivered/expired rows stay in the queue |
185
- | `dryRun` | boolean | `false` | Write `.eml`, skip transport |
237
+ | `sentFolder` | string | `'emails'` | Where send-once markers live; keep it gitignored and out of the deploy's delete set |
238
+ | `revision` | number | `1` | Bump to invalidate every marker (forces a resend) |
239
+ | `dryRun` | boolean | `false` | Write `.eml`, skip transport (and write no marker) |
186
240
 
187
241
  ## What it does NOT do (v1)
188
242
 
package/index.js CHANGED
@@ -117,6 +117,24 @@ async function recordSent(config, id, hash) {
117
117
  await writeFile(file, formatMarker(config.revision ?? 1, hash))
118
118
  }
119
119
 
120
+ // Record the marker AFTER the mail is already out, where a failure must
121
+ // never look like a delivery failure: the message has been handed to the
122
+ // transport and cannot be unsent. Losing the marker only risks a future
123
+ // duplicate (and only after a cache-wipe); treating it as a send failure
124
+ // would guarantee one — the queue path would keep the row due and
125
+ // re-deliver every drain, and the inline path would report a render
126
+ // failure for mail that was actually delivered. So: warn, don't throw.
127
+ async function recordSentSafely(config, id, hash, logger) {
128
+ try {
129
+ await recordSent(config, id, hash)
130
+ } catch (err) {
131
+ logger.warn(
132
+ 'postEmail: %s delivered but its send-once marker could not be written in %s — %s. ' +
133
+ 'A later rebuild may re-send it; check the folder exists and is writable.',
134
+ id, sentFolder(config), err.message || err)
135
+ }
136
+ }
137
+
120
138
  // ---------- queue ops ------------------------------------------------
121
139
 
122
140
  function upsertQueueRow({ id, emlPath, emlHash, sendAt }) {
@@ -198,6 +216,15 @@ async function drain({ config, logger }) {
198
216
  continue
199
217
  }
200
218
 
219
+ // Rows queued by < 1.1.0 predate eml_hash, so they carry no
220
+ // delivery identity and cannot be marked. They still send, but
221
+ // say so: this one row may re-send once after the upgrade.
222
+ if (!row.eml_hash) {
223
+ logger.warn(
224
+ 'postEmail: %s was queued before send-once tracking existed — delivering without a marker, ' +
225
+ 'so a rebuild may re-send it once.', row.id)
226
+ }
227
+
201
228
  try {
202
229
  const emlAbs = path.isAbsolute(row.eml_path)
203
230
  ? row.eml_path
@@ -205,11 +232,17 @@ async function drain({ config, logger }) {
205
232
  const raw = await readFile(emlAbs)
206
233
  if (config.dryRun) {
207
234
  logger.info('postEmail: [dryRun] would deliver %s', row.id)
235
+ markSent(row.id)
208
236
  } else {
209
237
  await transport.sendMail({ raw })
210
- if (row.eml_hash) await recordSent(config, row.id, row.eml_hash)
238
+ // Order matters: the mail is out, so retire the row FIRST.
239
+ // If the marker write were inside this try and threw, the
240
+ // catch below would markFailed() and leave the row due —
241
+ // re-delivering the same message every drain (~every 60s
242
+ // in watch mode) until it expires.
243
+ markSent(row.id)
244
+ if (row.eml_hash) await recordSentSafely(config, row.id, row.eml_hash, logger)
211
245
  }
212
- markSent(row.id)
213
246
  logger.info('postEmail: delivered %s', row.id)
214
247
  } catch (err) {
215
248
  markFailed(row.id, err)
@@ -250,7 +283,13 @@ export async function postprocess({ entity, options, config, logger }) {
250
283
 
251
284
  // Send-once identity for this delivery (semantic fields, not the .eml
252
285
  // bytes — those carry a fresh Message-ID/Date every compose).
253
- const hash = deliveryHash({ from, to, cc, bcc, subject, html })
286
+ // sendAt is part of it, so a rescheduled occurrence of a recurring
287
+ // email is a NEW delivery rather than a suppressed duplicate.
288
+ const hash = deliveryHash({
289
+ from, to, cc, bcc, subject, html,
290
+ sendAt: entity.meta?.sendAt,
291
+ deliveryKey: entity.meta?.deliveryKey,
292
+ })
254
293
 
255
294
  // Already delivered this exact content? Skip delivery — whatever the
256
295
  // timing. The .eml audit file above is still refreshed; only the send
@@ -278,7 +317,8 @@ export async function postprocess({ entity, options, config, logger }) {
278
317
  logger.info('postEmail: [dryRun] would deliver %s', entity.id)
279
318
  } else {
280
319
  await transport.sendMail({ from, to, cc, bcc, subject, html })
281
- await recordSent(config, entity.id, hash)
320
+ // Outside the throw path on purpose — see recordSentSafely.
321
+ await recordSentSafely(config, entity.id, hash, logger)
282
322
  }
283
323
  logger.info('postEmail: delivered %s', entity.id)
284
324
  } catch (err) {
@@ -301,10 +341,20 @@ export function postEmail(config = {}) {
301
341
 
302
342
  // Migrate pre-1.1 installs: the queue table predates eml_hash, and
303
343
  // CREATE TABLE IF NOT EXISTS won't add a column to an existing one.
344
+ //
345
+ // Only "already there" is expected and silent. Anything else — a
346
+ // locked or read-only database — must be said out loud: swallowing
347
+ // it leaves the table without the column, and the failure resurfaces
348
+ // later as an opaque "no column named eml_hash" from an INSERT
349
+ // inside postprocess, far from its cause.
304
350
  try {
305
351
  const db = useDatabase()
306
352
  if (db?.isOpen) db.handle.exec(`ALTER TABLE mikser_post_email_queue ADD COLUMN eml_hash TEXT`)
307
- } catch { /* column already present */ }
353
+ } catch (err) {
354
+ if (!/duplicate column/i.test(err.message || '')) {
355
+ logger.error('postEmail: could not add the eml_hash column — %s', err.message || err)
356
+ }
357
+ }
308
358
 
309
359
  onFinalized(async () => {
310
360
  try {
package/lib/pure.js CHANGED
@@ -100,24 +100,60 @@ export function decideTiming({ meta, maxDelayMs, now = Date.now() }) {
100
100
  // rebuild re-renders every email document and re-fires it. These are the
101
101
  // pure pieces; the fs lives in index.js (see sentFolder handling).
102
102
 
103
+ // `sendAt` is part of the delivery identity, but 'now' and "absent" mean
104
+ // the same thing, so they must normalize to the same value.
105
+ export function normalizeSendAt(value) {
106
+ return (value == null || value === 'now') ? null : String(value)
107
+ }
108
+
103
109
  // Stable content hash identifying a delivery. Deliberately NOT the
104
110
  // composed .eml bytes: nodemailer stamps a fresh Message-ID and Date on
105
111
  // every compose, so hashing the .eml would change on every build and
106
112
  // defeat the guard. Hash the semantic fields — same submission ⇒ same
107
113
  // hash, a genuinely edited one ⇒ new hash ⇒ resend.
108
- export function deliveryHash({ from, to, cc, bcc, subject, html }) {
114
+ //
115
+ // `sendAt` IS included: a recurring/rescheduled email keeps its id and
116
+ // its body and only moves its send time, and without it every occurrence
117
+ // after the first would hash identically and be suppressed — a silently
118
+ // lost email, which is worse than the duplicate this guard prevents.
119
+ //
120
+ // `deliveryKey` (entity frontmatter) pins the identity explicitly for
121
+ // templates whose rendered body is volatile — a timestamp, a random
122
+ // token, a cache-buster. Such a body hashes differently on every build,
123
+ // which would make the guard inert; naming a key opts out of hashing the
124
+ // body at all.
125
+ export function deliveryHash({ from, to, cc, bcc, subject, html, sendAt, deliveryKey }) {
109
126
  const norm = v => Array.isArray(v) ? v.join(',') : (v ?? '')
127
+ const keyed = deliveryKey != null
110
128
  const payload = JSON.stringify({
111
129
  from: norm(from), to: norm(to), cc: norm(cc), bcc: norm(bcc),
112
- subject: subject ?? '', html: html ?? '',
130
+ subject: subject ?? '',
131
+ key: keyed ? String(deliveryKey) : null,
132
+ html: keyed ? '' : (html ?? ''),
133
+ sendAt: normalizeSendAt(sendAt),
113
134
  })
114
135
  return createHash('sha256').update(payload).digest('hex')
115
136
  }
116
137
 
117
- // One marker file per delivery identity. Ids carry slashes and other
118
- // path-hostile characters, so collapse anything non-portable to '_'.
138
+ // One marker file per delivery identity.
139
+ //
140
+ // The sanitized id is for humans reading the folder; the appended digest
141
+ // is what makes the name UNIQUE. Sanitizing alone collides: every
142
+ // character outside the safe set becomes '_', so a fully non-ASCII id
143
+ // (Cyrillic document paths are normal here) collapses to a name that
144
+ // encodes only its length — '/бг/оферта' and '/бг/заявка' both became
145
+ // '__________.sent'. Colliding entities then overwrite each other's
146
+ // marker and BOTH re-send on every rebuild, defeating the guard, or —
147
+ // if their content matches — one is silently never sent.
148
+ //
149
+ // Also bounded: ids can be long and most filesystems cap a name at 255
150
+ // bytes. The digest is taken from the raw id, so truncation cannot
151
+ // merge two distinct ids.
119
152
  export function markerName(id) {
120
- return `${String(id).replace(/[^A-Za-z0-9._-]/g, '_')}.sent`
153
+ const raw = String(id)
154
+ const safe = raw.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
155
+ const digest = createHash('sha256').update(raw).digest('hex').slice(0, 12)
156
+ return `${safe}-${digest}.sent`
121
157
  }
122
158
 
123
159
  // Marker body: "<revision>:<hash>". Bumping config.revision invalidates
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-post-email",
3
- "version": "1.1.0",
3
+ "version": "2.0.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",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-post-email#readme",
20
20
  "peerDependencies": {
21
- "mikser-io": "^9.0.0"
21
+ "mikser-io": "^10.0.0"
22
22
  },
23
23
  "dependencies": {
24
24
  "nodemailer": "^6.9.0"
package/test/unit.test.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  parseDuration, humanizeMs,
8
8
  resolveSpec, dedupe, resolveAddresses,
9
9
  decideTiming,
10
- deliveryHash, markerName, formatMarker, isDelivered,
10
+ deliveryHash, markerName, formatMarker, isDelivered, normalizeSendAt,
11
11
  } from '../lib/pure.js'
12
12
 
13
13
  describe('parseDuration', () => {
@@ -195,12 +195,86 @@ describe('deliveryHash', () => {
195
195
  })
196
196
  })
197
197
 
198
+ describe('normalizeSendAt', () => {
199
+ it('treats absent and "now" as the same immediate send', () => {
200
+ assert.equal(normalizeSendAt(undefined), null)
201
+ assert.equal(normalizeSendAt(null), null)
202
+ assert.equal(normalizeSendAt('now'), null)
203
+ })
204
+ it('keeps a real timestamp', () => {
205
+ assert.equal(normalizeSendAt('2026-09-01T10:00:00Z'), '2026-09-01T10:00:00Z')
206
+ assert.equal(normalizeSendAt(1_700_000_000_000), '1700000000000')
207
+ })
208
+ })
209
+
210
+ describe('deliveryHash — sendAt is part of the identity', () => {
211
+ const base = { from: 'me@x.com', to: 'a@x.com', subject: 'Weekly digest', html: '<p>same</p>' }
212
+
213
+ // Regression: a recurring email keeps its id, recipients and body and
214
+ // only moves sendAt. Hashing without sendAt made every occurrence after
215
+ // the first look already-delivered, so it was silently never sent.
216
+ it('a rescheduled occurrence of identical content is a NEW delivery', () => {
217
+ const week1 = deliveryHash({ ...base, sendAt: '2026-09-01T08:00:00Z' })
218
+ const week2 = deliveryHash({ ...base, sendAt: '2026-09-08T08:00:00Z' })
219
+ assert.notEqual(week1, week2)
220
+ })
221
+ it('the same occurrence still hashes stably (rebuild ⇒ no resend)', () => {
222
+ const a = deliveryHash({ ...base, sendAt: '2026-09-01T08:00:00Z' })
223
+ const b = deliveryHash({ ...base, sendAt: '2026-09-01T08:00:00Z' })
224
+ assert.equal(a, b)
225
+ })
226
+ it('absent and "now" sendAt are equivalent', () => {
227
+ assert.equal(deliveryHash(base), deliveryHash({ ...base, sendAt: 'now' }))
228
+ })
229
+ })
230
+
231
+ describe('deliveryHash — deliveryKey pins volatile bodies', () => {
232
+ const base = { from: 'me@x.com', to: 'a@x.com', subject: 'Receipt' }
233
+
234
+ it('a changing body does not change the hash when a key is set', () => {
235
+ const a = deliveryHash({ ...base, html: '<p>generated 10:00:01</p>', deliveryKey: 'receipt-42' })
236
+ const b = deliveryHash({ ...base, html: '<p>generated 23:59:59</p>', deliveryKey: 'receipt-42' })
237
+ assert.equal(a, b)
238
+ })
239
+ it('a different key is a different delivery', () => {
240
+ const a = deliveryHash({ ...base, html: '<p>x</p>', deliveryKey: 'receipt-42' })
241
+ const b = deliveryHash({ ...base, html: '<p>x</p>', deliveryKey: 'receipt-43' })
242
+ assert.notEqual(a, b)
243
+ })
244
+ it('without a key the body still counts', () => {
245
+ assert.notEqual(
246
+ deliveryHash({ ...base, html: '<p>a</p>' }),
247
+ deliveryHash({ ...base, html: '<p>b</p>' }),
248
+ )
249
+ })
250
+ })
251
+
198
252
  describe('markerName', () => {
199
- it('collapses path-hostile characters to _', () => {
200
- assert.equal(markerName('/franchise/123-request'), '_franchise_123-request.sent')
253
+ it('keeps a readable prefix and appends a disambiguating digest', () => {
254
+ assert.match(markerName('/franchise/123-request'), /^_franchise_123-request-[0-9a-f]{12}\.sent$/)
201
255
  })
202
- it('keeps portable characters', () => {
203
- assert.equal(markerName('a.B_9-x'), 'a.B_9-x.sent')
256
+
257
+ // Regression: sanitizing alone mapped every unsafe char to '_', so ids
258
+ // that differ only in unsafe characters — including any two same-length
259
+ // Cyrillic paths — produced ONE marker file. Colliding entities then
260
+ // overwrote each other and both re-sent on every rebuild.
261
+ it('does not collide for same-length non-ASCII ids', () => {
262
+ assert.notEqual(markerName('/бг/оферта'), markerName('/бг/заявка'))
263
+ })
264
+ it('does not collide for ids differing only in unsafe characters', () => {
265
+ assert.notEqual(markerName('/mail/a:b'), markerName('/mail/a/b'))
266
+ })
267
+ it('is stable for the same id', () => {
268
+ assert.equal(markerName('/mail/x'), markerName('/mail/x'))
269
+ })
270
+ it('bounds the filename for very long ids', () => {
271
+ const name = markerName('/' + 'x'.repeat(500))
272
+ assert.ok(name.length <= 140, `too long: ${name.length}`)
273
+ })
274
+ it('still separates long ids that share a truncated prefix', () => {
275
+ const a = markerName('/' + 'x'.repeat(300) + 'a')
276
+ const b = markerName('/' + 'x'.repeat(300) + 'b')
277
+ assert.notEqual(a, b)
204
278
  })
205
279
  })
206
280