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 +56 -2
- package/index.js +55 -5
- package/lib/pure.js +41 -5
- package/package.json +2 -2
- package/test/unit.test.js +79 -5
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 ?? '',
|
|
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.
|
|
118
|
-
//
|
|
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
|
-
|
|
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": "
|
|
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": "^
|
|
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('
|
|
200
|
-
assert.
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|