@softure-ai/mailing 0.1.6 → 0.1.8

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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +108 -25
  3. package/dist/cli/run.d.ts +3 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +141 -25
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +48 -8
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/contract.js +2 -0
  10. package/dist/contract.js.map +1 -1
  11. package/dist/index.d.ts +12 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/messages/en.d.ts +2 -0
  16. package/dist/messages/en.d.ts.map +1 -1
  17. package/dist/messages/en.js +2 -0
  18. package/dist/messages/en.js.map +1 -1
  19. package/dist/messages/index.d.ts +4 -0
  20. package/dist/messages/index.d.ts.map +1 -1
  21. package/dist/messages/pl.d.ts.map +1 -1
  22. package/dist/messages/pl.js +2 -0
  23. package/dist/messages/pl.js.map +1 -1
  24. package/dist/next/actions.d.ts.map +1 -1
  25. package/dist/next/actions.js +6 -6
  26. package/dist/next/actions.js.map +1 -1
  27. package/dist/next/pages.d.ts.map +1 -1
  28. package/dist/next/pages.js +8 -6
  29. package/dist/next/pages.js.map +1 -1
  30. package/dist/next/route.d.ts +2 -1
  31. package/dist/next/route.d.ts.map +1 -1
  32. package/dist/next/route.js +6 -4
  33. package/dist/next/route.js.map +1 -1
  34. package/dist/options.d.ts +15 -1
  35. package/dist/options.d.ts.map +1 -1
  36. package/dist/options.js +32 -1
  37. package/dist/options.js.map +1 -1
  38. package/dist/providers/resend.d.ts.map +1 -1
  39. package/dist/providers/resend.js +12 -6
  40. package/dist/providers/resend.js.map +1 -1
  41. package/dist/schema.d.ts +17 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +2 -0
  44. package/dist/schema.js.map +1 -1
  45. package/dist/server/campaigns.d.ts +30 -4
  46. package/dist/server/campaigns.d.ts.map +1 -1
  47. package/dist/server/campaigns.js +67 -12
  48. package/dist/server/campaigns.js.map +1 -1
  49. package/dist/server/deliveries.d.ts +30 -2
  50. package/dist/server/deliveries.d.ts.map +1 -1
  51. package/dist/server/deliveries.js +58 -23
  52. package/dist/server/deliveries.js.map +1 -1
  53. package/dist/server/dns.d.ts +48 -1
  54. package/dist/server/dns.d.ts.map +1 -1
  55. package/dist/server/dns.js +124 -15
  56. package/dist/server/dns.js.map +1 -1
  57. package/dist/server/index.d.ts +4 -4
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -3
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/send-mail.d.ts +3 -1
  62. package/dist/server/send-mail.d.ts.map +1 -1
  63. package/dist/server/send-mail.js +18 -6
  64. package/dist/server/send-mail.js.map +1 -1
  65. package/dist/server/suppressions.d.ts +8 -6
  66. package/dist/server/suppressions.d.ts.map +1 -1
  67. package/dist/server/suppressions.js +23 -7
  68. package/dist/server/suppressions.js.map +1 -1
  69. package/dist/server/unsubscribe-link.d.ts +23 -0
  70. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  71. package/dist/server/unsubscribe-link.js +30 -1
  72. package/dist/server/unsubscribe-link.js.map +1 -1
  73. package/migrations/0003_add_delivery_provider_status.sql +13 -0
  74. package/module.json +1 -1
  75. package/package.json +1 -1
  76. package/src/cli/run.ts +164 -29
  77. package/src/contract.ts +49 -6
  78. package/src/index.ts +8 -3
  79. package/src/messages/en.ts +2 -0
  80. package/src/messages/pl.ts +2 -0
  81. package/src/next/actions.ts +5 -6
  82. package/src/next/pages.tsx +10 -7
  83. package/src/next/route.ts +6 -4
  84. package/src/options.ts +36 -2
  85. package/src/providers/resend.ts +11 -7
  86. package/src/schema.ts +2 -0
  87. package/src/server/campaigns.ts +95 -17
  88. package/src/server/deliveries.ts +89 -29
  89. package/src/server/dns.ts +174 -16
  90. package/src/server/index.ts +17 -0
  91. package/src/server/send-mail.ts +30 -6
  92. package/src/server/suppressions.ts +23 -8
  93. package/src/server/unsubscribe-link.ts +41 -1
@@ -1 +1 @@
1
- {"version":3,"file":"suppressions.js","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"suppressions.js","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"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"}
@@ -44,6 +44,29 @@ export declare function readUnsubscribeSecrets(env?: Env): UnsubscribeSecrets;
44
44
  export declare function verifyUnsubscribeToken(token: UnsubscribeToken, secrets: UnsubscribeSecrets): boolean;
45
45
  /** The two links for `address`, on the app's origin and the module's routes. */
46
46
  export declare function buildUnsubscribeLinks(config: SoftureConfig, address: string, secret: string): UnsubscribeLinks;
47
+ /** A legacy link's value longer than this makes the link invalid before the app's `verify` sees it. */
48
+ export declare const MAX_LEGACY_VALUE_LENGTH = 512;
49
+ /** The link a person followed: the module's signed one, or one the app sent before it adopted the module. */
50
+ export type UnsubscribeLink = {
51
+ readonly scheme: "signed";
52
+ readonly token: UnsubscribeToken;
53
+ } | {
54
+ readonly scheme: "legacy";
55
+ readonly values: Readonly<Record<string, string>>;
56
+ };
57
+ /** Reads one query or form value; `URLSearchParams` and `FormData` both fit. */
58
+ export interface LinkParams {
59
+ get(name: string): unknown;
60
+ }
61
+ /**
62
+ * The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
63
+ * carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
64
+ * `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
65
+ * a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
66
+ */
67
+ export declare function readUnsubscribeLink(params: LinkParams, config: SoftureConfig): UnsubscribeLink | null;
68
+ /** The query (or form) parameters that carry `link`, to send it back to the page. */
69
+ export declare function getUnsubscribeLinkParams(link: UnsubscribeLink): Record<string, string>;
47
70
  /** The token in a link's query (or a form), when both parameters are present once. */
48
71
  export declare function readUnsubscribeToken(params: {
49
72
  get(name: string): unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe-link.d.ts","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,yCAAyC;AACzC,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,sEAAsE;AACtE,eAAO,MAAM,+BAA+B,wCAAwC,CAAC;AACrF,kFAAkF;AAClF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,0CAA0C;AAC1C,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,eAAO,MAAM,eAAe,MAAM,CAAC;AAWnC,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAE/D,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wCAAwC;AACxC,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,yFAAyF;AACzF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,GAAiB,GAAG,kBAAkB,CAEjF;AAMD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAWpG;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAQ9G;AAED,sFAAsF;AACtF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GAAG,gBAAgB,GAAG,IAAI,CAIpG"}
1
+ {"version":3,"file":"unsubscribe-link.d.ts","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,yCAAyC;AACzC,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,sEAAsE;AACtE,eAAO,MAAM,+BAA+B,wCAAwC,CAAC;AACrF,kFAAkF;AAClF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,0CAA0C;AAC1C,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,eAAO,MAAM,eAAe,MAAM,CAAC;AAWnC,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAE/D,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wCAAwC;AACxC,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,yFAAyF;AACzF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,GAAiB,GAAG,kBAAkB,CAEjF;AAMD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAWpG;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAQ9G;AAED,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAE3C,6GAA6G;AAC7G,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAA;CAAE,GAC/D;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,CAAC;AAErF,gFAAgF;AAChF,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CAC5B;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,GAAG,eAAe,GAAG,IAAI,CAcrG;AAED,qFAAqF;AACrF,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEtF;AAED,sFAAsF;AACtF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GAAG,gBAAgB,GAAG,IAAI,CAIpG"}
@@ -4,7 +4,7 @@
4
4
  // during a rotation. The whole link is a credential: whoever has it can unsubscribe that address,
5
5
  // so it never reaches a log, an error or a result.
6
6
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
7
- import { getMailingRoutes } from "./options.js";
7
+ import { getMailingOptions, getMailingRoutes } from "./options.js";
8
8
  /** Signs every new link and verifies. */
9
9
  export const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
10
10
  /** Optional: verifies links signed before a rotation, never signs. */
@@ -69,6 +69,35 @@ export function buildUnsubscribeLinks(config, address, secret) {
69
69
  oneClick: `${config.appOrigin}${routes.oneClick}?${query.toString()}`,
70
70
  };
71
71
  }
72
+ /** A legacy link's value longer than this makes the link invalid before the app's `verify` sees it. */
73
+ export const MAX_LEGACY_VALUE_LENGTH = 512;
74
+ /**
75
+ * The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
76
+ * carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
77
+ * `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
78
+ * a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
79
+ */
80
+ export function readUnsubscribeLink(params, config) {
81
+ if (params.get(RECIPIENT_PARAM) !== null && params.get(RECIPIENT_PARAM) !== undefined) {
82
+ const token = readUnsubscribeToken(params);
83
+ return token === null ? null : { scheme: "signed", token };
84
+ }
85
+ const legacy = getMailingOptions(config).legacyUnsubscribe;
86
+ if (legacy === undefined)
87
+ return null;
88
+ const values = {};
89
+ for (const name of legacy.params) {
90
+ const value = params.get(name);
91
+ if (typeof value !== "string" || value === "" || value.length > MAX_LEGACY_VALUE_LENGTH)
92
+ return null;
93
+ values[name] = value;
94
+ }
95
+ return { scheme: "legacy", values };
96
+ }
97
+ /** The query (or form) parameters that carry `link`, to send it back to the page. */
98
+ export function getUnsubscribeLinkParams(link) {
99
+ return link.scheme === "signed" ? { [RECIPIENT_PARAM]: link.token.recipientKey, [SIGNATURE_PARAM]: link.token.signature } : { ...link.values };
100
+ }
72
101
  /** The token in a link's query (or a form), when both parameters are present once. */
73
102
  export function readUnsubscribeToken(params) {
74
103
  const recipientKey = params.get(RECIPIENT_PARAM);
@@ -1 +1 @@
1
- {"version":3,"file":"unsubscribe-link.js","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"unsubscribe-link.js","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ -- Keeps the provider's HTTP status with a delivery (the last failed answer, also on a released claim), and lets a
2
+ -- released claim give its attempt back: a halt (refused key, spent quota) says nothing about the mail, so a
3
+ -- `pending` row may hold 0 attempts until it is claimed again.
4
+ -- Rollback: ALTER TABLE mailing.deliveries DROP COLUMN provider_status;
5
+ -- UPDATE mailing.deliveries SET attempts = 1 WHERE attempts = 0;
6
+ -- ALTER TABLE mailing.deliveries DROP CONSTRAINT deliveries_attempts_check;
7
+ -- ALTER TABLE mailing.deliveries ADD CONSTRAINT deliveries_attempts_check CHECK (attempts >= 1);
8
+ -- DELETE FROM softure.migrations WHERE module = 'mailing' AND version = 3;
9
+
10
+ ALTER TABLE deliveries ADD COLUMN provider_status integer CHECK (provider_status BETWEEN 100 AND 599);
11
+
12
+ ALTER TABLE deliveries DROP CONSTRAINT deliveries_attempts_check;
13
+ ALTER TABLE deliveries ADD CONSTRAINT deliveries_attempts_check CHECK (attempts >= 1 OR (attempts = 0 AND status = 'pending'));
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mailing",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "dependsOn": {},
5
5
  "dbSchema": "mailing",
6
6
  "tables": ["suppressions", "campaigns", "deliveries"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/mailing",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "SOFTURE AI mailing module: sendMail over a provider adapter (Resend first) with plain-text and HTML bodies, sender and reply-to from config, idempotency keys, a timeout and a typed result; signed one-click unsubscribe (RFC 8058) with a suppression list for list mail; an exactly-once delivery ledger, campaigns sent from a content file by the softure-mail command, and a sender DNS check; a fake provider for tests.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli/run.ts CHANGED
@@ -13,8 +13,8 @@ import { systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@so
13
13
  import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
14
14
  import { parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
15
15
  import { planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
16
- import type { DeliveryContext } from "../server/deliveries.js";
17
- import { checkSenderDns, getSenderDomain, type DnsCheck, type ResolveTxt } from "../server/dns.js";
16
+ import type { DeliveryContext, HaltingErrorCode } from "../server/deliveries.js";
17
+ import { checkSenderDns, getSenderDomain, resendReturnPath, type DmarcAlignment, type DmarcExpectation, type DmarcPolicy, type DnsCheck, type ResolveCname, type ResolveMx, type ResolveTxt, type ReturnPathHost } from "../server/dns.js";
18
18
  import { getMailingOptions } from "../server/options.js";
19
19
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
20
20
 
@@ -34,6 +34,8 @@ export interface RunMailCliOptions {
34
34
  /** Opens the campaign's database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
35
35
  readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
36
36
  readonly resolveTxt?: ResolveTxt;
37
+ readonly resolveMx?: ResolveMx;
38
+ readonly resolveCname?: ResolveCname;
37
39
  readonly sleep?: (ms: number) => Promise<void>;
38
40
  readonly env?: Env;
39
41
  }
@@ -46,20 +48,34 @@ export const EXIT_USAGE = 2;
46
48
  export const DEFAULT_PAUSE_MS = 500;
47
49
 
48
50
  export const MAIL_USAGE = `Usage:
49
- softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>]
51
+ softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
50
52
  softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
53
+ [--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
54
+ [--reply-to <address>] [--return-path <host>]... [--resend-return-path]
51
55
 
52
56
  campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
53
57
  then the text body) to every address in the recipients file (one per line), at most
54
- once each. Re-running sends only to recipients without an outcome yet.
55
- --dry-run count what would be sent and change nothing
56
- --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
58
+ once each. Re-running sends only to recipients without an outcome yet. A refused
59
+ API key or a spent quota stops the run; the rest go out on the next one.
60
+ --dry-run count what would be sent and change nothing
61
+ --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
62
+ --resend-uncertain also send to recipients whose send was interrupted long ago
63
+ (it may have gone out: they may get the mail twice)
57
64
 
58
65
  dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
59
66
  --domain <domain> check this domain instead
60
67
  --dkim-selector <name> DKIM selector to look up (default resend; repeatable)
61
68
  --spf-host <host> where SPF is published (default the domain; repeatable;
62
69
  Resend publishes it on send.<domain>)
70
+ --dmarc-policy <p> require at least this DMARC policy (quarantine or reject)
71
+ and pct=100; a weaker record fails
72
+ --dmarc-sp <p> require at least this subdomain policy (sp, else p)
73
+ --dmarc-adkim <a> require this DKIM alignment (r or s; absent counts as r)
74
+ --dmarc-aspf <a> require this SPF alignment (r or s; absent counts as r)
75
+ --reply-to <address> check that this address's domain accepts mail (MX, one SPF)
76
+ (default: replyTo of the config, when --domain is not given)
77
+ --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
78
+ --resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
63
79
 
64
80
  Options for both:
65
81
  --config <file> the app's softure.config file (bin only)
@@ -72,8 +88,28 @@ const consoleOutput: CliOutput = {
72
88
 
73
89
  type Command =
74
90
  | { readonly kind: "help" }
75
- | { readonly kind: "campaign"; readonly contentFile: string; readonly recipientsFile: string; readonly dryRun: boolean; readonly pauseMs: number }
76
- | { readonly kind: "dns"; readonly domain: string | undefined; readonly dkimSelectors: readonly string[] | undefined; readonly spfHosts: readonly string[] | undefined };
91
+ | {
92
+ readonly kind: "campaign";
93
+ readonly contentFile: string;
94
+ readonly recipientsFile: string;
95
+ readonly dryRun: boolean;
96
+ readonly pauseMs: number;
97
+ readonly resendUncertain: boolean;
98
+ }
99
+ | {
100
+ readonly kind: "dns";
101
+ readonly domain: string | undefined;
102
+ readonly dkimSelectors: readonly string[] | undefined;
103
+ readonly spfHosts: readonly string[] | undefined;
104
+ readonly expectDmarc: DmarcExpectation | undefined;
105
+ readonly replyTo: string | undefined;
106
+ readonly returnPathHosts: readonly string[];
107
+ readonly resendReturnPath: boolean;
108
+ };
109
+
110
+ const DMARC_POLICIES: readonly DmarcPolicy[] = ["quarantine", "reject"];
111
+ const DMARC_ALIGNMENTS: readonly DmarcAlignment[] = ["r", "s"];
112
+ const DNS_ONLY_OPTIONS = ["domain", "dkim-selector", "spf-host", "dmarc-policy", "dmarc-sp", "dmarc-adkim", "dmarc-aspf", "reply-to", "return-path", "resend-return-path"] as const;
77
113
 
78
114
  /** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
79
115
  export async function runMailCli(options: RunMailCliOptions): Promise<number> {
@@ -111,13 +147,24 @@ function parseCommand(argv: readonly string[]): Command | string {
111
147
  if (values.help === true) return { kind: "help" };
112
148
 
113
149
  if (name === "dns") {
114
- const misplaced = (["recipients", "dry-run", "pause-ms"] as const).filter((option) => values[option] !== undefined);
150
+ const misplaced = (["recipients", "dry-run", "pause-ms", "resend-uncertain"] as const).filter((option) => values[option] !== undefined);
115
151
  if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
116
152
  if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
117
- return { kind: "dns", domain: values.domain, dkimSelectors: values["dkim-selector"], spfHosts: values["spf-host"] };
153
+ const expectDmarc = parseDmarcExpectation(values);
154
+ if (typeof expectDmarc === "string") return expectDmarc;
155
+ return {
156
+ kind: "dns",
157
+ domain: values.domain,
158
+ dkimSelectors: values["dkim-selector"],
159
+ spfHosts: values["spf-host"],
160
+ expectDmarc,
161
+ replyTo: values["reply-to"],
162
+ returnPathHosts: values["return-path"] ?? [],
163
+ resendReturnPath: values["resend-return-path"] === true,
164
+ };
118
165
  }
119
166
 
120
- const misplaced = (["domain", "dkim-selector", "spf-host"] as const).filter((option) => values[option] !== undefined);
167
+ const misplaced = DNS_ONLY_OPTIONS.filter((option) => values[option] !== undefined);
121
168
  if (misplaced.length > 0) return `campaign does not take --${misplaced.join(", --")}`;
122
169
  const [contentFile, ...extra] = positionals;
123
170
  if (contentFile === undefined) return "campaign needs a content file";
@@ -125,7 +172,7 @@ function parseCommand(argv: readonly string[]): Command | string {
125
172
  if (values.recipients === undefined) return "campaign needs --recipients <file>";
126
173
  const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
127
174
  if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
128
- return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs };
175
+ return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
129
176
  }
130
177
 
131
178
  function parseCommandArgs(args: readonly string[]) {
@@ -135,9 +182,17 @@ function parseCommandArgs(args: readonly string[]) {
135
182
  recipients: { type: "string" },
136
183
  "dry-run": { type: "boolean" },
137
184
  "pause-ms": { type: "string" },
185
+ "resend-uncertain": { type: "boolean" },
138
186
  domain: { type: "string" },
139
187
  "dkim-selector": { type: "string", multiple: true },
140
188
  "spf-host": { type: "string", multiple: true },
189
+ "dmarc-policy": { type: "string" },
190
+ "dmarc-sp": { type: "string" },
191
+ "dmarc-adkim": { type: "string" },
192
+ "dmarc-aspf": { type: "string" },
193
+ "reply-to": { type: "string" },
194
+ "return-path": { type: "string", multiple: true },
195
+ "resend-return-path": { type: "boolean" },
141
196
  help: { type: "boolean" },
142
197
  },
143
198
  strict: true,
@@ -145,34 +200,82 @@ function parseCommandArgs(args: readonly string[]) {
145
200
  });
146
201
  }
147
202
 
203
+ type ParsedValues = ReturnType<typeof parseCommandArgs>["values"];
204
+
205
+ /** One choice of a fixed list, case-insensitive; `undefined` when the option was not given. */
206
+ function pickChoice<T extends string>(values: ParsedValues, option: "dmarc-policy" | "dmarc-sp" | "dmarc-adkim" | "dmarc-aspf", allowed: readonly T[]): { readonly value: T | undefined } | string {
207
+ const given = values[option];
208
+ if (given === undefined) return { value: undefined };
209
+ const found = allowed.find((candidate) => candidate === given.trim().toLowerCase());
210
+ return found === undefined ? `--${option} expects ${allowed.join(" or ")}, got "${given}"` : { value: found };
211
+ }
212
+
213
+ function parseDmarcExpectation(values: ParsedValues): DmarcExpectation | undefined | string {
214
+ const policy = pickChoice(values, "dmarc-policy", DMARC_POLICIES);
215
+ if (typeof policy === "string") return policy;
216
+ const subdomainPolicy = pickChoice(values, "dmarc-sp", DMARC_POLICIES);
217
+ if (typeof subdomainPolicy === "string") return subdomainPolicy;
218
+ const adkim = pickChoice(values, "dmarc-adkim", DMARC_ALIGNMENTS);
219
+ if (typeof adkim === "string") return adkim;
220
+ const aspf = pickChoice(values, "dmarc-aspf", DMARC_ALIGNMENTS);
221
+ if (typeof aspf === "string") return aspf;
222
+ const expectation: DmarcExpectation = {
223
+ ...(policy.value === undefined ? {} : { policy: policy.value }),
224
+ ...(subdomainPolicy.value === undefined ? {} : { subdomainPolicy: subdomainPolicy.value }),
225
+ ...(adkim.value === undefined ? {} : { adkim: adkim.value }),
226
+ ...(aspf.value === undefined ? {} : { aspf: aspf.value }),
227
+ };
228
+ return Object.keys(expectation).length === 0 ? undefined : expectation;
229
+ }
230
+
148
231
  async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
149
232
  let domain = command.domain;
233
+ let replyTo = command.replyTo;
150
234
  if (domain === undefined) {
151
235
  if (options.config === undefined) {
152
236
  output.error("softure-mail dns: pass --domain <domain>, or run it with the app's config to check the sender's domain");
153
237
  return EXIT_FAILED;
154
238
  }
155
- domain = getSenderDomain(getMailingOptions(options.config).from);
239
+ const mailing = getMailingOptions(options.config);
240
+ domain = getSenderDomain(mailing.from);
241
+ replyTo ??= mailing.replyTo;
156
242
  }
243
+ const returnPath: ReturnPathHost[] = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...command.returnPathHosts.map((host) => ({ host }))];
157
244
  const report = await checkSenderDns(domain, {
158
245
  ...(command.dkimSelectors === undefined ? {} : { dkimSelectors: command.dkimSelectors }),
159
246
  ...(command.spfHosts === undefined ? {} : { spfHosts: command.spfHosts }),
247
+ ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
248
+ ...(replyTo === undefined ? {} : { replyTo }),
249
+ returnPath,
160
250
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
251
+ ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
252
+ ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
161
253
  });
162
- const checks: [string, DnsCheck][] = [
254
+ const authentication: [string, DnsCheck][] = [
163
255
  ["SPF", report.spf],
164
256
  ["DKIM", report.dkim],
165
257
  ["DMARC", report.dmarc],
166
258
  ];
259
+ const reply: [string, DnsCheck][] = report.replyTo === null ? [] : [["REPLY", report.replyTo]];
260
+ const paths: [string, DnsCheck][] = report.returnPath.map((check) => ["PATH", check]);
167
261
  output.log(`sender domain ${report.domain}`);
168
- for (const [name, check] of checks) {
169
- const line = `${name.padEnd(6)}${check.status.padEnd(6)}${check.finding.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
262
+ for (const [name, check] of [...authentication, ...reply, ...paths]) {
263
+ const line = `${name.padEnd(6)}${check.status.padEnd(6)}${`${check.finding} `.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
170
264
  if (check.status === "fail") output.error(line);
171
265
  else output.log(line);
172
266
  }
173
- const failed = checks.filter(([, check]) => check.status === "fail").length;
174
- output.log(failed === 0 ? "receivers can authenticate mail from this domain" : `${String(failed)} check(s) failed: list mail from this domain will land in spam or be refused`);
175
- return failed === 0 ? EXIT_OK : EXIT_FAILED;
267
+ const countFailed = (checks: readonly [string, DnsCheck][]) => checks.filter(([, check]) => check.status === "fail").length;
268
+ const failedAuthentication = countFailed(authentication);
269
+ const failedReply = countFailed(reply);
270
+ const failedPaths = countFailed(paths);
271
+ if (failedAuthentication + failedReply + failedPaths === 0) {
272
+ output.log("receivers can authenticate mail from this domain");
273
+ return EXIT_OK;
274
+ }
275
+ if (failedAuthentication > 0) output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
276
+ if (failedReply > 0) output.log("the reply-to domain does not accept mail: replies will bounce");
277
+ if (failedPaths > 0) output.log(`${String(failedPaths)} return-path host(s) failed: the provider cannot use them for bounces`);
278
+ return EXIT_FAILED;
176
279
  }
177
280
 
178
281
  async function runCampaign(command: Extract<Command, { kind: "campaign" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
@@ -205,7 +308,9 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
205
308
  }
206
309
  const ctx: DeliveryContext = { db: opened.handle.db, clock: systemClock, config };
207
310
  try {
208
- return command.dryRun ? await reportPlan(ctx, loaded, output) : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep }, output);
311
+ return command.dryRun
312
+ ? await reportPlan(ctx, loaded, command.resendUncertain, output)
313
+ : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
209
314
  } catch (error) {
210
315
  // Driver errors: the message only, never a stack, an address or the database URL.
211
316
  output.error(`softure-mail campaign: ${describeError(error)} (did softure migrate run?)`);
@@ -238,10 +343,19 @@ async function loadCampaign(contentPath: string, recipientsPath: string): Promis
238
343
  return { campaign: { ...content, html }, recipients: parseRecipientList(list) };
239
344
  }
240
345
 
241
- async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, output: CliOutput): Promise<number> {
242
- const plan = await planCampaign(ctx, loaded);
346
+ async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, retakeUncertain: boolean, output: CliOutput): Promise<number> {
347
+ const plan = await planCampaign(ctx, loaded, { retakeUncertain });
243
348
  output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}), dry run: nothing sent or written`);
244
- output.log(`recipients ${String(plan.recipients)}, already done ${String(plan.done)}, unsubscribed ${String(plan.suppressed)}, to send ${String(plan.toSend)}`);
349
+ output.log(
350
+ [
351
+ `recipients ${String(plan.recipients)}`,
352
+ `already done ${String(plan.done)}`,
353
+ `unsubscribed ${String(plan.suppressed)}`,
354
+ `filtered out ${String(plan.filtered)}`,
355
+ `uncertain ${String(plan.uncertain)}`,
356
+ `to send ${String(plan.toSend)}`,
357
+ ].join(", "),
358
+ );
245
359
  if (plan.contentChanged) {
246
360
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
247
361
  return EXIT_FAILED;
@@ -252,13 +366,14 @@ async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, output:
252
366
  async function reportSend(
253
367
  ctx: DeliveryContext,
254
368
  loaded: LoadedCampaign,
255
- pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined },
369
+ pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined; readonly retakeUncertain: boolean },
256
370
  output: CliOutput,
257
371
  ): Promise<number> {
258
372
  let handled = 0;
259
373
  const total = loaded.recipients.length;
260
374
  const result = await sendCampaign(ctx, loaded, {
261
375
  pauseMs: pace.pauseMs,
376
+ retakeUncertain: pace.retakeUncertain,
262
377
  ...(pace.sleep === undefined ? {} : { sleep: pace.sleep }),
263
378
  onDelivery: () => {
264
379
  handled += 1;
@@ -269,15 +384,33 @@ async function reportSend(
269
384
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
270
385
  return EXIT_FAILED;
271
386
  }
272
- output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(result.value)}`);
273
- const open = result.value.inFlight + result.value.retryLater;
274
- if (open > 0) {
387
+ const summary = result.value;
388
+ output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(summary)}`);
389
+ let code = EXIT_OK;
390
+ if (summary.halted !== null) {
391
+ const status = summary.halted.httpStatus === undefined ? "" : ` (HTTP ${String(summary.halted.httpStatus)})`;
392
+ output.error(`stopped: ${HALT_EXPLANATIONS[summary.halted.reason]}${status}; the remaining recipients were not touched, run the same command again once it is fixed`);
393
+ code = EXIT_FAILED;
394
+ }
395
+ const open = summary.inFlight + summary.retryLater;
396
+ if (open > 0 && summary.halted === null) {
275
397
  output.error(`${String(open)} recipient(s) have no outcome yet; run the same command again later`);
276
- return EXIT_FAILED;
398
+ code = EXIT_FAILED;
277
399
  }
278
- return EXIT_OK;
400
+ if (summary.uncertain > 0) {
401
+ output.error(
402
+ `${String(summary.uncertain)} recipient(s) are uncertain: a send was interrupted long ago and may have gone out; check the provider's log, then run again with --resend-uncertain to send them anyway`,
403
+ );
404
+ code = EXIT_FAILED;
405
+ }
406
+ return code;
279
407
  }
280
408
 
409
+ const HALT_EXPLANATIONS: Readonly<Record<HaltingErrorCode, string>> = {
410
+ "mailing.provider_refused": "the provider refused the account (check the API key and the account)",
411
+ "mailing.quota_exceeded": "the account's sending quota is spent",
412
+ };
413
+
281
414
  function formatSummary(summary: CampaignSummary): string {
282
415
  const rejected = Object.entries(summary.rejected).filter(([, count]) => count > 0);
283
416
  const rejectedTotal = rejected.reduce((sum, [, count]) => sum + count, 0);
@@ -289,6 +422,8 @@ function formatSummary(summary: CampaignSummary): string {
289
422
  `already done ${String(summary.done)}`,
290
423
  `in flight ${String(summary.inFlight)}`,
291
424
  `retry later ${String(summary.retryLater)}`,
425
+ `filtered out ${String(summary.filtered)}`,
426
+ `uncertain ${String(summary.uncertain)}`,
292
427
  ].join(", ");
293
428
  }
294
429
 
package/src/contract.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Result types, error codes and the provider contract of the mailing module. No user-facing copy
2
2
  // here: the UI translates codes through `messages` (docs/02-module-standard.md §6).
3
- import type { ModuleContext, Result } from "@softure-ai/core";
3
+ import type { Err, ModuleContext, Ok } from "@softure-ai/core";
4
4
  import type { Queryable } from "@softure-ai/db";
5
5
 
6
6
  export type MailingErrorCode =
@@ -11,7 +11,17 @@ export type MailingErrorCode =
11
11
  /** The provider could not be reached or did not answer in time. Retry later, with the same idempotency key. */
12
12
  | "mailing.unavailable"
13
13
  /** A list mail to a recipient who unsubscribed. Nothing was sent; do not retry. */
14
- | "mailing.suppressed";
14
+ | "mailing.suppressed"
15
+ /**
16
+ * The provider refuses the sender, not the mail: a wrong or revoked API key, a suspended or restricted account
17
+ * (HTTP 401/403). Every other mail would fail the same way: stop sending, fix the account, then retry.
18
+ */
19
+ | "mailing.provider_refused"
20
+ /** The account's sending quota is spent (HTTP 429 that is not a rate limit). Stop sending; retry when it renews. */
21
+ | "mailing.quota_exceeded";
22
+
23
+ /** The codes that are about the sending account, not the mail: a run of sends stops at the first one. */
24
+ export const HALTING_ERROR_CODES: ReadonlySet<MailingErrorCode> = new Set(["mailing.provider_refused", "mailing.quota_exceeded"]);
15
25
 
16
26
  /** Why an unsubscribe link was refused: missing, malformed, or not signed by a current secret. */
17
27
  export type UnsubscribeErrorCode = "mailing.invalid_link";
@@ -36,6 +46,32 @@ export interface UnsubscribeEvent {
36
46
  */
37
47
  export type OnUnsubscribedHook = (event: UnsubscribeEvent, ctx: ModuleContext<Queryable>) => Promise<void>;
38
48
 
49
+ /** What `filterCampaignRecipient` receives for each recipient of a campaign. */
50
+ export interface CampaignRecipient {
51
+ readonly address: string;
52
+ /** The recipient key of the address (`getRecipientKey`). */
53
+ readonly recipientKey: string;
54
+ readonly campaign: { readonly id: string; readonly kind: string };
55
+ }
56
+
57
+ /**
58
+ * Decides whether a campaign goes to a recipient, e.g. by the consent scope the app stored for the address. A
59
+ * recipient it refuses is skipped before the ledger is touched, so a later re-run sends to them once they qualify.
60
+ * A throw stops the campaign, like a database failure. Set in `mailing({ filterCampaignRecipient })`.
61
+ */
62
+ export type CampaignRecipientFilter = (recipient: CampaignRecipient, ctx: ModuleContext<Queryable>) => Promise<boolean>;
63
+
64
+ /**
65
+ * Unsubscribe links an app sent before it adopted the module, in its own scheme. `params` are the query names of
66
+ * such a link; `verify` receives their values (all present once, each at most 512 characters) and returns the
67
+ * recipient's address when the link is genuine, else `null`. It must check the link's signature itself: whatever
68
+ * address it returns is unsubscribed. A throw reads as a failure (the person may try again), not as a bad link.
69
+ */
70
+ export interface LegacyUnsubscribe {
71
+ readonly params: readonly string[];
72
+ readonly verify: (values: Readonly<Record<string, string>>, ctx: ModuleContext<Queryable>) => Promise<string | null>;
73
+ }
74
+
39
75
  /** One mail to one recipient. A mail to a list is a loop over recipients, never a list in `to`. */
40
76
  export interface OutgoingMail {
41
77
  /** Exactly one address: `ada@example.com`. */
@@ -77,7 +113,10 @@ export interface SentMail {
77
113
  readonly provider: string;
78
114
  }
79
115
 
80
- export type SendMailResult = Result<SentMail, MailingErrorCode>;
116
+ /** A failed send: the code, and the provider's HTTP status when it answered with one. */
117
+ export type SendMailFailure = Err<MailingErrorCode> & { readonly httpStatus?: number };
118
+
119
+ export type SendMailResult = Ok<SentMail> | SendMailFailure;
81
120
 
82
121
  /** What a provider receives: a validated mail with the sender and reply-to from the configuration. */
83
122
  export interface ProviderMessage {
@@ -91,11 +130,15 @@ export interface ProviderMessage {
91
130
  readonly idempotencyKey: string | null;
92
131
  }
93
132
 
94
- /** What a provider answers. `httpStatus` is logged; the mail never is. */
133
+ /**
134
+ * What a provider answers. `httpStatus` is logged and returned with the failure; the mail never is. `refused` is the
135
+ * provider refusing the sender (key, account), `quota_exceeded` a spent sending quota; `rejected` is about this mail.
136
+ */
95
137
  export type ProviderOutcome =
96
138
  | { readonly status: "sent"; readonly id: string }
97
- | { readonly status: "rejected"; readonly httpStatus?: number }
98
- | { readonly status: "unavailable"; readonly httpStatus?: number };
139
+ | { readonly status: ProviderFailureStatus; readonly httpStatus?: number };
140
+
141
+ export type ProviderFailureStatus = "rejected" | "unavailable" | "refused" | "quota_exceeded";
99
142
 
100
143
  /**
101
144
  * A mail service adapter. `send` should resolve with an outcome for every failure it knows; a
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export const MODULE_ID = "mailing";
17
17
  export const mailing = defineModule({
18
18
  manifest: {
19
19
  id: MODULE_ID,
20
- version: "0.1.6",
20
+ version: "0.1.8",
21
21
  dependsOn: {},
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
@@ -64,14 +64,19 @@ export {
64
64
  MAX_SUBJECT_LENGTH,
65
65
  RESERVED_HEADERS,
66
66
  } from "./address.js";
67
- export { TRANSACTIONAL_KIND } from "./contract.js";
67
+ export { HALTING_ERROR_CODES, TRANSACTIONAL_KIND } from "./contract.js";
68
68
  export type {
69
+ CampaignRecipient,
70
+ CampaignRecipientFilter,
71
+ LegacyUnsubscribe,
69
72
  MailingErrorCode,
70
73
  MailProvider,
71
74
  OnUnsubscribedHook,
72
75
  OutgoingMail,
76
+ ProviderFailureStatus,
73
77
  ProviderMessage,
74
78
  ProviderOutcome,
79
+ SendMailFailure,
75
80
  SendMailOptions,
76
81
  SendMailResult,
77
82
  SentMail,
@@ -80,6 +85,6 @@ export type {
80
85
  UnsubscribeEvent,
81
86
  } from "./contract.js";
82
87
  export { getMailingErrorMessage, mailingMessages, type MailingMessages } from "./messages/index.js";
83
- export { DEFAULT_TIMEOUT_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
88
+ export { DEFAULT_STALE_CLAIM_MS, DEFAULT_TIMEOUT_MS, DEFAULT_UNCERTAIN_CLAIM_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
84
89
  export { resend, RESEND_API_KEY_ENV, RESEND_ENDPOINT, type ResendOptions } from "./providers/resend.js";
85
90
  export { campaigns, deliveries, mailingSchema, suppressions, type DeliveryStatus } from "./schema.js";
@@ -5,6 +5,8 @@ export const en = {
5
5
  rejected: "The mail service refused this message.",
6
6
  unavailable: "The mail service is not answering. Try again in a moment.",
7
7
  suppressed: "This address unsubscribed from these emails, so nothing was sent.",
8
+ provider_refused: "The mail service refused our account, so nothing was sent. We need to fix it on our side.",
9
+ quota_exceeded: "We reached our sending limit for now. Try again later.",
8
10
  },
9
11
  },
10
12
  footer: {
@@ -8,6 +8,8 @@ export const pl: typeof en = {
8
8
  rejected: "Usługa pocztowa odrzuciła tę wiadomość.",
9
9
  unavailable: "Usługa pocztowa nie odpowiada. Spróbuj ponownie za chwilę.",
10
10
  suppressed: "Ten adres wypisał się z takich wiadomości, więc nic nie wysłano.",
11
+ provider_refused: "Usługa pocztowa odrzuciła nasze konto, więc nic nie wysłano. Musimy to naprawić po naszej stronie.",
12
+ quota_exceeded: "Wyczerpaliśmy na razie limit wysyłki. Spróbuj ponownie później.",
11
13
  },
12
14
  },
13
15
  footer: {