@softure-ai/mailing 0.1.11 → 0.1.12

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +173 -6
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +176 -24
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +5 -3
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/next/index.d.ts +3 -1
  13. package/dist/next/index.d.ts.map +1 -1
  14. package/dist/next/index.js +3 -1
  15. package/dist/next/index.js.map +1 -1
  16. package/dist/next/pages.d.ts +25 -0
  17. package/dist/next/pages.d.ts.map +1 -1
  18. package/dist/next/pages.js +33 -10
  19. package/dist/next/pages.js.map +1 -1
  20. package/dist/next/preview-mail.d.ts +5 -0
  21. package/dist/next/preview-mail.d.ts.map +1 -0
  22. package/dist/next/preview-mail.js +8 -0
  23. package/dist/next/preview-mail.js.map +1 -0
  24. package/dist/next/run-deliveries.d.ts +4 -0
  25. package/dist/next/run-deliveries.d.ts.map +1 -0
  26. package/dist/next/run-deliveries.js +7 -0
  27. package/dist/next/run-deliveries.js.map +1 -0
  28. package/dist/options.d.ts +3 -1
  29. package/dist/options.d.ts.map +1 -1
  30. package/dist/options.js +16 -1
  31. package/dist/options.js.map +1 -1
  32. package/dist/server/campaign-file.d.ts +7 -2
  33. package/dist/server/campaign-file.d.ts.map +1 -1
  34. package/dist/server/campaign-file.js +35 -15
  35. package/dist/server/campaign-file.js.map +1 -1
  36. package/dist/server/campaigns.d.ts.map +1 -1
  37. package/dist/server/campaigns.js +4 -4
  38. package/dist/server/campaigns.js.map +1 -1
  39. package/dist/server/deliveries.d.ts +19 -0
  40. package/dist/server/deliveries.d.ts.map +1 -1
  41. package/dist/server/deliveries.js +29 -0
  42. package/dist/server/deliveries.js.map +1 -1
  43. package/dist/server/dns.d.ts +15 -1
  44. package/dist/server/dns.d.ts.map +1 -1
  45. package/dist/server/dns.js +45 -4
  46. package/dist/server/dns.js.map +1 -1
  47. package/dist/server/index.d.ts +6 -4
  48. package/dist/server/index.d.ts.map +1 -1
  49. package/dist/server/index.js +6 -4
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/operator-output.d.ts +5 -0
  52. package/dist/server/operator-output.d.ts.map +1 -0
  53. package/dist/server/operator-output.js +19 -0
  54. package/dist/server/operator-output.js.map +1 -0
  55. package/dist/server/options.d.ts +2 -0
  56. package/dist/server/options.d.ts.map +1 -1
  57. package/dist/server/options.js +5 -0
  58. package/dist/server/options.js.map +1 -1
  59. package/dist/server/run-deliveries.d.ts +55 -0
  60. package/dist/server/run-deliveries.d.ts.map +1 -0
  61. package/dist/server/run-deliveries.js +132 -0
  62. package/dist/server/run-deliveries.js.map +1 -0
  63. package/dist/server/send-mail.d.ts +21 -2
  64. package/dist/server/send-mail.d.ts.map +1 -1
  65. package/dist/server/send-mail.js +50 -30
  66. package/dist/server/send-mail.js.map +1 -1
  67. package/dist/server/suppressions.d.ts +5 -0
  68. package/dist/server/suppressions.d.ts.map +1 -1
  69. package/dist/server/suppressions.js +27 -1
  70. package/dist/server/suppressions.js.map +1 -1
  71. package/migrations/0005_add_sql_surface.sql +44 -0
  72. package/module.json +1 -1
  73. package/package.json +1 -1
  74. package/src/cli/run.ts +179 -24
  75. package/src/contract.ts +9 -3
  76. package/src/index.ts +1 -1
  77. package/src/next/index.ts +3 -1
  78. package/src/next/pages.tsx +69 -14
  79. package/src/next/preview-mail.ts +9 -0
  80. package/src/next/run-deliveries.ts +10 -0
  81. package/src/options.ts +18 -1
  82. package/src/server/campaign-file.ts +36 -2
  83. package/src/server/campaigns.ts +5 -5
  84. package/src/server/deliveries.ts +33 -0
  85. package/src/server/dns.ts +60 -4
  86. package/src/server/index.ts +7 -3
  87. package/src/server/operator-output.ts +20 -0
  88. package/src/server/options.ts +6 -0
  89. package/src/server/run-deliveries.ts +184 -0
  90. package/src/server/send-mail.ts +63 -37
  91. package/src/server/suppressions.ts +28 -1
@@ -3,7 +3,7 @@
3
3
  // or the result, and `headers` cannot replace the envelope or the sender (a mail's own reply-to is
4
4
  // the `replyTo` field, checked like `to`). List mail (any kind but `transactional`) also gets a signed unsubscribe
5
5
  // link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
6
- import { err, ok, type SoftureConfig } from "@softure-ai/core";
6
+ import { err, ok, type Err, type Ok, type SoftureConfig } from "@softure-ai/core";
7
7
  import type { Queryable } from "@softure-ai/db";
8
8
  import {
9
9
  TRANSACTIONAL_KIND,
@@ -20,7 +20,7 @@ import type { MailingMessages } from "../messages/index.js";
20
20
  import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
21
21
  import { getMailingModule, getMailingOptions } from "./options.js";
22
22
  import { isSuppressed } from "./suppressions.js";
23
- import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
23
+ import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "./unsubscribe-link.js";
24
24
  import { validateMail, type ValidMail } from "./validate-mail.js";
25
25
 
26
26
  /**
@@ -32,8 +32,6 @@ export interface MailContext {
32
32
  readonly db?: Queryable;
33
33
  }
34
34
 
35
- type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
36
-
37
35
  /**
38
36
  * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
39
37
  * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
@@ -43,26 +41,24 @@ type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
43
41
  * Throws only when the module is not enabled, or for list mail without `context.db`.
44
42
  */
45
43
  export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
46
- const { from, replyTo, provider, timeoutMs } = getMailingOptions(context.config);
44
+ const { provider, timeoutMs } = getMailingOptions(context.config);
47
45
 
48
46
  const validation = validateMail(mail, options);
49
47
  if (!validation.ok) {
50
48
  logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
51
49
  return err("mailing.invalid_input");
52
50
  }
53
-
54
- const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = validation.value;
55
- let message: MailContent = content;
56
- if (kind !== TRANSACTIONAL_KIND) {
57
- const listMail = await prepareListMail(context, validation.value);
58
- if (!listMail.ok) {
59
- logFailure(provider, listMail.error, { cause: listMail.cause });
60
- return err(listMail.error);
51
+ const valid = validation.value;
52
+ const secret = readUnsubscribeSecrets().current;
53
+ if (valid.kind !== TRANSACTIONAL_KIND) {
54
+ const refusal = await checkListMail(context, valid, secret);
55
+ if (refusal !== null) {
56
+ logFailure(provider, refusal.error, { cause: refusal.cause });
57
+ return err(refusal.error);
61
58
  }
62
- message = listMail.value;
63
59
  }
64
60
 
65
- const outcome = await callProvider(provider, { ...message, from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey }, timeoutMs);
61
+ const outcome = await callProvider(provider, composeMessage(context.config, valid, secret), timeoutMs);
66
62
  if (outcome.status === "sent") {
67
63
  return ok({ id: outcome.id, provider: provider.name });
68
64
  }
@@ -71,6 +67,33 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
71
67
  return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
72
68
  }
73
69
 
70
+ /** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
71
+ export type MailPreviewFailure =
72
+ | (Err<"mailing.invalid_input"> & { readonly fields: readonly string[] })
73
+ | (Err<"mailing.unavailable"> & { readonly cause: "no_unsubscribe_secret" });
74
+
75
+ export type MailPreviewResult = Ok<ProviderMessage> | MailPreviewFailure;
76
+
77
+ export interface PreviewMailOptions extends SendMailOptions {
78
+ /** Where the unsubscribe secret is read. Default: `process.env`. */
79
+ readonly env?: Env;
80
+ }
81
+
82
+ /**
83
+ * Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, and for list mail the
84
+ * footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
85
+ * database: it does not check the suppression list, so it renders a mail `sendMail` would refuse as suppressed.
86
+ * The links in it are credentials for `mail.to`. Throws only when the module is not enabled.
87
+ */
88
+ export function previewMail(config: SoftureConfig, mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
89
+ const { env, ...sendOptions } = options;
90
+ const validation = validateMail(mail, sendOptions);
91
+ if (!validation.ok) return { ...err("mailing.invalid_input"), fields: validation.fields };
92
+ const secret = readUnsubscribeSecrets(env).current;
93
+ if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) return { ...err("mailing.unavailable"), cause: "no_unsubscribe_secret" };
94
+ return ok(composeMessage(config, validation.value, secret));
95
+ }
96
+
74
97
  const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> = {
75
98
  rejected: "mailing.rejected",
76
99
  unavailable: "mailing.unavailable",
@@ -78,44 +101,47 @@ const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> =
78
101
  quota_exceeded: "mailing.quota_exceeded",
79
102
  };
80
103
 
81
- type ListMailResult =
82
- | { readonly ok: true; readonly value: MailContent }
83
- | { readonly ok: false; readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
104
+ type ListMailRefusal = { readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
84
105
 
85
106
  /**
86
- * A list mail with its footer and headers, or why it must not go out. Fails closed: without the
87
- * secret, or when the suppression list cannot be read, nothing is sent.
107
+ * Why a list mail must not go out, or `null` when it may. Fails closed: without the secret, or when the suppression
108
+ * list cannot be read, nothing is sent.
88
109
  */
89
- async function prepareListMail(context: MailContext, mail: ValidMail): Promise<ListMailResult> {
110
+ async function checkListMail(context: MailContext, mail: ValidMail, secret: string | null): Promise<ListMailRefusal | null> {
90
111
  if (context.db === undefined) {
91
112
  throw new Error("@softure-ai/mailing: list mail needs the database handle; call sendMail({ config, db }, ...)");
92
113
  }
93
- const secret = readUnsubscribeSecrets().current;
94
114
  if (secret === null) {
95
115
  console.error(`mailing: list mail needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters) to sign unsubscribe links`);
96
- return { ok: false, error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
116
+ return { error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
97
117
  }
98
- let suppressed: boolean;
99
118
  try {
100
- suppressed = await isSuppressed({ db: context.db }, mail.to);
119
+ return (await isSuppressed({ db: context.db }, mail.to)) ? { error: "mailing.suppressed", cause: "unsubscribed" } : null;
101
120
  } catch {
102
121
  // The error text can carry the query parameters, the recipient key among them.
103
- return { ok: false, error: "mailing.unavailable", cause: "suppressions_unreadable" };
122
+ return { error: "mailing.unavailable", cause: "suppressions_unreadable" };
104
123
  }
105
- if (suppressed) return { ok: false, error: "mailing.suppressed", cause: "unsubscribed" };
124
+ }
106
125
 
107
- const links = buildUnsubscribeLinks(context.config, mail.to, secret);
126
+ /**
127
+ * The message the provider receives: the configured sender, the mail's or the configured reply-to, and for list
128
+ * mail the footer and headers signed with `secret` (which the caller checked is set).
129
+ */
130
+ function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string | null): ProviderMessage {
131
+ const { from, replyTo } = getMailingOptions(config);
132
+ const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
133
+ const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey };
134
+ if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
135
+ const links = buildUnsubscribeLinks(config, mail.to, secret);
108
136
  // The module factory merged the dictionaries; their shape is the module's own.
109
- const copy = (getMailingModule(context.config).messages[context.config.locale] as MailingMessages).footer;
137
+ const copy = (getMailingModule(config).messages[config.locale] as MailingMessages).footer;
110
138
  return {
111
- ok: true,
112
- value: {
113
- to: mail.to,
114
- subject: mail.subject,
115
- text: addTextFooter(mail.text, links, copy),
116
- html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
117
- headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
118
- },
139
+ ...envelope,
140
+ to: mail.to,
141
+ subject: mail.subject,
142
+ text: addTextFooter(mail.text, links, copy),
143
+ html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
144
+ headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
119
145
  };
120
146
  }
121
147
 
@@ -23,6 +23,33 @@ export async function isSuppressed(ctx: Pick<SuppressionContext, "db">, address:
23
23
  return rows.length > 0;
24
24
  }
25
25
 
26
+ /** How many recipient keys one query asks about, so a long list never builds an oversized statement. */
27
+ const SUPPRESSION_LOOKUP_BATCH = 500;
28
+
29
+ /**
30
+ * Which of `addresses` unsubscribed, each returned as it was given; e.g. a report that splits a list into
31
+ * active and suppressed recipients. Throws on a database failure: callers decide how to fail.
32
+ */
33
+ export async function findSuppressedAddresses(ctx: Pick<SuppressionContext, "db">, addresses: readonly string[]): Promise<Set<string>> {
34
+ const byKey = new Map<string, string[]>();
35
+ for (const address of addresses) {
36
+ const key = getRecipientKey(address);
37
+ byKey.set(key, [...(byKey.get(key) ?? []), address]);
38
+ }
39
+ const keys = [...byKey.keys()];
40
+ const suppressed = new Set<string>();
41
+ for (let start = 0; start < keys.length; start += SUPPRESSION_LOOKUP_BATCH) {
42
+ const rows = await ctx.db
43
+ .select({ recipientKey: suppressions.recipientKey })
44
+ .from(suppressions)
45
+ .where(inArray(suppressions.recipientKey, keys.slice(start, start + SUPPRESSION_LOOKUP_BATCH)));
46
+ for (const row of rows) {
47
+ for (const address of byKey.get(row.recipientKey) ?? []) suppressed.add(address);
48
+ }
49
+ }
50
+ return suppressed;
51
+ }
52
+
26
53
  /** Records an opt-out by recipient key. Throws on a database failure. */
27
54
  async function recordSuppression(ctx: SuppressionContext, recipientKey: string, source: SuppressionSource): Promise<void> {
28
55
  await ctx.db.insert(suppressions).values({ recipientKey, source, createdAt: ctx.clock.now() }).onConflictDoNothing();
@@ -93,7 +120,7 @@ async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | Unsub
93
120
  }
94
121
  const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
95
122
  if (legacy === undefined) return null;
96
- const address: unknown = await legacy.verify(link.values, ctx);
123
+ const address: unknown = await legacy.verify(link.values, ctx, env);
97
124
  if (typeof address !== "string" || address.trim() === "") return null;
98
125
  return { recipientKey: getRecipientKey(address), link: { scheme: "legacy", values: link.values } };
99
126
  }