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