mikser-io-post-email 1.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/LICENSE +21 -0
- package/README.md +196 -0
- package/index.js +273 -0
- package/lib/pure.js +91 -0
- package/package.json +26 -0
- package/test/unit.test.js +175 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Almero Digital Marketing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# mikser-io-post-email
|
|
2
|
+
|
|
3
|
+
Email postprocessor for [mikser-io](https://github.com/almero-digital-marketing/mikser-io). Reads rendered HTML from a postprocess chain, composes a MIME message, delivers it via SMTP (or any [nodemailer](https://nodemailer.com/) transport), and writes the `.eml` audit file to the output folder. Idempotency falls out of mikser's render manifest — unchanged inputs don't resend.
|
|
4
|
+
|
|
5
|
+
Sits *after* `post-mjml` in the canonical chain, so the same source content ships as a web page *and* a responsive email built from one MJML layout:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
layouts/welcome.html-mjml-email.hbs # renderer → MJML → post-mjml → HTML → post-email → EML
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install mikser-io-post-email
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Minimal usage
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// mikser.config.js
|
|
21
|
+
import { documents, layouts, renderHbs, frontMatter } from 'mikser-io'
|
|
22
|
+
import { postMjml } from 'mikser-io-post-mjml'
|
|
23
|
+
import { postEmail } from 'mikser-io-post-email'
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
plugins: [
|
|
27
|
+
documents(),
|
|
28
|
+
frontMatter(),
|
|
29
|
+
layouts(),
|
|
30
|
+
renderHbs(),
|
|
31
|
+
postMjml(),
|
|
32
|
+
postEmail({
|
|
33
|
+
from: 'hello@me.com',
|
|
34
|
+
transport: {
|
|
35
|
+
host: 'smtp.example.com',
|
|
36
|
+
port: 587,
|
|
37
|
+
auth: { user: '...', pass: '...' },
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
---
|
|
46
|
+
to: alice@acme.com
|
|
47
|
+
subject: Welcome, Alice
|
|
48
|
+
layout: welcome.html-mjml-email
|
|
49
|
+
---
|
|
50
|
+
```
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
## Recipient lists — `@listname` references
|
|
55
|
+
|
|
56
|
+
For broadcast, define named lists in plugin options and reference them from `to`/`cc`/`bcc` with an `@` prefix.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
postEmail({
|
|
60
|
+
from: 'newsletter@me.com',
|
|
61
|
+
lists: {
|
|
62
|
+
subscribers: async ({ entity }) =>
|
|
63
|
+
(await queryEntities({ type: 'subscriber', meta: { lang: entity.meta.lang } }))
|
|
64
|
+
.map(s => s.meta.email),
|
|
65
|
+
clients: ['ceo@acme.com', 'cto@acme.com'],
|
|
66
|
+
team: ['ops@me.com'],
|
|
67
|
+
archive: ['audit@me.com'],
|
|
68
|
+
},
|
|
69
|
+
bcc: ['@archive'], // global audit copy on every send
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
# newsletter
|
|
75
|
+
---
|
|
76
|
+
to: '@subscribers'
|
|
77
|
+
subject: This week
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
# client update + one-off CC
|
|
81
|
+
---
|
|
82
|
+
to: '@clients'
|
|
83
|
+
cc: ['investor@vc.com']
|
|
84
|
+
---
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Resolution rule
|
|
88
|
+
|
|
89
|
+
| Field | Where it comes from | Combine semantics |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `to` | entity `meta.to` only | exclusive — no plugin-options fallback |
|
|
92
|
+
| `cc` | entity `meta.cc` + plugin `cc` | additive, deduped (case-insensitive) |
|
|
93
|
+
| `bcc` | entity `meta.bcc` + plugin `bcc` | additive, deduped |
|
|
94
|
+
| `from`| entity `meta.from` ?? plugin `from` | entity wins; missing entirely → error |
|
|
95
|
+
|
|
96
|
+
A `@listname` in any of these fields resolves through `options.lists`. Spec values can be a literal string, an array, or `(ctx) => Promise<string | string[]>` where `ctx = { entity, runtime, config, lists, logger }`.
|
|
97
|
+
|
|
98
|
+
**Errors:**
|
|
99
|
+
- `to` missing → hard error (no silent send-to-nobody).
|
|
100
|
+
- `@unknown` → hard error naming the missing list.
|
|
101
|
+
- `to: []` (empty after resolution) → writes an empty `.eml` marker, logs debug, no delivery. Valid "no subscribers for this language" case.
|
|
102
|
+
|
|
103
|
+
## Scheduling — `sendAt`
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
---
|
|
107
|
+
to: '@subscribers'
|
|
108
|
+
subject: Friday digest
|
|
109
|
+
sendAt: 2026-06-20T09:00:00Z # ISO 8601, or missing/'now' for immediate
|
|
110
|
+
---
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Resolution against `maxDelay` (default `'1h'`):
|
|
114
|
+
|
|
115
|
+
| `sendAt` | What happens |
|
|
116
|
+
|---|---|
|
|
117
|
+
| missing / `'now'` | Deliver immediately during the chain |
|
|
118
|
+
| future | Write `.eml`, queue a row, deliver on drain when due |
|
|
119
|
+
| recent past (≤ `maxDelay`) | Catch-up: deliver immediately, warn nothing |
|
|
120
|
+
| ancient past (> `maxDelay`) | Write `.eml`, mark `expired_at`, log warning, no delivery |
|
|
121
|
+
|
|
122
|
+
`maxDelay` is overridable per-entity:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
---
|
|
126
|
+
sendAt: 2026-06-20T09:00:00Z
|
|
127
|
+
maxDelay: 4h # tolerate up to 4h of downtime
|
|
128
|
+
---
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### How the queue works
|
|
132
|
+
|
|
133
|
+
A persistent table (`mikser_post_email_queue` in `runtime/mikser.sqlite`) holds future sends.
|
|
134
|
+
|
|
135
|
+
```sql
|
|
136
|
+
mikser_post_email_queue (
|
|
137
|
+
id PRIMARY KEY → mikser_entities(id) ON DELETE CASCADE,
|
|
138
|
+
eml_path,
|
|
139
|
+
send_at, sent_at, expired_at,
|
|
140
|
+
attempts, last_error
|
|
141
|
+
)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **PK on entity id + UPSERT** — re-editing `sendAt` reschedules in place; can't double-queue.
|
|
145
|
+
- **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.
|
|
148
|
+
- **Retention**: delivered + expired rows are kept for `retention` (default `'90d'`) then pruned.
|
|
149
|
+
|
|
150
|
+
## Transport
|
|
151
|
+
|
|
152
|
+
Anything [nodemailer.createTransport](https://nodemailer.com/transports/) accepts:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
postEmail({
|
|
156
|
+
transport: {
|
|
157
|
+
host: 'smtp.example.com', port: 587, secure: false,
|
|
158
|
+
auth: { user: '...', pass: '...' },
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
With no `transport` provided, the plugin builds a JSON transport — useful for dev/testing: messages serialize to the `.eml` on disk but never leave the box.
|
|
164
|
+
|
|
165
|
+
## Dry-run
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
postEmail({ dryRun: process.env.NODE_ENV !== 'production' })
|
|
169
|
+
```
|
|
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.
|
|
172
|
+
|
|
173
|
+
## Options reference
|
|
174
|
+
|
|
175
|
+
| Option | Type | Default | |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| `name` | string | `'email'` | Chain identifier (used in layout filenames as `-email`) |
|
|
178
|
+
| `from` | string | required (or per-entity `from`) | Default sender |
|
|
179
|
+
| `lists` | `Record<string, string \| string[] \| function>` | `{}` | Named recipient lists for `@listname` references |
|
|
180
|
+
| `cc` | spec | none | Global CC, deduped with entity `cc` |
|
|
181
|
+
| `bcc` | spec | none | Global BCC, deduped with entity `bcc` |
|
|
182
|
+
| `transport` | nodemailer config | JSON transport | Delivery target |
|
|
183
|
+
| `maxDelay` | duration string | `'1h'` | How late past `sendAt` is still acceptable |
|
|
184
|
+
| `retention` | duration string | `'90d'` | How long delivered/expired rows stay in the queue |
|
|
185
|
+
| `dryRun` | boolean | `false` | Write `.eml`, skip transport |
|
|
186
|
+
|
|
187
|
+
## What it does NOT do (v1)
|
|
188
|
+
|
|
189
|
+
- **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.
|
|
190
|
+
- **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.
|
|
191
|
+
- **No throttling.** If 10k pending sends drain at once after long downtime, that's transport-side load to manage via SMTP-pool config.
|
|
192
|
+
- **No retry policy beyond "next drain tries again".** `attempts` is recorded for visibility; there's no exponential backoff or dead-lettering.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
3
|
+
import nodemailer from 'nodemailer'
|
|
4
|
+
import {
|
|
5
|
+
runtime,
|
|
6
|
+
registerSchema,
|
|
7
|
+
useDatabase,
|
|
8
|
+
useLogger,
|
|
9
|
+
onLoaded,
|
|
10
|
+
onFinalized,
|
|
11
|
+
} from 'mikser-io'
|
|
12
|
+
import {
|
|
13
|
+
parseDuration,
|
|
14
|
+
humanizeMs,
|
|
15
|
+
resolveAddresses,
|
|
16
|
+
decideTiming,
|
|
17
|
+
} from './lib/pure.js'
|
|
18
|
+
|
|
19
|
+
// Re-export pure helpers so callers (and tests) can import them
|
|
20
|
+
// from the package root if they want.
|
|
21
|
+
export {
|
|
22
|
+
parseDuration,
|
|
23
|
+
humanizeMs,
|
|
24
|
+
resolveAddresses,
|
|
25
|
+
decideTiming,
|
|
26
|
+
} from './lib/pure.js'
|
|
27
|
+
|
|
28
|
+
// Postprocessor name — used in chain syntax (`welcome.html-mjml-email.hbs`)
|
|
29
|
+
// and as the `post-email` plugin identifier the dispatcher resolves.
|
|
30
|
+
export const output = 'eml'
|
|
31
|
+
|
|
32
|
+
// Table prefix follows the cross-repo plugin-table convention:
|
|
33
|
+
// strip `mikser-io-` from the package name, replace `-` with `_`,
|
|
34
|
+
// prepend `mikser_`. `mikser-io-post-email` → `mikser_post_email_*`.
|
|
35
|
+
registerSchema('post_email', `
|
|
36
|
+
CREATE TABLE IF NOT EXISTS mikser_post_email_queue (
|
|
37
|
+
id TEXT PRIMARY KEY REFERENCES mikser_entities(id) ON DELETE CASCADE,
|
|
38
|
+
eml_path TEXT NOT NULL,
|
|
39
|
+
send_at INTEGER NOT NULL,
|
|
40
|
+
sent_at INTEGER,
|
|
41
|
+
expired_at INTEGER,
|
|
42
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
last_error TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_mikser_post_email_queue_due
|
|
46
|
+
ON mikser_post_email_queue (send_at)
|
|
47
|
+
WHERE sent_at IS NULL AND expired_at IS NULL;
|
|
48
|
+
`)
|
|
49
|
+
|
|
50
|
+
const DEFAULT_MAX_DELAY_MS = 60 * 60 * 1000 // 1h
|
|
51
|
+
const DEFAULT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000 // 90d
|
|
52
|
+
const DRAIN_INTERVAL_MS = 60 * 1000 // 60s in watch mode
|
|
53
|
+
|
|
54
|
+
// Per-config closures populate this on onLoaded. Module-level so the
|
|
55
|
+
// postprocess() call (which is per-entity, may run on workers in
|
|
56
|
+
// theory but in practice INLINE for this plugin) and the drain timer
|
|
57
|
+
// share the same transport handle.
|
|
58
|
+
let transport = null
|
|
59
|
+
let drainTimer = null
|
|
60
|
+
|
|
61
|
+
// ---------- EML composition + delivery -------------------------------
|
|
62
|
+
|
|
63
|
+
// Build the .eml bytes nodemailer would have handed SMTP. Used both
|
|
64
|
+
// for the on-disk audit file and (re-read) for queued deliveries.
|
|
65
|
+
async function composeEml({ from, to, cc, bcc, subject, html }) {
|
|
66
|
+
const json = nodemailer.createTransport({ jsonTransport: true })
|
|
67
|
+
const built = await json.sendMail({ from, to, cc, bcc, subject, html })
|
|
68
|
+
return built.message
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------- queue ops ------------------------------------------------
|
|
72
|
+
|
|
73
|
+
function upsertQueueRow({ id, emlPath, sendAt }) {
|
|
74
|
+
useDatabase().handle.prepare(`
|
|
75
|
+
INSERT INTO mikser_post_email_queue (id, eml_path, send_at)
|
|
76
|
+
VALUES (?, ?, ?)
|
|
77
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
78
|
+
send_at = excluded.send_at,
|
|
79
|
+
eml_path = excluded.eml_path,
|
|
80
|
+
sent_at = NULL,
|
|
81
|
+
expired_at = NULL,
|
|
82
|
+
attempts = 0,
|
|
83
|
+
last_error = NULL
|
|
84
|
+
`).run(id, emlPath, sendAt)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recordExpiredInBand({ id, emlPath, sendAt, reason }) {
|
|
88
|
+
// Expired during the in-band path → no future delivery, but record
|
|
89
|
+
// for observability. INSERT-or-UPDATE in case the entity was
|
|
90
|
+
// already queued and reschedule arrived too late.
|
|
91
|
+
useDatabase().handle.prepare(`
|
|
92
|
+
INSERT INTO mikser_post_email_queue (id, eml_path, send_at, expired_at, last_error)
|
|
93
|
+
VALUES (?, ?, ?, ?, ?)
|
|
94
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
95
|
+
send_at = excluded.send_at,
|
|
96
|
+
expired_at = excluded.expired_at,
|
|
97
|
+
last_error = excluded.last_error
|
|
98
|
+
`).run(id, emlPath, sendAt, Date.now(), reason)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function markSent(id) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET sent_at = ? WHERE id = ?`).run(Date.now(), id) }
|
|
102
|
+
function markExpired(id, r) { useDatabase().handle.prepare(`UPDATE mikser_post_email_queue SET expired_at = ?, last_error = ? WHERE id = ?`).run(Date.now(), r, id) }
|
|
103
|
+
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) }
|
|
104
|
+
|
|
105
|
+
// Drain the queue: deliver due rows that are still within maxDelay,
|
|
106
|
+
// expire the overdue ones. Failed deliveries stay queued for retry.
|
|
107
|
+
async function drain({ config, logger }) {
|
|
108
|
+
const db = useDatabase()
|
|
109
|
+
if (!db?.isOpen) return
|
|
110
|
+
|
|
111
|
+
const maxDelayMs = parseDuration(config.maxDelay, DEFAULT_MAX_DELAY_MS)
|
|
112
|
+
const retentionMs = parseDuration(config.retention, DEFAULT_RETENTION_MS)
|
|
113
|
+
const now = Date.now()
|
|
114
|
+
|
|
115
|
+
// Retention prune (best-effort, runs every drain).
|
|
116
|
+
db.handle.prepare(`
|
|
117
|
+
DELETE FROM mikser_post_email_queue
|
|
118
|
+
WHERE (sent_at IS NOT NULL AND sent_at < ?)
|
|
119
|
+
OR (expired_at IS NOT NULL AND expired_at < ?)
|
|
120
|
+
`).run(now - retentionMs, now - retentionMs)
|
|
121
|
+
|
|
122
|
+
const due = db.handle.prepare(`
|
|
123
|
+
SELECT id, eml_path, send_at FROM mikser_post_email_queue
|
|
124
|
+
WHERE sent_at IS NULL AND expired_at IS NULL AND send_at <= ?
|
|
125
|
+
ORDER BY send_at
|
|
126
|
+
`).all(now)
|
|
127
|
+
|
|
128
|
+
for (const row of due) {
|
|
129
|
+
// Cascade-race guard: catalog delete may have fired between
|
|
130
|
+
// SELECT and now. Re-check before sending.
|
|
131
|
+
const stillThere = db.handle.prepare(`SELECT 1 FROM mikser_post_email_queue WHERE id = ?`).get(row.id)
|
|
132
|
+
if (!stillThere) continue
|
|
133
|
+
|
|
134
|
+
const overdueMs = now - row.send_at
|
|
135
|
+
if (overdueMs > maxDelayMs) {
|
|
136
|
+
const reason = `overdue by ${humanizeMs(overdueMs)}, past maxDelay ${humanizeMs(maxDelayMs)}`
|
|
137
|
+
markExpired(row.id, reason)
|
|
138
|
+
logger.warn('postEmail: %s expired — %s', row.id, reason)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const emlAbs = path.isAbsolute(row.eml_path)
|
|
144
|
+
? row.eml_path
|
|
145
|
+
: path.join(runtime.options.outputFolder, row.eml_path)
|
|
146
|
+
const raw = await readFile(emlAbs)
|
|
147
|
+
if (config.dryRun) {
|
|
148
|
+
logger.info('postEmail: [dryRun] would deliver %s', row.id)
|
|
149
|
+
} else {
|
|
150
|
+
await transport.sendMail({ raw })
|
|
151
|
+
}
|
|
152
|
+
markSent(row.id)
|
|
153
|
+
logger.info('postEmail: delivered %s', row.id)
|
|
154
|
+
} catch (err) {
|
|
155
|
+
markFailed(row.id, err)
|
|
156
|
+
logger.error('postEmail: delivery failed for %s — %s', row.id, err.message || err)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- postprocess entrypoint -----------------------------------
|
|
162
|
+
|
|
163
|
+
// `options` here is the ENGINE options bag (outputFolder, watch, etc).
|
|
164
|
+
// `config` here is the PLUGIN options the factory was called with
|
|
165
|
+
// (lists, from, transport, maxDelay, dryRun, ...).
|
|
166
|
+
export async function postprocess({ entity, options, config, logger }) {
|
|
167
|
+
const sourcePath = path.join(options.outputFolder, entity.origin)
|
|
168
|
+
const outputPath = path.join(options.outputFolder, entity.destination)
|
|
169
|
+
const html = await readFile(sourcePath, 'utf8')
|
|
170
|
+
|
|
171
|
+
if (!entity.meta?.to) {
|
|
172
|
+
throw new Error(`postEmail: ${entity.id} has no "to" — set entity frontmatter`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ctx = { entity, runtime, config, lists: config.lists ?? {}, logger }
|
|
176
|
+
const { from, to, cc, bcc, subject } = await resolveAddresses({ entity, config, ctx })
|
|
177
|
+
|
|
178
|
+
await mkdir(path.dirname(outputPath), { recursive: true })
|
|
179
|
+
|
|
180
|
+
if (!to.length) {
|
|
181
|
+
// Empty resolved list — write a marker EML and skip delivery.
|
|
182
|
+
// Valid case: language-filtered list returns no subscribers.
|
|
183
|
+
logger.debug('postEmail: %s resolved to no recipients, skipping delivery', entity.id)
|
|
184
|
+
await writeFile(outputPath, '')
|
|
185
|
+
return { success: true, result: entity.destination }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const eml = await composeEml({ from, to, cc, bcc, subject, html })
|
|
189
|
+
await writeFile(outputPath, eml)
|
|
190
|
+
|
|
191
|
+
const maxDelayMs = parseDuration(entity.meta?.maxDelay ?? config.maxDelay, DEFAULT_MAX_DELAY_MS)
|
|
192
|
+
const timing = decideTiming({ meta: entity.meta ?? {}, maxDelayMs })
|
|
193
|
+
|
|
194
|
+
if (timing.mode === 'queue') {
|
|
195
|
+
upsertQueueRow({ id: entity.id, emlPath: entity.destination, sendAt: timing.sendAt })
|
|
196
|
+
logger.info('postEmail: queued %s for %s', entity.id, new Date(timing.sendAt).toISOString())
|
|
197
|
+
} else if (timing.mode === 'expired') {
|
|
198
|
+
const reason = `overdue by ${humanizeMs(timing.overdueMs)}, past maxDelay ${humanizeMs(maxDelayMs)}`
|
|
199
|
+
recordExpiredInBand({ id: entity.id, emlPath: entity.destination, sendAt: timing.sendAt, reason })
|
|
200
|
+
logger.warn('postEmail: %s expired in-band — %s', entity.id, reason)
|
|
201
|
+
} else {
|
|
202
|
+
// mode === 'now' — deliver synchronously.
|
|
203
|
+
try {
|
|
204
|
+
if (config.dryRun) {
|
|
205
|
+
logger.info('postEmail: [dryRun] would deliver %s', entity.id)
|
|
206
|
+
} else {
|
|
207
|
+
await transport.sendMail({ from, to, cc, bcc, subject, html })
|
|
208
|
+
}
|
|
209
|
+
logger.info('postEmail: delivered %s', entity.id)
|
|
210
|
+
} catch (err) {
|
|
211
|
+
logger.error('postEmail: delivery failed for %s — %s', entity.id, err.message || err)
|
|
212
|
+
throw err
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { success: true, result: entity.destination }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------- v9 factory + lifecycle wiring -----------------------------
|
|
220
|
+
|
|
221
|
+
export function postEmail(config = {}) {
|
|
222
|
+
// Lifecycle wiring is a side-effect of the factory call. The
|
|
223
|
+
// returned descriptor is the postprocessor itself.
|
|
224
|
+
onLoaded(async () => {
|
|
225
|
+
const logger = useLogger()
|
|
226
|
+
transport = nodemailer.createTransport(config.transport ?? { jsonTransport: true })
|
|
227
|
+
|
|
228
|
+
onFinalized(async () => {
|
|
229
|
+
try {
|
|
230
|
+
await drain({ config, logger })
|
|
231
|
+
} catch (err) {
|
|
232
|
+
logger.error('postEmail: drain failed (onFinalized) — %s', err.message || err)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
if (runtime.options.watch && !drainTimer) {
|
|
237
|
+
drainTimer = setInterval(() => {
|
|
238
|
+
drain({ config, logger }).catch(err => {
|
|
239
|
+
logger.error('postEmail: drain failed (timer) — %s', err.message || err)
|
|
240
|
+
})
|
|
241
|
+
}, DRAIN_INTERVAL_MS)
|
|
242
|
+
drainTimer.unref?.()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Run one drain at startup to catch anything that came due
|
|
246
|
+
// while mikser was off. Wrapped so a startup-time delivery
|
|
247
|
+
// failure doesn't crash the boot.
|
|
248
|
+
try {
|
|
249
|
+
await drain({ config, logger })
|
|
250
|
+
} catch (err) {
|
|
251
|
+
logger.error('postEmail: startup drain failed — %s', err.message || err)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
name: config.name ?? 'email',
|
|
257
|
+
output,
|
|
258
|
+
options: config,
|
|
259
|
+
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
|
+
}
|
|
272
|
+
transport = null
|
|
273
|
+
}
|
package/lib/pure.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Pure helpers — no mikser-io, no nodemailer, no sqlite imports.
|
|
2
|
+
// Kept separate so unit tests don't have to load the engine.
|
|
3
|
+
|
|
4
|
+
export function parseDuration(value, fallback) {
|
|
5
|
+
if (value == null) return fallback
|
|
6
|
+
if (typeof value === 'number') return value
|
|
7
|
+
const m = /^\s*(\d+)\s*(ms|s|m|h|d)\s*$/i.exec(String(value))
|
|
8
|
+
if (!m) throw new Error(`Invalid duration: ${value} (expected e.g. "1h", "30m", "90d")`)
|
|
9
|
+
const n = Number(m[1])
|
|
10
|
+
const u = m[2].toLowerCase()
|
|
11
|
+
return n * ({ ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[u])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function humanizeMs(ms) {
|
|
15
|
+
if (ms < 0) ms = -ms
|
|
16
|
+
if (ms < 1000) return `${ms}ms`
|
|
17
|
+
const s = Math.round(ms / 1000)
|
|
18
|
+
if (s < 60) return `${s}s`
|
|
19
|
+
const m = Math.round(s / 60)
|
|
20
|
+
if (m < 60) return `${m}m`
|
|
21
|
+
const h = Math.round(m / 60)
|
|
22
|
+
if (h < 48) return `${h}h`
|
|
23
|
+
return `${Math.round(h / 24)}d`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function resolveSpec(spec, ctx) {
|
|
27
|
+
if (spec == null) return []
|
|
28
|
+
if (typeof spec === 'function') spec = await spec(ctx)
|
|
29
|
+
const list = Array.isArray(spec) ? spec : [spec]
|
|
30
|
+
const out = []
|
|
31
|
+
for (const raw of list) {
|
|
32
|
+
if (raw == null || raw === '') continue
|
|
33
|
+
if (typeof raw !== 'string') {
|
|
34
|
+
throw new Error(`Recipient must be a string, got ${typeof raw}: ${JSON.stringify(raw)}`)
|
|
35
|
+
}
|
|
36
|
+
if (raw.startsWith('@')) {
|
|
37
|
+
const name = raw.slice(1)
|
|
38
|
+
const listSpec = ctx.lists?.[name]
|
|
39
|
+
if (listSpec === undefined) {
|
|
40
|
+
throw new Error(`Unknown recipient list "@${name}". Define it in postEmail({ lists: { ${name}: ... } }).`)
|
|
41
|
+
}
|
|
42
|
+
out.push(...await resolveSpec(listSpec, ctx))
|
|
43
|
+
} else {
|
|
44
|
+
out.push(raw)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function dedupe(addresses) {
|
|
51
|
+
const seen = new Set()
|
|
52
|
+
const out = []
|
|
53
|
+
for (const a of addresses) {
|
|
54
|
+
const k = a.toLowerCase().trim()
|
|
55
|
+
if (!k || seen.has(k)) continue
|
|
56
|
+
seen.add(k)
|
|
57
|
+
out.push(a)
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function resolveAddresses({ entity, config, ctx }) {
|
|
63
|
+
const meta = entity.meta ?? {}
|
|
64
|
+
const to = dedupe(await resolveSpec(meta.to, ctx))
|
|
65
|
+
const cc = dedupe([
|
|
66
|
+
...await resolveSpec(meta.cc, ctx),
|
|
67
|
+
...await resolveSpec(config.cc, ctx),
|
|
68
|
+
])
|
|
69
|
+
const bcc = dedupe([
|
|
70
|
+
...await resolveSpec(meta.bcc, ctx),
|
|
71
|
+
...await resolveSpec(config.bcc, ctx),
|
|
72
|
+
])
|
|
73
|
+
const from = meta.from ?? config.from
|
|
74
|
+
if (!from) {
|
|
75
|
+
throw new Error(`postEmail: no "from" address — set it in postEmail({from:...}) or entity frontmatter`)
|
|
76
|
+
}
|
|
77
|
+
return { from, to, cc, bcc, subject: meta.subject }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function decideTiming({ meta, maxDelayMs, now = Date.now() }) {
|
|
81
|
+
const raw = meta.sendAt
|
|
82
|
+
if (raw == null || raw === 'now') return { mode: 'now', sendAt: now }
|
|
83
|
+
|
|
84
|
+
const ts = typeof raw === 'number' ? raw : Date.parse(raw)
|
|
85
|
+
if (Number.isNaN(ts)) {
|
|
86
|
+
throw new Error(`postEmail: invalid sendAt "${raw}" (expected ISO 8601 or 'now')`)
|
|
87
|
+
}
|
|
88
|
+
if (ts > now) return { mode: 'queue', sendAt: ts }
|
|
89
|
+
if (now - ts <= maxDelayMs) return { mode: 'now', sendAt: ts }
|
|
90
|
+
return { mode: 'expired', sendAt: ts, overdueMs: now - ts }
|
|
91
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mikser-io-post-email",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/almero-digital-marketing/mikser-io-post-email.git"
|
|
13
|
+
},
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/almero-digital-marketing/mikser-io-post-email/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/almero-digital-marketing/mikser-io-post-email#readme",
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"mikser-io": "^9.0.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"nodemailer": "^6.9.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Pure-logic tests for postEmail. No engine, no nodemailer transport,
|
|
2
|
+
// no sqlite — just the resolution + decision-tree functions.
|
|
3
|
+
|
|
4
|
+
import { describe, it } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import {
|
|
7
|
+
parseDuration, humanizeMs,
|
|
8
|
+
resolveSpec, dedupe, resolveAddresses,
|
|
9
|
+
decideTiming,
|
|
10
|
+
} from '../lib/pure.js'
|
|
11
|
+
|
|
12
|
+
describe('parseDuration', () => {
|
|
13
|
+
it('returns fallback on null', () => {
|
|
14
|
+
assert.equal(parseDuration(null, 42), 42)
|
|
15
|
+
})
|
|
16
|
+
it('passes through numbers', () => {
|
|
17
|
+
assert.equal(parseDuration(1234), 1234)
|
|
18
|
+
})
|
|
19
|
+
it('parses suffixed strings', () => {
|
|
20
|
+
assert.equal(parseDuration('500ms'), 500)
|
|
21
|
+
assert.equal(parseDuration('10s'), 10_000)
|
|
22
|
+
assert.equal(parseDuration('30m'), 1_800_000)
|
|
23
|
+
assert.equal(parseDuration('1h'), 3_600_000)
|
|
24
|
+
assert.equal(parseDuration('2d'), 172_800_000)
|
|
25
|
+
})
|
|
26
|
+
it('throws on garbage', () => {
|
|
27
|
+
assert.throws(() => parseDuration('soon'))
|
|
28
|
+
assert.throws(() => parseDuration('1week'))
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('humanizeMs', () => {
|
|
33
|
+
it('chooses appropriate units', () => {
|
|
34
|
+
assert.equal(humanizeMs(500), '500ms')
|
|
35
|
+
assert.equal(humanizeMs(2_500), '3s')
|
|
36
|
+
assert.equal(humanizeMs(120_000), '2m')
|
|
37
|
+
assert.equal(humanizeMs(3_600_000), '1h')
|
|
38
|
+
assert.equal(humanizeMs(48 * 3_600_000), '2d')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('resolveSpec', () => {
|
|
43
|
+
const ctx = (lists = {}) => ({ entity: {}, runtime: {}, config: {}, lists, logger: { debug() {} } })
|
|
44
|
+
|
|
45
|
+
it('returns [] for null', async () => {
|
|
46
|
+
assert.deepEqual(await resolveSpec(null, ctx()), [])
|
|
47
|
+
})
|
|
48
|
+
it('passes literal address through', async () => {
|
|
49
|
+
assert.deepEqual(await resolveSpec('a@x.com', ctx()), ['a@x.com'])
|
|
50
|
+
})
|
|
51
|
+
it('expands array of literals', async () => {
|
|
52
|
+
assert.deepEqual(await resolveSpec(['a@x.com', 'b@y.com'], ctx()), ['a@x.com', 'b@y.com'])
|
|
53
|
+
})
|
|
54
|
+
it('expands @listname against options.lists', async () => {
|
|
55
|
+
const r = await resolveSpec('@team', ctx({ team: ['a@x.com', 'b@y.com'] }))
|
|
56
|
+
assert.deepEqual(r, ['a@x.com', 'b@y.com'])
|
|
57
|
+
})
|
|
58
|
+
it('expands listname when value is a function', async () => {
|
|
59
|
+
const r = await resolveSpec('@subs', ctx({ subs: async () => ['c@z.com'] }))
|
|
60
|
+
assert.deepEqual(r, ['c@z.com'])
|
|
61
|
+
})
|
|
62
|
+
it('mixes literals and @lists', async () => {
|
|
63
|
+
const r = await resolveSpec(['@team', 'extra@me.com'], ctx({ team: ['a@x.com'] }))
|
|
64
|
+
assert.deepEqual(r, ['a@x.com', 'extra@me.com'])
|
|
65
|
+
})
|
|
66
|
+
it('recurses into nested @-references', async () => {
|
|
67
|
+
const r = await resolveSpec('@outer', ctx({
|
|
68
|
+
outer: ['@inner', 'top@x.com'],
|
|
69
|
+
inner: ['a@x.com', 'b@x.com'],
|
|
70
|
+
}))
|
|
71
|
+
assert.deepEqual(r, ['a@x.com', 'b@x.com', 'top@x.com'])
|
|
72
|
+
})
|
|
73
|
+
it('throws on unknown @listname', async () => {
|
|
74
|
+
await assert.rejects(
|
|
75
|
+
() => resolveSpec('@nope', ctx()),
|
|
76
|
+
/Unknown recipient list "@nope"/,
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('dedupe', () => {
|
|
82
|
+
it('removes case-insensitive duplicates', () => {
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
dedupe(['A@X.com', 'a@x.com', 'b@y.com']),
|
|
85
|
+
['A@X.com', 'b@y.com'],
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
it('drops empty and whitespace', () => {
|
|
89
|
+
assert.deepEqual(dedupe(['', ' ', 'a@x.com']), ['a@x.com'])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('resolveAddresses', () => {
|
|
94
|
+
const entity = (meta) => ({ id: '/x', meta })
|
|
95
|
+
const ctx = (lists) => ({ runtime: {}, lists, logger: { debug() {} } })
|
|
96
|
+
|
|
97
|
+
it('to is exclusive to the entity (no options fallback)', async () => {
|
|
98
|
+
const r = await resolveAddresses({
|
|
99
|
+
entity: entity({ to: 'alice@acme.com' }),
|
|
100
|
+
config: { from: 'me@x.com', cc: ['audit@x.com'] },
|
|
101
|
+
ctx: { ...ctx(), config: {} },
|
|
102
|
+
})
|
|
103
|
+
assert.deepEqual(r.to, ['alice@acme.com'])
|
|
104
|
+
assert.deepEqual(r.cc, ['audit@x.com'])
|
|
105
|
+
assert.equal(r.from, 'me@x.com')
|
|
106
|
+
})
|
|
107
|
+
it('cc/bcc are additive between entity and options', async () => {
|
|
108
|
+
const r = await resolveAddresses({
|
|
109
|
+
entity: entity({ to: 'a@x.com', cc: ['per@entity.com'], bcc: ['x@y.com'] }),
|
|
110
|
+
config: { from: 'me@x.com', cc: ['from@options.com'], bcc: ['x@y.com', 'z@y.com'] },
|
|
111
|
+
ctx: { ...ctx(), config: {} },
|
|
112
|
+
})
|
|
113
|
+
assert.deepEqual(r.cc, ['per@entity.com', 'from@options.com'])
|
|
114
|
+
assert.deepEqual(r.bcc, ['x@y.com', 'z@y.com']) // deduped across both
|
|
115
|
+
})
|
|
116
|
+
it('expands @listname references in any field', async () => {
|
|
117
|
+
const r = await resolveAddresses({
|
|
118
|
+
entity: entity({ to: '@subs' }),
|
|
119
|
+
config: { from: 'me@x.com', bcc: ['@audit'] },
|
|
120
|
+
ctx: { ...ctx({ subs: ['a@x.com', 'b@x.com'], audit: ['log@x.com'] }), config: {} },
|
|
121
|
+
})
|
|
122
|
+
assert.deepEqual(r.to, ['a@x.com', 'b@x.com'])
|
|
123
|
+
assert.deepEqual(r.bcc, ['log@x.com'])
|
|
124
|
+
})
|
|
125
|
+
it('frontmatter from beats options from', async () => {
|
|
126
|
+
const r = await resolveAddresses({
|
|
127
|
+
entity: entity({ to: 'a@x.com', from: 'override@x.com' }),
|
|
128
|
+
config: { from: 'default@x.com' },
|
|
129
|
+
ctx: { ...ctx(), config: {} },
|
|
130
|
+
})
|
|
131
|
+
assert.equal(r.from, 'override@x.com')
|
|
132
|
+
})
|
|
133
|
+
it('throws when from is missing entirely', async () => {
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
() => resolveAddresses({
|
|
136
|
+
entity: entity({ to: 'a@x.com' }),
|
|
137
|
+
config: {},
|
|
138
|
+
ctx: { ...ctx(), config: {} },
|
|
139
|
+
}),
|
|
140
|
+
/no "from" address/,
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('decideTiming', () => {
|
|
146
|
+
const now = 1_700_000_000_000 // fixed ts to avoid Date.now-based flake
|
|
147
|
+
const oneHour = 3_600_000
|
|
148
|
+
|
|
149
|
+
it('missing sendAt → now', () => {
|
|
150
|
+
const r = decideTiming({ meta: {}, maxDelayMs: oneHour, now })
|
|
151
|
+
assert.equal(r.mode, 'now')
|
|
152
|
+
assert.equal(r.sendAt, now)
|
|
153
|
+
})
|
|
154
|
+
it('"now" literal → now', () => {
|
|
155
|
+
const r = decideTiming({ meta: { sendAt: 'now' }, maxDelayMs: oneHour, now })
|
|
156
|
+
assert.equal(r.mode, 'now')
|
|
157
|
+
})
|
|
158
|
+
it('future ISO → queue', () => {
|
|
159
|
+
const r = decideTiming({ meta: { sendAt: new Date(now + 86_400_000).toISOString() }, maxDelayMs: oneHour, now })
|
|
160
|
+
assert.equal(r.mode, 'queue')
|
|
161
|
+
assert.equal(r.sendAt, now + 86_400_000)
|
|
162
|
+
})
|
|
163
|
+
it('past within maxDelay → now (catch-up)', () => {
|
|
164
|
+
const r = decideTiming({ meta: { sendAt: new Date(now - 30 * 60_000).toISOString() }, maxDelayMs: oneHour, now })
|
|
165
|
+
assert.equal(r.mode, 'now')
|
|
166
|
+
})
|
|
167
|
+
it('past beyond maxDelay → expired', () => {
|
|
168
|
+
const r = decideTiming({ meta: { sendAt: new Date(now - 2 * oneHour).toISOString() }, maxDelayMs: oneHour, now })
|
|
169
|
+
assert.equal(r.mode, 'expired')
|
|
170
|
+
assert.ok(r.overdueMs > oneHour)
|
|
171
|
+
})
|
|
172
|
+
it('garbage sendAt throws', () => {
|
|
173
|
+
assert.throws(() => decideTiming({ meta: { sendAt: 'soon' }, maxDelayMs: oneHour, now }))
|
|
174
|
+
})
|
|
175
|
+
})
|