mikser-io-post-email 2.0.0 → 11.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.
@@ -0,0 +1,73 @@
1
+ # Publish to npm without a stored token.
2
+ #
3
+ # npm is retiring tokens that skip the second factor — its own token page says
4
+ # "Publish new versions directly (deprecated — ends January 2027)". This is the
5
+ # replacement: the job proves who it is at publish time with a short-lived
6
+ # credential GitHub issues and npm verifies, so npm trusts "the publish.yml
7
+ # workflow in this repository" rather than a string.
8
+ #
9
+ # There is no NODE_AUTH_TOKEN and no secret to configure. Nothing to leak,
10
+ # expire or rotate — the two ways publishing broke in September 2026.
11
+ #
12
+ # Link it once on npm: the package → Settings → Trusted Publisher → GitHub
13
+ # Actions, naming this repository and this filename. Until that exists the
14
+ # publish step fails rather than falling back to something weaker.
15
+
16
+ name: Publish
17
+
18
+ on:
19
+ push:
20
+ tags: ['v*']
21
+ workflow_dispatch:
22
+
23
+ jobs:
24
+ publish:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ # The whole mechanism. Without it npm has nothing to verify.
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '24'
36
+ registry-url: 'https://registry.npmjs.org'
37
+
38
+ # Trusted publishing landed in npm 11.5.1.
39
+ - name: Use an npm that can do trusted publishing
40
+ run: npm install -g npm@latest
41
+
42
+ # `npm install`, not `npm ci`: these packages are developed in a
43
+ # workspace, so dependencies hoist to its root and each package's own
44
+ # lockfile is never read locally. They drift silently, and CI is the only
45
+ # thing that would ever read them.
46
+ - run: npm install --no-audit --no-fund
47
+ env:
48
+ # puppeteer downloads a browser on install; nothing in the release
49
+ # path renders a page.
50
+ PUPPETEER_SKIP_DOWNLOAD: '1'
51
+
52
+ # The tag is the release. A tag that disagrees with package.json would
53
+ # publish a version nobody asked for.
54
+ - name: Tag must match package.json
55
+ if: startsWith(github.ref, 'refs/tags/v')
56
+ run: |
57
+ TAG="${GITHUB_REF_NAME#v}"
58
+ PKG="$(node -p 'require("./package.json").version')"
59
+ if [ "$TAG" != "$PKG" ]; then
60
+ echo "tag v$TAG does not match package.json $PKG"
61
+ exit 1
62
+ fi
63
+ echo "publishing $PKG"
64
+
65
+ # The suite runs here now. It did not before: these packages declared
66
+ # mikser-io only as a peerDependency (npm does not install a package's
67
+ # own peers) or as file:../mikser-io (a path that exists on a laptop and
68
+ # nowhere else), so a standalone clone could not resolve the engine it
69
+ # tests against. Both are now real devDependencies with version ranges,
70
+ # which npm still satisfies from the workspace locally.
71
+ - run: npm test
72
+
73
+ - run: npm publish --access public
package/README.md CHANGED
@@ -18,7 +18,8 @@ npm install mikser-io-post-email
18
18
 
19
19
  ```js
20
20
  // mikser.config.js
21
- import { documents, layouts, renderHbs, frontMatter } from 'mikser-io'
21
+ import { documents, renderHbs, frontMatter } from 'mikser-io'
22
+ import { layouts } from 'mikser-io-layouts'
22
23
  import { postMjml } from 'mikser-io-post-mjml'
23
24
  import { postEmail } from 'mikser-io-post-email'
24
25
 
@@ -45,11 +46,24 @@ export default {
45
46
  ---
46
47
  to: alice@acme.com
47
48
  subject: Welcome, Alice
48
- layout: welcome.html-mjml-email
49
+ layout: welcome # the layout's NAME — the chain suffixes are not part of it
49
50
  ---
50
51
  ```
51
52
 
52
- That's the transactional case: one entity, one recipient. The `.eml` lands at `out/welcome.eml` and the message ships immediately. On the next build, mikser's render manifest sees unchanged inputs and skips the whole chain — no resend.
53
+ That's the transactional case: one entity, one recipient. The `.eml` lands at `out/welcome.eml` and the message is queued for immediate delivery — see [Delivery is out of band](#delivery-is-out-of-band). On the next build, mikser's render manifest sees unchanged inputs and skips the whole chain — no resend.
54
+
55
+ ## Delivery is out of band
56
+
57
+ **Nothing is ever sent from inside the render pipeline.** A postprocessor writes the `.eml`, records a queue row, and returns; the transport is called later, by the drain.
58
+
59
+ That matters because a transport is a third-party service. Sending inline — which this plugin did through 11.0.x — made two things true:
60
+
61
+ - **Every cycle waited on the provider.** A build could not finish until every message in it had been acknowledged. One restart with a backlog spent it on 1515 sequential Mailgun round-trips, inside the pipeline, while the site's own requests queued behind it.
62
+ - **The provider could fail the build.** A rejection — a 429, an outage, a socket that never answered — threw out of `postprocess` and failed the entity. A provider having a bad minute became a broken render, and with no marker written and no backoff, the next cycle simply tried again.
63
+
64
+ Queuing costs one sqlite row and buys delivery that survives a crash mid-cycle, retries with backoff, a bounded per-message timeout, and a render that never waits on mail.
65
+
66
+ Promptness is unaffected at both ends. A one-shot `mikser` drains at `onFinalized`, so it still delivers before it exits. A resident instance (`--watch` or `--server`) drains on a 60-second timer, off the cycle — and *not* at `onFinalized`, because that hook is awaited inside the cycle and draining there would put the transport straight back on the critical path.
53
67
 
54
68
  ## Recipient lists — `@listname` references
55
69
 
@@ -114,9 +128,9 @@ Resolution against `maxDelay` (default `'1h'`):
114
128
 
115
129
  | `sendAt` | What happens |
116
130
  |---|---|
117
- | missing / `'now'` | Deliver immediately during the chain |
131
+ | missing / `'now'` | Write `.eml`, queue a row due now, deliver on the next drain |
118
132
  | future | Write `.eml`, queue a row, deliver on drain when due |
119
- | recent past (≤ `maxDelay`) | Catch-up: deliver immediately, warn nothing |
133
+ | recent past (≤ `maxDelay`) | Catch-up: same as `'now'` |
120
134
  | ancient past (> `maxDelay`) | Write `.eml`, mark `expired_at`, log warning, no delivery |
121
135
 
122
136
  `maxDelay` is overridable per-entity:
@@ -135,16 +149,19 @@ A persistent table (`mikser_post_email_queue` in `runtime/mikser.sqlite`) holds
135
149
  ```sql
136
150
  mikser_post_email_queue (
137
151
  id PRIMARY KEY → mikser_entities(id) ON DELETE CASCADE,
138
- eml_path,
139
- send_at, sent_at, expired_at,
140
- attempts, last_error
152
+ eml_path, eml_hash, payload,
153
+ send_at, next_attempt_at,
154
+ sent_at, expired_at,
155
+ attempts, last_error
141
156
  )
142
157
  ```
143
158
 
144
159
  - **PK on entity id + UPSERT** — re-editing `sendAt` reschedules in place; can't double-queue.
145
160
  - **FK CASCADE** — delete the source doc, queue row vanishes. Schedule follows the file.
146
- - **Drain triggers**: on startup, after every cycle (`onFinalized`), and every 60s in `--watch` mode.
147
- - **Failures stay queued**: `attempts` and `last_error` track retries; the next drain re-attempts.
161
+ - **`payload` is what gets delivered** — the message as fields, stored on the row. Not the `.eml`: that is an audit artifact, and re-reading it to send a raw message depended both on the output tree still holding the file (a `--clear`, or a deploy `rsync --delete`, removes it) and on the transport honouring nodemailer's `raw` field. Mailgun's does not — `nodemailer-mailgun-transport` applies a key whitelist with no `raw` in it, so the field is dropped and what reaches the API has no sender, no recipient and no body. Released once the row is delivered.
162
+ - **Drain triggers**: on startup; every 60s while resident (`--watch` **or** `--server`); after every cycle *only* when there is no timer, i.e. for a one-shot build.
163
+ - **One drain at a time.** Passes are serialised. A pass only marks a row sent once the transport has answered, so an overlapping pass would select the same unmarked rows and deliver them twice — and a backlog easily outlasts the 60s interval.
164
+ - **Failures back off**: `attempts` and `last_error` record what happened, and `next_attempt_at` holds the row until 1m × 2^attempts (capped at 15m). `send_at` is never moved — `maxDelay` is measured from it, so backing off must not make a row that keeps failing look permanently on-time and never expire.
148
165
  - **Retention**: delivered + expired rows are kept for `retention` (default `'90d'`) then pruned.
149
166
 
150
167
  ## Transport
@@ -233,6 +250,7 @@ Delete the entity's marker file, or bump `revision` to invalidate all of them.
233
250
  | `bcc` | spec | none | Global BCC, deduped with entity `bcc` |
234
251
  | `transport` | nodemailer config | JSON transport | Delivery target |
235
252
  | `maxDelay` | duration string | `'1h'` | How late past `sendAt` is still acceptable |
253
+ | `sendTimeout` | duration string \| number | `'30s'` | How long one message may wait on the transport. `0` waits forever |
236
254
  | `retention` | duration string | `'90d'` | How long delivered/expired rows stay in the queue |
237
255
  | `sentFolder` | string | `'emails'` | Where send-once markers live; keep it gitignored and out of the deploy's delete set |
238
256
  | `revision` | number | `1` | Bump to invalidate every marker (forces a resend) |
@@ -242,8 +260,10 @@ Delete the entity's marker file, or bump `revision` to invalidate all of them.
242
260
 
243
261
  - **No transport-native scheduling.** Setting `send_at` on SendGrid/Mailchimp/etc. is not exposed — when an entity is rescheduled (frontmatter edited), the transport would hold both the old and new send. The internal queue dedupes via PK; transport-native scheduling can't.
244
262
  - **No rename-preserving queue.** A file rename = old entity DELETE + new entity INSERT; the old queue row cascades out, the new one's queue row inherits the renamed file's `sendAt`. Acceptable for v1.
245
- - **No throttling.** If 10k pending sends drain at once after long downtime, that's transport-side load to manage via SMTP-pool config.
246
- - **No retry policy beyond "next drain tries again".** `attempts` is recorded for visibility; there's no exponential backoff or dead-lettering.
263
+ - **No throttling.** A drain delivers every due row, one at a time, with no rate cap. After long downtime that is transport-side load to manage via SMTP-pool config. Deliberately not capped per pass: a cap plus `maxDelay` is a way to *expire* mail that was only late because we throttled it, and a lost email is worse than a slow one.
264
+ - **No dead-lettering.** A row that keeps failing backs off to a 15m retry and is eventually expired by `maxDelay`, with `last_error` on the row. There is no separate dead-letter table and no alert.
265
+ - **A timeout cannot cancel a send.** It abandons the call, so a message that timed out may still have been delivered and the retry can duplicate it. The trade is deliberate: an unbounded send wedges the (serialised) drain permanently, which stops *all* delivery.
266
+ - **One transport per process.** `transport` and the drain timer are module-level, so configuring `postEmail()` twice in one project has the second call's transport win for both. Use one.
247
267
 
248
268
  ## License
249
269
 
package/index.js CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  markerName,
20
20
  formatMarker,
21
21
  isDelivered,
22
+ sendWithTimeout,
23
+ backoffDelay,
22
24
  } from './lib/pure.js'
23
25
 
24
26
  // Re-export pure helpers so callers (and tests) can import them
@@ -28,6 +30,8 @@ export {
28
30
  humanizeMs,
29
31
  resolveAddresses,
30
32
  decideTiming,
33
+ sendWithTimeout,
34
+ backoffDelay,
31
35
  } from './lib/pure.js'
32
36
 
33
37
  // Postprocessor name — used in chain syntax (`welcome.html-mjml-email.hbs`)
@@ -39,23 +43,44 @@ export const output = 'eml'
39
43
  // prepend `mikser_`. `mikser-io-post-email` → `mikser_post_email_*`.
40
44
  registerSchema('post_email', `
41
45
  CREATE TABLE IF NOT EXISTS mikser_post_email_queue (
42
- id TEXT PRIMARY KEY REFERENCES mikser_entities(id) ON DELETE CASCADE,
43
- eml_path TEXT NOT NULL,
44
- eml_hash TEXT,
45
- send_at INTEGER NOT NULL,
46
- sent_at INTEGER,
47
- expired_at INTEGER,
48
- attempts INTEGER NOT NULL DEFAULT 0,
49
- last_error TEXT
46
+ id TEXT PRIMARY KEY REFERENCES mikser_entities(id) ON DELETE CASCADE,
47
+ eml_path TEXT NOT NULL,
48
+ eml_hash TEXT,
49
+ -- The message as fields (JSON), which is how it is DELIVERED.
50
+ --
51
+ -- Not the .eml on disk: that is an audit artifact, and re-reading it
52
+ -- to send a raw message made delivery depend both on the output tree
53
+ -- still holding the file (a --clear, or a deploy rsync with --delete,
54
+ -- removes it) and on the transport honouring nodemailer's raw field
55
+ -- at all. Mailgun's does not: nodemailer-mailgun-transport applies a
56
+ -- key whitelist with no raw in it, so the field is dropped and what
57
+ -- reaches the API has no sender, no recipient and no body.
58
+ --
59
+ -- Cleared on delivery; a sent row does not need to keep the body.
60
+ payload TEXT,
61
+ -- When delivery was ASKED for. maxDelay is measured from it, so it is
62
+ -- never moved once written.
63
+ send_at INTEGER NOT NULL,
64
+ -- When the next attempt may run. Set by a failed attempt's backoff;
65
+ -- separate from send_at precisely so backing off cannot make a row
66
+ -- that keeps failing look permanently on-time and never expire.
67
+ next_attempt_at INTEGER,
68
+ sent_at INTEGER,
69
+ expired_at INTEGER,
70
+ attempts INTEGER NOT NULL DEFAULT 0,
71
+ last_error TEXT
50
72
  );
51
73
  CREATE INDEX IF NOT EXISTS idx_mikser_post_email_queue_due
52
74
  ON mikser_post_email_queue (send_at)
53
75
  WHERE sent_at IS NULL AND expired_at IS NULL;
54
76
  `)
55
77
 
56
- const DEFAULT_MAX_DELAY_MS = 60 * 60 * 1000 // 1h
57
- const DEFAULT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000 // 90d
58
- const DRAIN_INTERVAL_MS = 60 * 1000 // 60s in watch mode
78
+ const DEFAULT_MAX_DELAY_MS = 60 * 60 * 1000 // 1h
79
+ const DEFAULT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000 // 90d
80
+ const DEFAULT_SEND_TIMEOUT_MS = 30 * 1000 // per message
81
+ const DRAIN_INTERVAL_MS = 60 * 1000 // while resident
82
+ const BACKOFF_BASE_MS = 60 * 1000 // 1st retry
83
+ const BACKOFF_MAX_MS = 15 * 60 * 1000 // ceiling
59
84
 
60
85
  // Per-config closures populate this on onLoaded. Module-level so the
61
86
  // postprocess() call (which is per-entity, may run on workers in
@@ -64,6 +89,14 @@ const DRAIN_INTERVAL_MS = 60 * 1000 // 60s in watch mode
64
89
  let transport = null
65
90
  let drainTimer = null
66
91
 
92
+ // The drain is SINGLE-FLIGHT, and has to be: a pass delivers sequentially and
93
+ // only marks a row sent once the transport has answered, so a second pass
94
+ // starting while the first is still in flight selects the same unmarked rows
95
+ // and delivers them a second time. There are two callers (the timer, and
96
+ // onFinalized for one-shot builds) and a backlog can easily outlast the 60s
97
+ // interval — 1515 queued messages did, on gpoint.bg.
98
+ let draining = false
99
+
67
100
  // ---------- EML composition + delivery -------------------------------
68
101
 
69
102
  // Build the .eml bytes nodemailer would have handed SMTP. Used both
@@ -137,19 +170,21 @@ async function recordSentSafely(config, id, hash, logger) {
137
170
 
138
171
  // ---------- queue ops ------------------------------------------------
139
172
 
140
- function upsertQueueRow({ id, emlPath, emlHash, sendAt }) {
173
+ function upsertQueueRow({ id, emlPath, emlHash, sendAt, payload }) {
141
174
  useDatabase().handle.prepare(`
142
- INSERT INTO mikser_post_email_queue (id, eml_path, eml_hash, send_at)
143
- VALUES (?, ?, ?, ?)
175
+ INSERT INTO mikser_post_email_queue (id, eml_path, eml_hash, payload, send_at)
176
+ VALUES (?, ?, ?, ?, ?)
144
177
  ON CONFLICT(id) DO UPDATE SET
145
- send_at = excluded.send_at,
146
- eml_path = excluded.eml_path,
147
- eml_hash = excluded.eml_hash,
148
- sent_at = NULL,
149
- expired_at = NULL,
150
- attempts = 0,
151
- last_error = NULL
152
- `).run(id, emlPath, emlHash, sendAt)
178
+ send_at = excluded.send_at,
179
+ eml_path = excluded.eml_path,
180
+ eml_hash = excluded.eml_hash,
181
+ payload = excluded.payload,
182
+ sent_at = NULL,
183
+ expired_at = NULL,
184
+ next_attempt_at = NULL,
185
+ attempts = 0,
186
+ last_error = NULL
187
+ `).run(id, emlPath, emlHash, payload == null ? null : JSON.stringify(payload), sendAt)
153
188
  }
154
189
 
155
190
  function recordExpiredInBand({ id, emlPath, sendAt, reason }) {
@@ -166,13 +201,82 @@ function recordExpiredInBand({ id, emlPath, sendAt, reason }) {
166
201
  `).run(id, emlPath, sendAt, Date.now(), reason)
167
202
  }
168
203
 
169
- function markSent(id) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET sent_at = ? WHERE id = ?`).run(Date.now(), id) }
170
- function markExpired(id, r) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET expired_at = ?, last_error = ? WHERE id = ?`).run(Date.now(), r, id) }
171
- function markFailed(id, err) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?`).run(err.message || String(err), id) }
204
+ // `payload` is released here: the row stays for its retention window as a
205
+ // record that this id was delivered, and a delivered message body has no
206
+ // further use — keeping every rendered email in the cache DB for 90 days does.
207
+ function markSent(id) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET sent_at = ?, payload = NULL WHERE id = ?`).run(Date.now(), id) }
208
+ // payload released for the same reason as in markSent — an expired row is
209
+ // never delivered, so holding its body for the retention window is pure cost.
210
+ function markExpired(id, r) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET expired_at = ?, last_error = ?, payload = NULL WHERE id = ?`).run(Date.now(), r, id) }
211
+ // A failed attempt stays queued, but not due again immediately. Retrying a
212
+ // flapping provider every 60s only multiplies the load on it, and for an
213
+ // attempt that TIMED OUT rather than been refused it multiplies the
214
+ // duplicates — the message may well have gone out. So back off exponentially
215
+ // in `next_attempt_at` and leave `send_at` untouched: `send_at` is the
216
+ // delivery intent and maxDelay is measured from it, so moving it would make a
217
+ // row that keeps failing look permanently on-time and never expire.
218
+ //
219
+ // Returns the delay it set, for the caller to log.
220
+ function markFailed(id, err) {
221
+ const row = useDatabase().handle
222
+ .prepare(`SELECT attempts FROM mikser_post_email_queue WHERE id = ?`).get(id)
223
+ const delay = backoffDelay({ attempts: row?.attempts, baseMs: BACKOFF_BASE_MS, maxMs: BACKOFF_MAX_MS })
224
+ useDatabase().handle.prepare(`
225
+ UPDATE mikser_post_email_queue
226
+ SET attempts = attempts + 1, last_error = ?, next_attempt_at = ?
227
+ WHERE id = ?
228
+ `).run(err.message || String(err), Date.now() + delay, id)
229
+ return delay
230
+ }
231
+
232
+ // Hand one message to the transport under a per-message timeout — see
233
+ // sendWithTimeout in lib/pure.js for why an unbounded send is not an option.
234
+ // `sendTimeout: 0` opts out.
235
+ function sendMailWithTimeout({ config, payload }) {
236
+ return sendWithTimeout({
237
+ send: message => transport.sendMail(message),
238
+ payload,
239
+ timeoutMs: parseDuration(config.sendTimeout, DEFAULT_SEND_TIMEOUT_MS),
240
+ })
241
+ }
242
+
243
+ // What to hand the transport for a queued row: the stored fields, or — for a
244
+ // row queued by a version that did not store them — the composed .eml re-read
245
+ // from disk. That fallback is best-effort by nature: it survives only while
246
+ // the file is still in the output tree, and only reaches the provider intact
247
+ // on a transport that honours `raw`, which SMTP does and Mailgun's does not.
248
+ async function deliveryPayload({ row, logger }) {
249
+ if (row.payload) return JSON.parse(row.payload)
250
+
251
+ logger.warn(
252
+ 'postEmail: %s was queued before the message payload was stored — falling back to its .eml. ' +
253
+ 'A transport that ignores `raw` (Mailgun) will reject it; re-render the entity to requeue it properly.',
254
+ row.id)
255
+
256
+ const emlAbs = path.isAbsolute(row.eml_path)
257
+ ? row.eml_path
258
+ : path.join(runtime.options.outputFolder, row.eml_path)
259
+ return { raw: await readFile(emlAbs) }
260
+ }
172
261
 
173
262
  // Drain the queue: deliver due rows that are still within maxDelay,
174
263
  // expire the overdue ones. Failed deliveries stay queued for retry.
264
+ //
265
+ // Serialised — see `draining`. Overlapping passes double-send.
175
266
  async function drain({ config, logger }) {
267
+ if (draining) {
268
+ logger.debug('postEmail: a drain is already in flight, skipping this pass')
269
+ return
270
+ }
271
+ draining = true
272
+ try {
273
+ await drainQueue({ config, logger })
274
+ } finally {
275
+ draining = false
276
+ }
277
+ }
278
+
279
+ async function drainQueue({ config, logger }) {
176
280
  const db = useDatabase()
177
281
  if (!db?.isOpen) return
178
282
 
@@ -188,10 +292,11 @@ async function drain({ config, logger }) {
188
292
  `).run(now - retentionMs, now - retentionMs)
189
293
 
190
294
  const due = db.handle.prepare(`
191
- SELECT id, eml_path, eml_hash, send_at FROM mikser_post_email_queue
295
+ SELECT id, eml_path, eml_hash, payload, send_at FROM mikser_post_email_queue
192
296
  WHERE sent_at IS NULL AND expired_at IS NULL AND send_at <= ?
297
+ AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
193
298
  ORDER BY send_at
194
- `).all(now)
299
+ `).all(now, now)
195
300
 
196
301
  for (const row of due) {
197
302
  // Cascade-race guard: catalog delete may have fired between
@@ -226,15 +331,12 @@ async function drain({ config, logger }) {
226
331
  }
227
332
 
228
333
  try {
229
- const emlAbs = path.isAbsolute(row.eml_path)
230
- ? row.eml_path
231
- : path.join(runtime.options.outputFolder, row.eml_path)
232
- const raw = await readFile(emlAbs)
334
+ const payload = await deliveryPayload({ row, logger })
233
335
  if (config.dryRun) {
234
336
  logger.info('postEmail: [dryRun] would deliver %s', row.id)
235
337
  markSent(row.id)
236
338
  } else {
237
- await transport.sendMail({ raw })
339
+ await sendMailWithTimeout({ config, payload })
238
340
  // Order matters: the mail is out, so retire the row FIRST.
239
341
  // If the marker write were inside this try and threw, the
240
342
  // catch below would markFailed() and leave the row due —
@@ -245,8 +347,9 @@ async function drain({ config, logger }) {
245
347
  }
246
348
  logger.info('postEmail: delivered %s', row.id)
247
349
  } catch (err) {
248
- markFailed(row.id, err)
249
- logger.error('postEmail: delivery failed for %s — %s', row.id, err.message || err)
350
+ const delay = markFailed(row.id, err)
351
+ logger.error('postEmail: delivery failed for %s, retrying in %s — %s',
352
+ row.id, humanizeMs(delay), err.message || err)
250
353
  }
251
354
  }
252
355
  }
@@ -303,28 +406,48 @@ export async function postprocess({ entity, options, config, logger }) {
303
406
  const maxDelayMs = parseDuration(entity.meta?.maxDelay ?? config.maxDelay, DEFAULT_MAX_DELAY_MS)
304
407
  const timing = decideTiming({ meta: entity.meta ?? {}, maxDelayMs })
305
408
 
306
- if (timing.mode === 'queue') {
307
- upsertQueueRow({ id: entity.id, emlPath: entity.destination, emlHash: hash, sendAt: timing.sendAt })
308
- logger.info('postEmail: queued %s for %s', entity.id, new Date(timing.sendAt).toISOString())
309
- } else if (timing.mode === 'expired') {
409
+ if (timing.mode === 'expired') {
310
410
  const reason = `overdue by ${humanizeMs(timing.overdueMs)}, past maxDelay ${humanizeMs(maxDelayMs)}`
311
411
  recordExpiredInBand({ id: entity.id, emlPath: entity.destination, sendAt: timing.sendAt, reason })
312
412
  logger.warn('postEmail: %s expired in-band — %s', entity.id, reason)
413
+ return { success: true, result: entity.destination }
414
+ }
415
+
416
+ // Both 'now' and 'queue' go through the queue. NOTHING is delivered from
417
+ // here any more.
418
+ //
419
+ // `postprocess` runs INSIDE the render pipeline. 'now' used to await
420
+ // transport.sendMail() right here, which made two things true that should
421
+ // never have been:
422
+ //
423
+ // 1. Every cycle waited on a third-party service. A build could not
424
+ // finish until the provider had answered for every message in it —
425
+ // 1515 sequential Mailgun round-trips, in one case, inside the
426
+ // pipeline, while the site's own requests queued behind it.
427
+ // 2. The provider could FAIL THE BUILD. A rejection — a 429, an
428
+ // outage, a socket that never answered — threw out of postprocess
429
+ // and failed the entity, so a provider having a bad minute became a
430
+ // broken render, with no retry: the marker was never written, so the
431
+ // next cycle simply tried again with no backoff.
432
+ //
433
+ // A row costs one sqlite insert and buys what the inline path never had:
434
+ // delivery that survives a crash mid-cycle, retries with backoff, a
435
+ // bounded per-message timeout, and a render that never waits on mail.
436
+ //
437
+ // Delivery promptness is preserved at both ends. A one-shot build drains
438
+ // at onFinalized, so `mikser` still sends before it exits. A resident
439
+ // instance drains on the timer, off the cycle, within DRAIN_INTERVAL_MS.
440
+ upsertQueueRow({
441
+ id: entity.id,
442
+ emlPath: entity.destination,
443
+ emlHash: hash,
444
+ payload: { from, to, cc, bcc, subject, html },
445
+ sendAt: timing.sendAt,
446
+ })
447
+ if (timing.mode === 'now') {
448
+ logger.info('postEmail: queued %s for immediate delivery', entity.id)
313
449
  } else {
314
- // mode === 'now' — deliver synchronously, once.
315
- try {
316
- if (config.dryRun) {
317
- logger.info('postEmail: [dryRun] would deliver %s', entity.id)
318
- } else {
319
- await transport.sendMail({ from, to, cc, bcc, subject, html })
320
- // Outside the throw path on purpose — see recordSentSafely.
321
- await recordSentSafely(config, entity.id, hash, logger)
322
- }
323
- logger.info('postEmail: delivered %s', entity.id)
324
- } catch (err) {
325
- logger.error('postEmail: delivery failed for %s — %s', entity.id, err.message || err)
326
- throw err
327
- }
450
+ logger.info('postEmail: queued %s for %s', entity.id, new Date(timing.sendAt).toISOString())
328
451
  }
329
452
 
330
453
  return { success: true, result: entity.destination }
@@ -339,32 +462,38 @@ export function postEmail(config = {}) {
339
462
  const logger = useLogger()
340
463
  transport = nodemailer.createTransport(config.transport ?? { jsonTransport: true })
341
464
 
342
- // Migrate pre-1.1 installs: the queue table predates eml_hash, and
343
- // CREATE TABLE IF NOT EXISTS won't add a column to an existing one.
465
+ // Bring an older queue table up to date. CREATE TABLE IF NOT EXISTS
466
+ // will not add a column to a table that already exists, so each one
467
+ // added since needs its own ALTER.
344
468
  //
345
469
  // 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.
350
- try {
351
- const db = useDatabase()
352
- if (db?.isOpen) db.handle.exec(`ALTER TABLE mikser_post_email_queue ADD COLUMN eml_hash TEXT`)
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
- }
358
-
359
- onFinalized(async () => {
470
+ // locked or read-only database — must be said out loud: swallowing it
471
+ // leaves the table without the column, and the failure resurfaces
472
+ // later as an opaque "no column named …" from an INSERT inside
473
+ // postprocess, far from its cause.
474
+ for (const column of ['eml_hash TEXT', 'payload TEXT', 'next_attempt_at INTEGER']) {
360
475
  try {
361
- await drain({ config, logger })
476
+ const db = useDatabase()
477
+ if (db?.isOpen) db.handle.exec(`ALTER TABLE mikser_post_email_queue ADD COLUMN ${column}`)
362
478
  } catch (err) {
363
- logger.error('postEmail: drain failed (onFinalized) — %s', err.message || err)
479
+ if (!/duplicate column/i.test(err.message || '')) {
480
+ logger.error('postEmail: could not add the %s column — %s', column, err.message || err)
481
+ }
364
482
  }
365
- })
483
+ }
366
484
 
367
- if (runtime.options.watch && !drainTimer) {
485
+ // The timer is what makes delivery out-of-band, so it has to run
486
+ // whenever this process stays up — `--server` as much as `--watch`.
487
+ // Gated on `watch` alone it was missing from the configuration that
488
+ // needs it most: a production `--server` instance (no watcher, which
489
+ // is how gpoint-cms runs) had no timer at all, so a queued message
490
+ // waited for the end of the next cycle and, after the last cycle,
491
+ // waited indefinitely.
492
+ //
493
+ // Matches how core decides the same thing — see the residency check
494
+ // in mikser-io's src/instance.js.
495
+ const resident = runtime.options.watch || runtime.options.server
496
+ if (resident && !drainTimer) {
368
497
  drainTimer = setInterval(() => {
369
498
  drain({ config, logger }).catch(err => {
370
499
  logger.error('postEmail: drain failed (timer) — %s', err.message || err)
@@ -373,13 +502,41 @@ export function postEmail(config = {}) {
373
502
  drainTimer.unref?.()
374
503
  }
375
504
 
376
- // Run one drain at startup to catch anything that came due
377
- // while mikser was off. Wrapped so a startup-time delivery
378
- // failure doesn't crash the boot.
379
- try {
380
- await drain({ config, logger })
381
- } catch (err) {
382
- logger.error('postEmail: startup drain failed — %s', err.message || err)
505
+ onFinalized(async () => {
506
+ // A one-shot build has no timer and exits after finalize, so the
507
+ // queue MUST be drained here or its mail is never sent.
508
+ //
509
+ // When a timer owns delivery, this must NOT drain: onFinalized is
510
+ // awaited inside the cycle, so draining here would put the
511
+ // third-party transport straight back on the critical path — the
512
+ // coupling the queue exists to break.
513
+ //
514
+ // Keyed on the timer actually existing rather than on residency,
515
+ // so the two conditions cannot disagree and strand the queue with
516
+ // neither draining it.
517
+ if (drainTimer) return
518
+ try {
519
+ await drain({ config, logger })
520
+ } catch (err) {
521
+ logger.error('postEmail: drain failed (onFinalized) — %s', err.message || err)
522
+ }
523
+ })
524
+
525
+ // One drain at startup, to catch whatever came due while mikser was
526
+ // off. Awaited only when nothing else will drain: a resident instance
527
+ // gets this fired and forgotten, because awaiting it here is how a
528
+ // restart with a backlog spent its boot inside the transport —
529
+ // 1515 messages, one at a time, before the first cycle ran.
530
+ if (drainTimer) {
531
+ drain({ config, logger }).catch(err => {
532
+ logger.error('postEmail: startup drain failed — %s', err.message || err)
533
+ })
534
+ } else {
535
+ try {
536
+ await drain({ config, logger })
537
+ } catch (err) {
538
+ logger.error('postEmail: startup drain failed — %s', err.message || err)
539
+ }
383
540
  }
384
541
  })
385
542
 
@@ -397,6 +554,6 @@ export function postEmail(config = {}) {
397
554
  name: config.name ?? 'email',
398
555
  output,
399
556
  options: config,
400
- postprocess,
557
+ postprocess, module: import.meta.url,
401
558
  }
402
559
  }
package/lib/pure.js CHANGED
@@ -92,6 +92,57 @@ export function decideTiming({ meta, maxDelayMs, now = Date.now() }) {
92
92
  return { mode: 'expired', sendAt: ts, overdueMs: now - ts }
93
93
  }
94
94
 
95
+ // ---------- delivery containment -------------------------------------
96
+ //
97
+ // A transport is a THIRD-PARTY SERVICE — Mailgun's HTTP API, an SMTP relay,
98
+ // SES — and none of its behaviour is ours to control. These two decide how
99
+ // much of that is allowed to reach the rest of the process.
100
+
101
+ // Hand one message to `send`, with a bound on how long that may take.
102
+ //
103
+ // nodemailer's own connection and socket timeouts cover only its SMTP
104
+ // transport; an HTTP API transport can sit on a socket for as long as the OS
105
+ // allows. Unbounded, one hung call stops ALL delivery: the drain is
106
+ // single-flight, so that pass never finishes, no later pass starts, and
107
+ // nothing is ever sent again — with nothing in the log to say why.
108
+ //
109
+ // A timeout ABANDONS the call; it cannot cancel it. The provider may still
110
+ // deliver the message, so a retry after a timeout can produce a duplicate.
111
+ // That is the deliberate trade — one possible duplicate against delivery
112
+ // stopping permanently — and it is why the retry backs off rather than firing
113
+ // again on the next pass. A falsy `timeoutMs` opts out and waits forever.
114
+ export async function sendWithTimeout({ send, payload, timeoutMs }) {
115
+ if (!timeoutMs) return send(payload)
116
+
117
+ let timer
118
+ try {
119
+ return await Promise.race([
120
+ send(payload),
121
+ new Promise((_, reject) => {
122
+ timer = setTimeout(
123
+ () => reject(new Error(`the transport did not answer within ${humanizeMs(timeoutMs)}`)),
124
+ timeoutMs)
125
+ }),
126
+ ])
127
+ } finally {
128
+ // Always — a resolved send must not leave a pending timer holding the
129
+ // event loop open for the rest of the timeout.
130
+ clearTimeout(timer)
131
+ }
132
+ }
133
+
134
+ // How long to wait before attempt number `attempts + 1`.
135
+ //
136
+ // Retrying a flapping provider on every pass only multiplies the load on it,
137
+ // and for an attempt that timed out rather than been refused it multiplies
138
+ // the duplicates. Exponential, capped, and counted from attempts already
139
+ // made, so the first failure waits `baseMs` and a persistent one settles at
140
+ // `maxMs` instead of hammering.
141
+ export function backoffDelay({ attempts, baseMs, maxMs }) {
142
+ const made = Math.max(0, Math.floor(Number(attempts) || 0))
143
+ return Math.min(baseMs * 2 ** made, maxMs)
144
+ }
145
+
95
146
  // ---------- send-once ledger (pure parts) ----------------------------
96
147
  //
97
148
  // A durable on-disk marker lets postEmail skip re-sending an email it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-post-email",
3
- "version": "2.0.0",
3
+ "version": "11.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",
@@ -18,9 +18,12 @@
18
18
  },
19
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-post-email#readme",
20
20
  "peerDependencies": {
21
- "mikser-io": "^10.0.0"
21
+ "mikser-io": "^11.0.1"
22
22
  },
23
23
  "dependencies": {
24
24
  "nodemailer": "^6.9.0"
25
+ },
26
+ "devDependencies": {
27
+ "mikser-io": "^11.0.1"
25
28
  }
26
29
  }
package/test/unit.test.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  resolveSpec, dedupe, resolveAddresses,
9
9
  decideTiming,
10
10
  deliveryHash, markerName, formatMarker, isDelivered, normalizeSendAt,
11
+ sendWithTimeout, backoffDelay,
11
12
  } from '../lib/pure.js'
12
13
 
13
14
  describe('parseDuration', () => {
@@ -298,3 +299,80 @@ describe('formatMarker / isDelivered', () => {
298
299
  assert.equal(isDelivered(undefined, 1, h), false)
299
300
  })
300
301
  })
302
+
303
+ describe('sendWithTimeout', () => {
304
+ it('passes the payload through and returns what the transport returned', async () => {
305
+ const seen = []
306
+ const result = await sendWithTimeout({
307
+ send: async message => { seen.push(message); return { messageId: 'ok' } },
308
+ payload: { to: ['a@example.com'] },
309
+ timeoutMs: 1000,
310
+ })
311
+ assert.deepEqual(seen, [{ to: ['a@example.com'] }])
312
+ assert.deepEqual(result, { messageId: 'ok' })
313
+ })
314
+
315
+ it('rejects when the transport does not answer in time', async () => {
316
+ await assert.rejects(
317
+ sendWithTimeout({
318
+ send: () => new Promise(() => {}), // never settles
319
+ payload: {},
320
+ timeoutMs: 20,
321
+ }),
322
+ /did not answer within/)
323
+ })
324
+
325
+ it('lets a real transport error through unchanged', async () => {
326
+ await assert.rejects(
327
+ sendWithTimeout({
328
+ send: async () => { throw new Error('421 rate limited') },
329
+ payload: {},
330
+ timeoutMs: 1000,
331
+ }),
332
+ /421 rate limited/)
333
+ })
334
+
335
+ it('waits forever when the timeout is falsy', async () => {
336
+ // Opting out must not wrap the call at all: a send that takes longer
337
+ // than any plausible timeout still resolves.
338
+ const result = await sendWithTimeout({
339
+ send: () => new Promise(resolve => setTimeout(() => resolve('late'), 30)),
340
+ payload: {},
341
+ timeoutMs: 0,
342
+ })
343
+ assert.equal(result, 'late')
344
+ })
345
+
346
+ it('does not leave a pending timer behind on success', async () => {
347
+ // A leaked timer keeps the event loop alive for the rest of the
348
+ // timeout — for a one-shot build that is the process refusing to exit.
349
+ const before = process.getActiveResourcesInfo().filter(r => r === 'Timeout').length
350
+ await sendWithTimeout({ send: async () => 'sent', payload: {}, timeoutMs: 60_000 })
351
+ const after = process.getActiveResourcesInfo().filter(r => r === 'Timeout').length
352
+ assert.equal(after, before)
353
+ })
354
+ })
355
+
356
+ describe('backoffDelay', () => {
357
+ const opts = { baseMs: 60_000, maxMs: 900_000 }
358
+
359
+ it('waits the base delay after the first failure', () => {
360
+ assert.equal(backoffDelay({ attempts: 0, ...opts }), 60_000)
361
+ })
362
+ it('doubles with each attempt already made', () => {
363
+ assert.equal(backoffDelay({ attempts: 1, ...opts }), 120_000)
364
+ assert.equal(backoffDelay({ attempts: 2, ...opts }), 240_000)
365
+ assert.equal(backoffDelay({ attempts: 3, ...opts }), 480_000)
366
+ })
367
+ it('caps at maxMs instead of growing without bound', () => {
368
+ assert.equal(backoffDelay({ attempts: 4, ...opts }), 900_000)
369
+ assert.equal(backoffDelay({ attempts: 40, ...opts }), 900_000)
370
+ })
371
+ it('treats a missing or junk attempt count as none made', () => {
372
+ // attempts comes straight from a sqlite row, which can be null on a
373
+ // row written before the column existed.
374
+ assert.equal(backoffDelay({ attempts: null, ...opts }), 60_000)
375
+ assert.equal(backoffDelay({ attempts: undefined, ...opts }), 60_000)
376
+ assert.equal(backoffDelay({ attempts: -3, ...opts }), 60_000)
377
+ })
378
+ })