@voltro/plugin-mail 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ import { MailError as e, MailService as t, toList as n } from "./types.js";
2
+ import { renderTemplate as r } from "./render.js";
3
+ import { lookupGlobalTemplate as i } from "./registry.js";
4
+ import { memorySuppressionStore as a } from "./suppression.js";
5
+ import { memoryIdempotencyStore as o } from "./idempotency.js";
6
+ import { makeScheduler as s, memoryScheduleStore as c } from "./scheduler.js";
7
+ import { consoleProvider as l, lettermintProvider as u, mailgunProvider as d, memoryProvider as f, postmarkProvider as p, resendProvider as m, sendgridProvider as h, sesProvider as g, smtpProvider as _ } from "./providers.js";
8
+ import { Effect as v, Layer as y, Schedule as b } from "effect";
9
+ import { createLogger as x } from "@voltro/logger";
10
+ //#region src/service.ts
11
+ var S = x({ scope: "@voltro/plugin-mail" }), C = (e) => {
12
+ if (e.provider !== void 0 && typeof e.provider != "string") return e.provider;
13
+ let t = e.env ?? {}, n = e.provider ?? t.MAIL_PROVIDER ?? "console", r = (e, t) => {
14
+ if (!t) throw Error(`@voltro/plugin-mail: provider '${n}' needs apiKey or ${e}`);
15
+ return t;
16
+ };
17
+ switch (n) {
18
+ case "lettermint": return u({ projectToken: r("LETTERMINT_PROJECT_TOKEN", e.apiKey ?? t.LETTERMINT_PROJECT_TOKEN) });
19
+ case "resend": return m({ apiKey: r("RESEND_API_KEY", e.apiKey ?? t.RESEND_API_KEY) });
20
+ case "postmark": return p({ serverToken: r("POSTMARK_SERVER_TOKEN", e.apiKey ?? t.POSTMARK_SERVER_TOKEN) });
21
+ case "sendgrid": return h({ apiKey: r("SENDGRID_API_KEY", e.apiKey ?? t.SENDGRID_API_KEY) });
22
+ case "ses": return g({
23
+ region: r("AWS_REGION", t.AWS_REGION),
24
+ accessKeyId: r("AWS_ACCESS_KEY_ID", t.AWS_ACCESS_KEY_ID),
25
+ secretAccessKey: r("AWS_SECRET_ACCESS_KEY", t.AWS_SECRET_ACCESS_KEY),
26
+ ...t.AWS_SESSION_TOKEN ? { sessionToken: t.AWS_SESSION_TOKEN } : {}
27
+ });
28
+ case "mailgun": return d({
29
+ apiKey: r("MAILGUN_API_KEY", e.apiKey ?? t.MAILGUN_API_KEY),
30
+ domain: r("MAILGUN_DOMAIN", t.MAILGUN_DOMAIN),
31
+ ...t.MAILGUN_BASE_URL ? { baseUrl: t.MAILGUN_BASE_URL } : {}
32
+ });
33
+ case "smtp": {
34
+ if (t.SMTP_URL) return _({ url: t.SMTP_URL });
35
+ let e = r("SMTP_URL/SMTP_HOST", t.SMTP_HOST);
36
+ return _({
37
+ host: e,
38
+ ...t.SMTP_PORT ? { port: Number(t.SMTP_PORT) } : {},
39
+ secure: t.SMTP_SECURE === "true",
40
+ ...t.SMTP_USER && t.SMTP_PASS ? { auth: {
41
+ user: t.SMTP_USER,
42
+ pass: t.SMTP_PASS
43
+ } } : {}
44
+ });
45
+ }
46
+ case "memory": return f();
47
+ default: return l((e, t) => S.info(e, t));
48
+ }
49
+ }, w = (t) => {
50
+ let l = t.attempts ?? 3, u = t.suppression ?? a(), d = t.allowlist, f = t.production !== !0 && d !== void 0 && d.length > 0, p = (e) => d.some((t) => t.toLowerCase() === e.toLowerCase()), m = b.recurs(l).pipe(b.while(({ input: e }) => e.transient)), h = /* @__PURE__ */ new Map();
51
+ for (let e of t.templates ?? []) h.set(e.locale ? `${e.name}:${e.locale}` : e.name, e);
52
+ let g = (e, t) => (t ? h.get(`${e}:${t}`) : void 0) ?? h.get(e) ?? i(e, t), _ = /* @__PURE__ */ new Map(), y = (e, t) => `${e.name}|${e.locale ?? ""}|${JSON.stringify(t ?? null)}`, x = (e, t) => {
53
+ if (_.set(e, t), _.size > 500) {
54
+ let e = _.keys().next().value;
55
+ e !== void 0 && _.delete(e);
56
+ }
57
+ }, C = t.idempotency ?? o(), w = s({
58
+ store: t.schedule ?? c(),
59
+ providerSchedules: t.provider.supportsScheduling === !0,
60
+ flush: (e) => O(e)
61
+ }), T = (n) => {
62
+ if (n.stream !== "broadcast" || t.provider.managesUnsubscribe === !0) return v.succeed(void 0);
63
+ let r = n.unsubscribe;
64
+ if (r === void 0 || !r.oneClickUrl.startsWith("https://")) return v.fail(new e({
65
+ provider: t.provider.name,
66
+ message: `a broadcast needs \`unsubscribe: { oneClickUrl }\` (an https URL) — '${t.provider.name}' does not unsubscribe recipients itself`,
67
+ transient: !1
68
+ }));
69
+ let i = [`<${r.oneClickUrl}>`, ...r.mailto === void 0 ? [] : [`<mailto:${r.mailto.replace(/^mailto:/, "")}>`]];
70
+ return v.succeed({
71
+ "List-Unsubscribe": i.join(", "),
72
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
73
+ });
74
+ }, E = (r) => v.gen(function* () {
75
+ let i = r.from ?? t.from;
76
+ if (!i) return yield* v.fail(new e({
77
+ provider: t.provider.name,
78
+ message: "no `from` — set mailPlugin({ from }) or pass message.from",
79
+ transient: !1
80
+ }));
81
+ let a = n(r.to);
82
+ if (f) {
83
+ let e = a.filter(p);
84
+ if (e.length === 0) return S.warn("mail skipped — no recipient in allowlist (non-production)", {
85
+ to: a.join(","),
86
+ subject: r.subject
87
+ }), t.onSent?.({
88
+ id: "skipped-allowlist",
89
+ to: a,
90
+ from: i,
91
+ subject: r.subject,
92
+ provider: "skipped",
93
+ ok: !0,
94
+ ts: Date.now()
95
+ }), {
96
+ kind: "skip",
97
+ result: {
98
+ id: "skipped-allowlist",
99
+ provider: "skipped"
100
+ }
101
+ };
102
+ a = e;
103
+ }
104
+ let o = r.tenantId ?? null, s = [];
105
+ for (let e of a) (yield* u.isSuppressed(o, e)) || s.push(e);
106
+ if (s.length === 0) return S.warn("mail skipped — all recipients suppressed", {
107
+ to: a.join(","),
108
+ subject: r.subject
109
+ }), t.onSent?.({
110
+ id: "skipped-suppressed",
111
+ to: a,
112
+ from: i,
113
+ subject: r.subject,
114
+ provider: "suppressed",
115
+ ok: !0,
116
+ ts: Date.now()
117
+ }), {
118
+ kind: "skip",
119
+ result: {
120
+ id: "skipped-suppressed",
121
+ provider: "suppressed"
122
+ }
123
+ };
124
+ a = s;
125
+ let c = r.replyTo ?? t.replyTo, l = yield* T(r), d = l === void 0 ? r.headers : {
126
+ ...r.headers,
127
+ ...l
128
+ };
129
+ return {
130
+ kind: "send",
131
+ message: {
132
+ ...r,
133
+ from: i,
134
+ to: a,
135
+ ...c === void 0 ? {} : { replyTo: c },
136
+ ...d === void 0 ? {} : { headers: d }
137
+ }
138
+ };
139
+ }), D = (e) => {
140
+ let r = n(e.to);
141
+ return t.provider.send(e).pipe(v.retry(m), v.tap((n) => v.sync(() => {
142
+ S.info("mail sent", {
143
+ provider: n.provider,
144
+ id: n.id,
145
+ to: r.join(",")
146
+ }), t.onSent?.({
147
+ id: n.id,
148
+ to: r,
149
+ from: e.from,
150
+ subject: e.subject,
151
+ provider: n.provider,
152
+ ok: !0,
153
+ ts: Date.now()
154
+ });
155
+ })), v.tapError((n) => v.sync(() => {
156
+ S.warn("mail failed", {
157
+ provider: n.provider,
158
+ status: n.status,
159
+ message: n.message
160
+ }), t.onSent?.({
161
+ id: `err-${Date.now()}`,
162
+ to: r,
163
+ from: e.from,
164
+ subject: e.subject,
165
+ provider: n.provider,
166
+ ok: !1,
167
+ error: n.message,
168
+ ts: Date.now()
169
+ });
170
+ })));
171
+ }, O = (e) => v.gen(function* () {
172
+ let r = yield* E(e);
173
+ return r.kind === "skip" ? r.result : w.shouldHold(e.scheduledAt) ? (yield* w.hold(r.message), S.info("mail scheduled (held for later flush)", {
174
+ subject: r.message.subject,
175
+ dueAt: e.scheduledAt?.toISOString()
176
+ }), t.onSent?.({
177
+ id: "scheduled",
178
+ to: n(r.message.to),
179
+ from: r.message.from,
180
+ subject: r.message.subject,
181
+ provider: "scheduled",
182
+ ok: !0,
183
+ ts: Date.now()
184
+ }), {
185
+ id: "scheduled",
186
+ provider: "scheduled"
187
+ }) : yield* D(r.message);
188
+ }), k = (e, t, n) => t === void 0 ? n : v.gen(function* () {
189
+ let r = yield* C.reserve(e, t);
190
+ if (!r.won) return S.info("mail idempotent replay — skipped", { key: t }), r.prior ?? {
191
+ id: "idempotent-replay",
192
+ provider: "idempotent"
193
+ };
194
+ let i = yield* n;
195
+ return yield* C.record(e, t, i), i;
196
+ }), A = (t) => v.suspend(() => {
197
+ let n = g(t.template, t.locale);
198
+ if (!n) return v.fail(new e({
199
+ provider: "template",
200
+ message: `unknown template '${t.template}'${t.locale ? ` (locale '${t.locale}')` : ""}`,
201
+ transient: !1
202
+ }));
203
+ let i = y(n, t.props), a = _.get(i);
204
+ return (a ? v.succeed(a) : r(n, t.props).pipe(v.tap((e) => v.sync(() => x(i, e))))).pipe(v.map((e) => ({
205
+ to: t.to,
206
+ subject: e.subject,
207
+ html: e.html,
208
+ text: e.text,
209
+ ...t.from === void 0 ? {} : { from: t.from },
210
+ ...t.replyTo === void 0 ? {} : { replyTo: t.replyTo },
211
+ ...t.cc === void 0 ? {} : { cc: t.cc },
212
+ ...t.bcc === void 0 ? {} : { bcc: t.bcc },
213
+ ...t.headers === void 0 ? {} : { headers: t.headers },
214
+ ...t.tags === void 0 ? {} : { tags: t.tags },
215
+ ...t.attachments === void 0 ? {} : { attachments: t.attachments },
216
+ ...t.tenantId === void 0 ? {} : { tenantId: t.tenantId },
217
+ ...t.scheduledAt === void 0 ? {} : { scheduledAt: t.scheduledAt },
218
+ ...t.idempotencyKey === void 0 ? {} : { idempotencyKey: t.idempotencyKey },
219
+ ...t.stream === void 0 ? {} : { stream: t.stream },
220
+ ...t.unsubscribe === void 0 ? {} : { unsubscribe: t.unsubscribe },
221
+ ...t.metadata === void 0 ? {} : { metadata: t.metadata }
222
+ })));
223
+ }), j = (e) => "template" in e ? A(e) : v.succeed(e);
224
+ return {
225
+ service: {
226
+ send: (e) => v.gen(function* () {
227
+ let t = yield* j(e);
228
+ return yield* k(t.tenantId ?? null, t.idempotencyKey, O(t));
229
+ }),
230
+ sendBatch: (e) => v.gen(function* () {
231
+ let r = yield* v.forEach(e, j), i = r.some((e) => e.idempotencyKey !== void 0 || w.shouldHold(e.scheduledAt));
232
+ if (t.provider.sendBatch && !i) {
233
+ let e = [], i = [];
234
+ for (let t of r) {
235
+ let n = yield* E(t);
236
+ n.kind === "skip" ? i.push(n.result) : (i.push(e.length), e.push(n.message));
237
+ }
238
+ let a = e.length > 0 ? yield* t.provider.sendBatch(e).pipe(v.retry(m), v.tap((r) => v.sync(() => {
239
+ e.forEach((e, i) => {
240
+ let a = r[i];
241
+ a && (S.info("mail sent (batch)", {
242
+ provider: a.provider,
243
+ id: a.id,
244
+ to: n(e.to).join(",")
245
+ }), t.onSent?.({
246
+ id: a.id,
247
+ to: n(e.to),
248
+ from: e.from,
249
+ subject: e.subject,
250
+ provider: a.provider,
251
+ ok: !0,
252
+ ts: Date.now()
253
+ }));
254
+ });
255
+ })), v.tapError((e) => v.sync(() => S.warn("mail batch failed", {
256
+ provider: e.provider,
257
+ status: e.status,
258
+ message: e.message
259
+ })))) : [];
260
+ return i.map((e) => typeof e == "number" ? a[e] ?? {
261
+ id: "batch",
262
+ provider: t.provider.name
263
+ } : e);
264
+ }
265
+ return yield* v.forEach(r, (e) => k(e.tenantId ?? null, e.idempotencyKey, O(e)));
266
+ }),
267
+ suppress: u.suppress,
268
+ unsuppress: u.unsuppress,
269
+ isSuppressed: u.isSuppressed,
270
+ provider: {
271
+ name: t.provider.name,
272
+ managesUnsubscribe: t.provider.managesUnsubscribe === !0
273
+ }
274
+ },
275
+ scheduler: w
276
+ };
277
+ }, T = (e) => w(e).service, E = (e) => y.succeed(t, T(e));
278
+ //#endregion
279
+ export { T as buildMailService, w as buildMailServiceWithScheduler, E as mailServiceLayer, C as resolveProvider };
@@ -0,0 +1,24 @@
1
+ //#region src/skeleton.ts
2
+ var e = (t, n) => {
3
+ if (n > 6) return null;
4
+ let r = t.encoding === void 0 ? t : t.encoding[t.encoding.length - 1].to;
5
+ if (r !== t) return e(r, n + 1);
6
+ switch (t._tag) {
7
+ case "String": return "";
8
+ case "Number": return 0;
9
+ case "Boolean": return !1;
10
+ case "Literal": return t.literal;
11
+ case "Enum": return t.enums.length > 0 ? t.enums[0][1] : null;
12
+ case "Objects": {
13
+ let r = {};
14
+ for (let i of t.propertySignatures) typeof i.name == "string" && (r[i.name] = e(i.type, n + 1));
15
+ return r;
16
+ }
17
+ case "Arrays": return [];
18
+ case "Union": return t.types.length > 0 ? e(t.types[0], n + 1) : null;
19
+ case "Suspend": return e(t.thunk(), n + 1);
20
+ default: return null;
21
+ }
22
+ }, t = (t) => e(t.ast, 0);
23
+ //#endregion
24
+ export { t as schemaSkeleton };
@@ -0,0 +1,28 @@
1
+ import { Effect as e } from "effect";
2
+ import { dropped as t, id as n, table as r, text as i, timestamp as a } from "@voltro/database";
3
+ //#region src/storeTables.ts
4
+ var o = "_voltro_mail_suppression", s = "_voltro_mail_idempotency", c = r(o, {
5
+ id: n({ scheme: "numeric" }),
6
+ tenant: i().maxLength(191),
7
+ email: i().maxLength(320),
8
+ reason: i().nullable(),
9
+ createdAt: a().default("now"),
10
+ created_at: t()
11
+ }).unique("mailSuppressionAddress", ["tenant", "email"]).nonReactive(), l = r(s, {
12
+ id: n({ scheme: "numeric" }),
13
+ tenant: i().maxLength(191),
14
+ key: i().maxLength(255),
15
+ resultId: i().nullable().renamedFrom("result_id"),
16
+ resultProvider: i().nullable().renamedFrom("result_provider"),
17
+ createdAt: a().default("now"),
18
+ created_at: t()
19
+ }).unique("mailIdempotencyKey", ["tenant", "key"]).nonReactive(), u = (t) => e.tryPromise({
20
+ try: t,
21
+ catch: (e) => e
22
+ }), d = (e) => {
23
+ let t = [];
24
+ for (let n = e, r = 0; n != null && r < 4; n = n.cause, r++) t.push(n instanceof Error ? n.message : String(n));
25
+ return t.join(" ← ");
26
+ };
27
+ //#endregion
28
+ export { s as MAIL_IDEMPOTENCY_TABLE, o as MAIL_SUPPRESSION_TABLE, u as attempt, d as describeError, l as mailIdempotencyTable, c as mailSuppressionTable };
@@ -0,0 +1,46 @@
1
+ import { MAIL_SUPPRESSION_TABLE as e, attempt as t, describeError as n, mailSuppressionTable as r } from "./storeTables.js";
2
+ import { Effect as i } from "effect";
3
+ import { createLogger as a } from "@voltro/logger";
4
+ import { and as o, eq as s, inSet as c, queryFor as l } from "@voltro/database";
5
+ //#region src/suppression.ts
6
+ var u = a({ scope: "@voltro/plugin-mail" }), d = (e) => e.trim().toLowerCase(), f = "*", p = (e) => e ?? f, m = () => {
7
+ let e = /* @__PURE__ */ new Set(), t = (e, t) => `${p(e)}\u0000${d(t)}`;
8
+ return {
9
+ isSuppressed: (n, r) => i.sync(() => e.has(t(n, r)) || e.has(t(null, r))),
10
+ suppress: (n, r) => i.sync(() => void e.add(t(n, r))),
11
+ unsuppress: (n, r) => i.sync(() => void e.delete(t(n, r)))
12
+ };
13
+ }, h = () => {
14
+ let a = null, m = (e, t) => ({
15
+ tenant: p(e),
16
+ email: d(t)
17
+ });
18
+ return {
19
+ isSuppressed: (e, n) => {
20
+ let u = a;
21
+ return u ? t(() => u.query(l(r).where(o(s("email", d(n)), c("tenant", [p(e), f]))).limit(1).descriptor)).pipe(i.map((e) => e.length > 0), i.catch(() => i.succeed(!1))) : i.succeed(!1);
22
+ },
23
+ suppress: (r, o, s) => {
24
+ let c = a;
25
+ return c ? t(() => c.upsert(e, {
26
+ ...m(r, o),
27
+ reason: s,
28
+ createdAt: /* @__PURE__ */ new Date()
29
+ }, { conflictColumns: ["tenant", "email"] })).pipe(i.asVoid, i.catch((e) => i.sync(() => u.warn("suppress write failed", {
30
+ email: d(o),
31
+ message: n(e)
32
+ })))) : i.void;
33
+ },
34
+ unsuppress: (n, r) => {
35
+ let c = a;
36
+ if (!c) return i.void;
37
+ let { tenant: l, email: u } = m(n, r);
38
+ return t(() => c.deleteMany(e, { where: o(s("tenant", l), s("email", u)) })).pipe(i.asVoid, i.catch(() => i.void));
39
+ },
40
+ bind: async (e) => {
41
+ a = e;
42
+ }
43
+ };
44
+ };
45
+ //#endregion
46
+ export { m as memorySuppressionStore, h as postgresSuppressionStore };
package/dist/types.js ADDED
@@ -0,0 +1,20 @@
1
+ import { Context as e, Schema as t } from "effect";
2
+ //#region src/types.ts
3
+ var n = class extends t.TaggedError()("MailError", {
4
+ provider: t.String,
5
+ message: t.String,
6
+ transient: t.Boolean,
7
+ status: t.optional(t.Number)
8
+ }) {}, r = class extends t.TaggedError()("MailWebhookError", {
9
+ provider: t.String,
10
+ message: t.String
11
+ }) {}, i = (e) => ({
12
+ name: e.name,
13
+ props: e.props,
14
+ subject: e.subject,
15
+ render: e.render,
16
+ ...e.locale === void 0 ? {} : { locale: e.locale },
17
+ ...e.preview === void 0 ? {} : { preview: e.preview }
18
+ }), a = class extends e.Service()("@voltro/plugin-mail/MailService") {}, o = (e) => e === void 0 ? [] : typeof e == "string" ? [e] : e;
19
+ //#endregion
20
+ export { n as MailError, a as MailService, r as MailWebhookError, i as defineEmail, o as toList };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-mail",
3
- "version": "0.84.0",
3
+ "version": "0.86.0",
4
4
  "description": "Transactional and broadcast email — one MailProvider contract, multiple adapters (Lettermint / Resend / Postmark / SendGrid / SES / Mailgun / SMTP + console/memory for dev/test). Exposes a MailService via the plugin services layer with transient retry, suppression, a mounted delivery-event webhook and a dev allowlist.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -33,19 +33,17 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "dependencies": {
36
- "@effect/platform": "^0.97.2",
37
- "@effect/sql": "^0.52.1",
38
- "@voltro/database": "0.84.0",
39
- "@voltro/env": "0.84.0",
40
- "@voltro/logger": "0.84.0",
41
- "@voltro/protocol": "0.84.0"
36
+ "@voltro/database": "0.86.0",
37
+ "@voltro/env": "0.86.0",
38
+ "@voltro/logger": "0.86.0",
39
+ "@voltro/protocol": "0.86.0"
42
40
  },
43
41
  "optionalDependencies": {
44
42
  "nodemailer": "^10.0.10"
45
43
  },
46
44
  "peerDependencies": {
47
45
  "@react-email/render": "^2.0.8",
48
- "effect": "^3.22.2",
46
+ "effect": "4.0.1",
49
47
  "react": "^19.0.0",
50
48
  "react-dom": "^19.0.0"
51
49
  },