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.
- package/.github/workflows/publish.yml +73 -0
- package/README.md +32 -12
- package/index.js +237 -80
- package/lib/pure.js +51 -0
- package/package.json +5 -2
- package/test/unit.test.js +78 -0
|
@@ -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,
|
|
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
|
|
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
|
|
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'` |
|
|
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:
|
|
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,
|
|
140
|
-
|
|
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
|
-
- **
|
|
147
|
-
- **
|
|
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.**
|
|
246
|
-
- **No retry
|
|
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
|
|
43
|
-
eml_path
|
|
44
|
-
eml_hash
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
57
|
-
const DEFAULT_RETENTION_MS
|
|
58
|
-
const
|
|
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
|
|
146
|
-
eml_path
|
|
147
|
-
eml_hash
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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',
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
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
|
-
//
|
|
348
|
-
// later as an opaque "no column named
|
|
349
|
-
//
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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": "
|
|
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": "^
|
|
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
|
+
})
|