@softure-ai/mailing 0.1.8 → 0.1.10
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/CHANGELOG.md +32 -0
- package/README.md +110 -16
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +202 -30
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +41 -6
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/next/import-deliveries.d.ts +4 -0
- package/dist/next/import-deliveries.d.ts.map +1 -0
- package/dist/next/import-deliveries.js +9 -0
- package/dist/next/import-deliveries.js.map +1 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/route.d.ts +3 -2
- package/dist/next/route.d.ts.map +1 -1
- package/dist/next/route.js +5 -4
- package/dist/next/route.js.map +1 -1
- package/dist/options.d.ts +19 -2
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +30 -6
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -1
- package/dist/server/campaigns.d.ts +5 -0
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +10 -0
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +9 -5
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +9 -7
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/import-deliveries.d.ts +68 -0
- package/dist/server/import-deliveries.d.ts.map +1 -0
- package/dist/server/import-deliveries.js +105 -0
- package/dist/server/import-deliveries.js.map +1 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/send-mail.js +6 -6
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +9 -6
- package/dist/server/suppressions.js.map +1 -1
- package/dist/server/unsubscribe-link.d.ts +3 -2
- package/dist/server/unsubscribe-link.d.ts.map +1 -1
- package/dist/server/unsubscribe-link.js +12 -3
- package/dist/server/unsubscribe-link.js.map +1 -1
- package/dist/server/validate-mail.d.ts +3 -1
- package/dist/server/validate-mail.d.ts.map +1 -1
- package/dist/server/validate-mail.js +4 -3
- package/dist/server/validate-mail.js.map +1 -1
- package/migrations/0004_allow_imported_deliveries.sql +20 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +208 -28
- package/src/contract.ts +36 -6
- package/src/index.ts +4 -1
- package/src/next/import-deliveries.ts +9 -0
- package/src/next/index.ts +1 -0
- package/src/next/route.ts +5 -4
- package/src/options.ts +36 -7
- package/src/schema.ts +2 -0
- package/src/server/campaigns.ts +13 -0
- package/src/server/deliveries.ts +15 -8
- package/src/server/import-deliveries.ts +149 -0
- package/src/server/index.ts +12 -0
- package/src/server/send-mail.ts +6 -6
- package/src/server/suppressions.ts +15 -8
- package/src/server/unsubscribe-link.ts +10 -3
- package/src/server/validate-mail.ts +6 -3
|
@@ -74,8 +74,9 @@ export const MAX_LEGACY_VALUE_LENGTH = 512;
|
|
|
74
74
|
/**
|
|
75
75
|
* The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
|
|
76
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
|
|
78
|
-
* a legacy link
|
|
77
|
+
* `mailing({ legacyUnsubscribe })`, a link that has every required legacy parameter (non-empty, at most 512
|
|
78
|
+
* characters) is a legacy link; its values also hold the optional parameters it has (an empty one is absent, one
|
|
79
|
+
* over 512 characters makes the link invalid). Anything else is `null`. Checks no signature: `unsubscribe` does.
|
|
79
80
|
*/
|
|
80
81
|
export function readUnsubscribeLink(params, config) {
|
|
81
82
|
if (params.get(RECIPIENT_PARAM) !== null && params.get(RECIPIENT_PARAM) !== undefined) {
|
|
@@ -86,12 +87,20 @@ export function readUnsubscribeLink(params, config) {
|
|
|
86
87
|
if (legacy === undefined)
|
|
87
88
|
return null;
|
|
88
89
|
const values = {};
|
|
89
|
-
for (const name of legacy.params) {
|
|
90
|
+
for (const name of legacy.params.required) {
|
|
90
91
|
const value = params.get(name);
|
|
91
92
|
if (typeof value !== "string" || value === "" || value.length > MAX_LEGACY_VALUE_LENGTH)
|
|
92
93
|
return null;
|
|
93
94
|
values[name] = value;
|
|
94
95
|
}
|
|
96
|
+
for (const name of legacy.params.optional) {
|
|
97
|
+
const value = params.get(name);
|
|
98
|
+
if (typeof value !== "string" || value === "")
|
|
99
|
+
continue;
|
|
100
|
+
if (value.length > MAX_LEGACY_VALUE_LENGTH)
|
|
101
|
+
return null;
|
|
102
|
+
values[name] = value;
|
|
103
|
+
}
|
|
95
104
|
return { scheme: "legacy", values };
|
|
96
105
|
}
|
|
97
106
|
/** The query (or form) parameters that carry `link`, to send it back to the page. */
|
|
@@ -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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"}
|
|
@@ -4,6 +4,8 @@ export interface ValidMail {
|
|
|
4
4
|
readonly text: string;
|
|
5
5
|
readonly html: string | null;
|
|
6
6
|
readonly headers: Readonly<Record<string, string>>;
|
|
7
|
+
/** The mail's own reply-to; null to use the configured one. */
|
|
8
|
+
readonly replyTo: string | null;
|
|
7
9
|
/** `transactional` or a list kind. */
|
|
8
10
|
readonly kind: string;
|
|
9
11
|
readonly idempotencyKey: string | null;
|
|
@@ -17,7 +19,7 @@ export type MailValidation = {
|
|
|
17
19
|
};
|
|
18
20
|
/**
|
|
19
21
|
* Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
|
|
20
|
-
* `html`, `headers`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
|
|
22
|
+
* `html`, `headers`, `replyTo`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
|
|
21
23
|
*/
|
|
22
24
|
export declare function validateMail(mail: unknown, options?: unknown): MailValidation;
|
|
23
25
|
//# sourceMappingURL=validate-mail.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-mail.d.ts","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"validate-mail.d.ts","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"AAsCA,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAED,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC;AAE3I;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,OAAY,GAAG,cAAc,CAajF"}
|
|
@@ -21,6 +21,7 @@ const mailSchema = z.object({
|
|
|
21
21
|
.refine((html) => html.trim() !== "")
|
|
22
22
|
.optional(),
|
|
23
23
|
headers: headersSchema.optional(),
|
|
24
|
+
replyTo: z.string().trim().refine(isSingleAddress).optional(),
|
|
24
25
|
kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
|
|
25
26
|
}).superRefine((mail, context) => {
|
|
26
27
|
// The module writes these two on list mail; a second, unsigned copy would confuse clients.
|
|
@@ -33,7 +34,7 @@ const optionsSchema = z.object({
|
|
|
33
34
|
});
|
|
34
35
|
/**
|
|
35
36
|
* Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
|
|
36
|
-
* `html`, `headers`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
|
|
37
|
+
* `html`, `headers`, `replyTo`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
|
|
37
38
|
*/
|
|
38
39
|
export function validateMail(mail, options = {}) {
|
|
39
40
|
const parsedMail = mailSchema.safeParse(mail);
|
|
@@ -43,10 +44,10 @@ export function validateMail(mail, options = {}) {
|
|
|
43
44
|
const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
|
|
44
45
|
return { ok: false, fields: [...fields] };
|
|
45
46
|
}
|
|
46
|
-
const { to, subject, text, html, headers, kind } = parsedMail.data;
|
|
47
|
+
const { to, subject, text, html, headers, replyTo, kind } = parsedMail.data;
|
|
47
48
|
return {
|
|
48
49
|
ok: true,
|
|
49
|
-
value: { to, subject, text, html: html ?? null, headers: headers ?? {}, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
|
|
50
|
+
value: { to, subject, text, html: html ?? null, headers: headers ?? {}, replyTo: replyTo ?? null, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
//# sourceMappingURL=validate-mail.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate-mail.js","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,uFAAuF;AACvF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAClK,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAEpD,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE;IACtF,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACpD,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;YACzE,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,4BAA4B,EAAE,CAAC,CAAC;QAC9E,CAAC;IACH,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1B,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,eAAe,CAAC;IAC7C,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,OAAO,CAAC,MAAM,IAAI,kBAAkB,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IAC/G,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;IACrD,IAAI,EAAE,CAAC;SACJ,MAAM,EAAE;SACR,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;SACpC,QAAQ,EAAE;IACb,OAAO,EAAE,aAAa,CAAC,QAAQ,EAAE;IACjC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC;CAChE,CAAC,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE;IAC/B,2FAA2F;IAC3F,IAAI,IAAI,CAAC,IAAI,KAAK,kBAAkB,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC;QAC/F,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,wDAAwD,EAAE,CAAC,CAAC;IAC7H,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7B,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,QAAQ,EAAE;CAC/D,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"validate-mail.js","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,uFAAuF;AACvF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAClK,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAEpD,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,WAAW,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE;IACtF,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACpD,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,gBAAgB,CAAC,IAAI,CAAC,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;YACzE,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,4BAA4B,EAAE,CAAC,CAAC;QAC9E,CAAC;IACH,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1B,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,eAAe,CAAC;IAC7C,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,OAAO,CAAC,MAAM,IAAI,kBAAkB,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IAC/G,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;IACrD,IAAI,EAAE,CAAC;SACJ,MAAM,EAAE;SACR,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC;SACpC,QAAQ,EAAE;IACb,OAAO,EAAE,aAAa,CAAC,QAAQ,EAAE;IACjC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,QAAQ,EAAE;IAC7D,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC,kBAAkB,CAAC;CAChE,CAAC,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE;IAC/B,2FAA2F;IAC3F,IAAI,IAAI,CAAC,IAAI,KAAK,kBAAkB,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC;QAC/F,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,wDAAwD,EAAE,CAAC,CAAC;IAC7H,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7B,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,QAAQ,EAAE;CAC/D,CAAC,CAAC;AAiBH;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,IAAa,EAAE,UAAmB,EAAE;IAC/D,MAAM,UAAU,GAAG,UAAU,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;IAC9C,MAAM,aAAa,GAAG,aAAa,CAAC,SAAS,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC;IAC7D,IAAI,CAAC,UAAU,CAAC,OAAO,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,CAAC;QAClD,MAAM,MAAM,GAAG,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,EAAE,MAAM,IAAI,EAAE,CAAC,EAAE,GAAG,CAAC,aAAa,CAAC,KAAK,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC;QAC7F,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1G,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC;IAC5C,CAAC;IACD,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,UAAU,CAAC,IAAI,CAAC;IAC5E,OAAO;QACL,EAAE,EAAE,IAAI;QACR,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,IAAI,IAAI,EAAE,OAAO,EAAE,OAAO,IAAI,EAAE,EAAE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,IAAI,EAAE,cAAc,EAAE,aAAa,CAAC,IAAI,CAAC,cAAc,IAAI,IAAI,EAAE;KACpK,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Lets an app seed the ledger with the deliveries it made before it adopted the module (importDeliveries), so the
|
|
2
|
+
-- first deliverOnce or campaign run skips them. Its history rarely kept the provider's message id, so a `sent` row
|
|
3
|
+
-- needs one only when the module sent it; an imported row is marked by imported_at. A row the module did not close
|
|
4
|
+
-- still never has a message id.
|
|
5
|
+
-- Rollback: DELETE FROM mailing.deliveries WHERE imported_at IS NOT NULL AND provider_message_id IS NULL;
|
|
6
|
+
-- ALTER TABLE mailing.deliveries DROP CONSTRAINT deliveries_message_id_check;
|
|
7
|
+
-- ALTER TABLE mailing.deliveries DROP CONSTRAINT deliveries_sent_message_id_check;
|
|
8
|
+
-- ALTER TABLE mailing.deliveries DROP COLUMN imported_at;
|
|
9
|
+
-- ALTER TABLE mailing.deliveries ADD CONSTRAINT deliveries_check1 CHECK ((status = 'sent') = (provider_message_id IS NOT NULL));
|
|
10
|
+
-- DELETE FROM softure.migrations WHERE module = 'mailing' AND version = 4;
|
|
11
|
+
|
|
12
|
+
-- When the row was imported; null for a delivery the module made. Only a closed row can be imported.
|
|
13
|
+
ALTER TABLE deliveries ADD COLUMN imported_at timestamptz
|
|
14
|
+
CONSTRAINT deliveries_imported_at_check CHECK (imported_at IS NULL OR status IN ('sent', 'rejected'));
|
|
15
|
+
|
|
16
|
+
-- Was: CHECK ((status = 'sent') = (provider_message_id IS NOT NULL)), unnamed in 0002.
|
|
17
|
+
ALTER TABLE deliveries DROP CONSTRAINT deliveries_check1;
|
|
18
|
+
ALTER TABLE deliveries ADD CONSTRAINT deliveries_message_id_check CHECK (provider_message_id IS NULL OR status = 'sent');
|
|
19
|
+
ALTER TABLE deliveries ADD CONSTRAINT deliveries_sent_message_id_check
|
|
20
|
+
CHECK (status <> 'sent' OR provider_message_id IS NOT NULL OR imported_at IS NOT NULL);
|
package/module.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/mailing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `softure-mail`: sends a campaign from a content file
|
|
2
|
-
//
|
|
3
|
-
// bundler packs for a container:
|
|
1
|
+
// `softure-mail`: sends a campaign from a content file, imports an app's delivery history into the
|
|
2
|
+
// ledger and checks the sender domain's DNS. The app passes its validated config, so the same
|
|
3
|
+
// function serves the bin and an app script that a bundler packs for a container:
|
|
4
4
|
//
|
|
5
5
|
// // scripts/mail.ts
|
|
6
6
|
// import { runMailCli } from "@softure-ai/mailing/cli";
|
|
@@ -10,11 +10,13 @@ import { readFile } from "node:fs/promises";
|
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
import { systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
|
|
13
|
+
import { z } from "zod";
|
|
13
14
|
import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
|
|
14
15
|
import { parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
|
|
15
|
-
import { planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
|
|
16
|
+
import { listConfiguredCampaignRecipients, planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
|
|
16
17
|
import type { DeliveryContext, HaltingErrorCode } from "../server/deliveries.js";
|
|
17
18
|
import { checkSenderDns, getSenderDomain, resendReturnPath, type DmarcAlignment, type DmarcExpectation, type DmarcPolicy, type DnsCheck, type ResolveCname, type ResolveMx, type ResolveTxt, type ReturnPathHost } from "../server/dns.js";
|
|
19
|
+
import { checkImportedDeliveries, importDeliveries, type ImportedDelivery, type ImportProblem } from "../server/import-deliveries.js";
|
|
18
20
|
import { getMailingOptions } from "../server/options.js";
|
|
19
21
|
import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
|
|
20
22
|
|
|
@@ -24,7 +26,7 @@ export interface CliOutput {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export interface RunMailCliOptions {
|
|
27
|
-
/** The app's config. Needed by `campaign`, and by `dns` without `--domain`. */
|
|
29
|
+
/** The app's config. Needed by `campaign` and `import`, and by `dns` without `--domain`. */
|
|
28
30
|
readonly config?: SoftureConfig;
|
|
29
31
|
/** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
|
|
30
32
|
readonly argv: readonly string[];
|
|
@@ -38,6 +40,8 @@ export interface RunMailCliOptions {
|
|
|
38
40
|
readonly resolveCname?: ResolveCname;
|
|
39
41
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
40
42
|
readonly env?: Env;
|
|
43
|
+
/** Reads all of standard input, for a file given as `-`. Default: `process.stdin`. */
|
|
44
|
+
readonly readStdin?: () => Promise<string>;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export const EXIT_OK = 0;
|
|
@@ -48,20 +52,30 @@ export const EXIT_USAGE = 2;
|
|
|
48
52
|
export const DEFAULT_PAUSE_MS = 500;
|
|
49
53
|
|
|
50
54
|
export const MAIL_USAGE = `Usage:
|
|
51
|
-
softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
|
|
55
|
+
softure-mail campaign <content-file> [--recipients <file>] [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
|
|
56
|
+
softure-mail import <history-file> [--dry-run]
|
|
52
57
|
softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
|
|
53
58
|
[--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
|
|
54
59
|
[--reply-to <address>] [--return-path <host>]... [--resend-return-path]
|
|
55
60
|
|
|
56
61
|
campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
|
|
57
62
|
then the text body) to every address in the recipients file (one per line), at most
|
|
58
|
-
once each.
|
|
59
|
-
|
|
63
|
+
once each. Without --recipients, the recipients come from listCampaignRecipients in
|
|
64
|
+
the mailing options. Re-running sends only to recipients without an outcome yet. A
|
|
65
|
+
refused API key or a spent quota stops the run; the rest go out on the next one.
|
|
66
|
+
--recipients <file> the recipients, one address per line
|
|
60
67
|
--dry-run count what would be sent and change nothing
|
|
61
68
|
--pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
|
|
62
69
|
--resend-uncertain also send to recipients whose send was interrupted long ago
|
|
63
70
|
(it may have gone out: they may get the mail twice)
|
|
64
71
|
|
|
72
|
+
import Writes deliveries the app made before it adopted the module into the ledger, so
|
|
73
|
+
later sends skip them. <history-file> holds one JSON object per line: scope,
|
|
74
|
+
address, status (sent or rejected), finishedAt, and optional providerMessageId,
|
|
75
|
+
reason, kind. Rows already in the ledger are left as they are; any invalid row
|
|
76
|
+
stops the import before anything is written.
|
|
77
|
+
--dry-run check the file and change nothing
|
|
78
|
+
|
|
65
79
|
dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
|
|
66
80
|
--domain <domain> check this domain instead
|
|
67
81
|
--dkim-selector <name> DKIM selector to look up (default resend; repeatable)
|
|
@@ -77,9 +91,12 @@ dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain
|
|
|
77
91
|
--return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
|
|
78
92
|
--resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
|
|
79
93
|
|
|
80
|
-
Options for
|
|
94
|
+
Options for all:
|
|
81
95
|
--config <file> the app's softure.config file (bin only)
|
|
82
|
-
--help show this help
|
|
96
|
+
--help show this help
|
|
97
|
+
|
|
98
|
+
A file given as - is read from standard input (one of them per command), e.g.
|
|
99
|
+
docker compose exec -T app npx softure-mail campaign - < launch.md`;
|
|
83
100
|
|
|
84
101
|
const consoleOutput: CliOutput = {
|
|
85
102
|
log: (line) => console.log(line),
|
|
@@ -91,11 +108,13 @@ type Command =
|
|
|
91
108
|
| {
|
|
92
109
|
readonly kind: "campaign";
|
|
93
110
|
readonly contentFile: string;
|
|
94
|
-
|
|
111
|
+
/** `null`: the module's `listCampaignRecipients`. */
|
|
112
|
+
readonly recipientsFile: string | null;
|
|
95
113
|
readonly dryRun: boolean;
|
|
96
114
|
readonly pauseMs: number;
|
|
97
115
|
readonly resendUncertain: boolean;
|
|
98
116
|
}
|
|
117
|
+
| { readonly kind: "import"; readonly historyFile: string; readonly dryRun: boolean }
|
|
99
118
|
| {
|
|
100
119
|
readonly kind: "dns";
|
|
101
120
|
readonly domain: string | undefined;
|
|
@@ -109,6 +128,9 @@ type Command =
|
|
|
109
128
|
|
|
110
129
|
const DMARC_POLICIES: readonly DmarcPolicy[] = ["quarantine", "reject"];
|
|
111
130
|
const DMARC_ALIGNMENTS: readonly DmarcAlignment[] = ["r", "s"];
|
|
131
|
+
/** A file name that means standard input. */
|
|
132
|
+
const STDIN = "-";
|
|
133
|
+
const CAMPAIGN_ONLY_OPTIONS = ["recipients", "pause-ms", "resend-uncertain"] as const;
|
|
112
134
|
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;
|
|
113
135
|
|
|
114
136
|
/** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
|
|
@@ -128,14 +150,16 @@ export async function runMailCli(options: RunMailCliOptions): Promise<number> {
|
|
|
128
150
|
return runDns(command, options, output);
|
|
129
151
|
case "campaign":
|
|
130
152
|
return runCampaign(command, options, output);
|
|
153
|
+
case "import":
|
|
154
|
+
return runImport(command, options, output);
|
|
131
155
|
}
|
|
132
156
|
}
|
|
133
157
|
|
|
134
158
|
function parseCommand(argv: readonly string[]): Command | string {
|
|
135
159
|
const [name, ...rest] = argv;
|
|
136
|
-
if (name === undefined) return "missing command; use campaign or dns";
|
|
160
|
+
if (name === undefined) return "missing command; use campaign, import or dns";
|
|
137
161
|
if (name === "--help") return { kind: "help" };
|
|
138
|
-
if (name !== "campaign" && name !== "dns") return `unknown command "${name}"; use campaign or dns`;
|
|
162
|
+
if (name !== "campaign" && name !== "import" && name !== "dns") return `unknown command "${name}"; use campaign, import or dns`;
|
|
139
163
|
|
|
140
164
|
let parsed: ReturnType<typeof parseCommandArgs>;
|
|
141
165
|
try {
|
|
@@ -147,7 +171,7 @@ function parseCommand(argv: readonly string[]): Command | string {
|
|
|
147
171
|
if (values.help === true) return { kind: "help" };
|
|
148
172
|
|
|
149
173
|
if (name === "dns") {
|
|
150
|
-
const misplaced = ([
|
|
174
|
+
const misplaced = ([...CAMPAIGN_ONLY_OPTIONS, "dry-run"] as const).filter((option) => values[option] !== undefined);
|
|
151
175
|
if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
|
|
152
176
|
if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
|
|
153
177
|
const expectDmarc = parseDmarcExpectation(values);
|
|
@@ -164,15 +188,24 @@ function parseCommand(argv: readonly string[]): Command | string {
|
|
|
164
188
|
};
|
|
165
189
|
}
|
|
166
190
|
|
|
191
|
+
if (name === "import") {
|
|
192
|
+
const misplaced = [...CAMPAIGN_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS].filter((option) => values[option] !== undefined);
|
|
193
|
+
if (misplaced.length > 0) return `import does not take --${misplaced.join(", --")}`;
|
|
194
|
+
const [historyFile, ...extra] = positionals;
|
|
195
|
+
if (historyFile === undefined) return "import needs a history file (- for standard input)";
|
|
196
|
+
if (extra.length > 0) return `import takes one history file, got also "${extra.join(" ")}"`;
|
|
197
|
+
return { kind: "import", historyFile, dryRun: values["dry-run"] === true };
|
|
198
|
+
}
|
|
199
|
+
|
|
167
200
|
const misplaced = DNS_ONLY_OPTIONS.filter((option) => values[option] !== undefined);
|
|
168
201
|
if (misplaced.length > 0) return `campaign does not take --${misplaced.join(", --")}`;
|
|
169
202
|
const [contentFile, ...extra] = positionals;
|
|
170
203
|
if (contentFile === undefined) return "campaign needs a content file";
|
|
171
204
|
if (extra.length > 0) return `campaign takes one content file, got also "${extra.join(" ")}"`;
|
|
172
|
-
if (values.recipients ===
|
|
205
|
+
if (contentFile === STDIN && values.recipients === STDIN) return "campaign can read only one of the content file and --recipients from standard input";
|
|
173
206
|
const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
|
|
174
207
|
if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
|
|
175
|
-
return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
|
|
208
|
+
return { kind: "campaign", contentFile, recipientsFile: values.recipients ?? null, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
|
|
176
209
|
}
|
|
177
210
|
|
|
178
211
|
function parseCommandArgs(args: readonly string[]) {
|
|
@@ -285,9 +318,19 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
|
|
|
285
318
|
output.error("softure-mail campaign: needs the app's config");
|
|
286
319
|
return EXIT_FAILED;
|
|
287
320
|
}
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
321
|
+
const input = createInputReader(cwd, options.readStdin ?? readProcessStdin);
|
|
322
|
+
const content = await loadCampaignContent(command.contentFile, input);
|
|
323
|
+
if (typeof content === "string") {
|
|
324
|
+
output.error(`softure-mail campaign: ${content}`);
|
|
325
|
+
return EXIT_FAILED;
|
|
326
|
+
}
|
|
327
|
+
const listed = command.recipientsFile === null ? null : await loadRecipientFile(command.recipientsFile, input);
|
|
328
|
+
if (typeof listed === "string") {
|
|
329
|
+
output.error(`softure-mail campaign: ${listed}`);
|
|
330
|
+
return EXIT_FAILED;
|
|
331
|
+
}
|
|
332
|
+
if (listed === null && getMailingOptions(config).listCampaignRecipients === undefined) {
|
|
333
|
+
output.error("softure-mail campaign: pass --recipients <file>, or set listCampaignRecipients in the mailing options");
|
|
291
334
|
return EXIT_FAILED;
|
|
292
335
|
}
|
|
293
336
|
if (!command.dryRun && readUnsubscribeSecrets(options.env ?? process.env).current === null) {
|
|
@@ -308,6 +351,7 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
|
|
|
308
351
|
}
|
|
309
352
|
const ctx: DeliveryContext = { db: opened.handle.db, clock: systemClock, config };
|
|
310
353
|
try {
|
|
354
|
+
const loaded: LoadedCampaign = { campaign: content, recipients: listed ?? (await collectRecipients(ctx, content)) };
|
|
311
355
|
return command.dryRun
|
|
312
356
|
? await reportPlan(ctx, loaded, command.resendUncertain, output)
|
|
313
357
|
: await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
|
|
@@ -325,22 +369,152 @@ interface LoadedCampaign {
|
|
|
325
369
|
readonly recipients: readonly string[];
|
|
326
370
|
}
|
|
327
371
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
372
|
+
/** Reads a named file, or standard input for `-`; `name` is what messages call it. */
|
|
373
|
+
interface InputReader {
|
|
374
|
+
readonly read: (file: string) => Promise<{ readonly text: string | null; readonly name: string; readonly dir: string }>;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function createInputReader(cwd: string, readStdin: () => Promise<string>): InputReader {
|
|
378
|
+
return {
|
|
379
|
+
read: async (file) => {
|
|
380
|
+
if (file === STDIN) return { text: await readStdin(), name: "standard input", dir: cwd };
|
|
381
|
+
const path = resolve(cwd, file);
|
|
382
|
+
return { text: await readText(path), name: path, dir: dirname(path) };
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function loadCampaignContent(file: string, input: InputReader): Promise<CampaignContent | string> {
|
|
388
|
+
const source = await input.read(file);
|
|
389
|
+
if (source.text === null) return `cannot read the content file ${source.name}`;
|
|
390
|
+
const parsed = parseCampaignFile(source.text);
|
|
391
|
+
if (!parsed.ok) return `${source.name} is not a campaign:\n ${parsed.problems.join("\n ")}`;
|
|
333
392
|
const { htmlPath, ...content } = parsed.value;
|
|
334
393
|
let html: string | null = null;
|
|
335
394
|
if (htmlPath !== null) {
|
|
336
|
-
|
|
395
|
+
// Next to the content file; for standard input, the working directory.
|
|
396
|
+
const htmlFile = resolve(source.dir, htmlPath);
|
|
337
397
|
html = await readText(htmlFile);
|
|
338
398
|
if (html === null) return `cannot read the HTML body ${htmlFile}`;
|
|
339
399
|
if (html.trim() === "") return `the HTML body ${htmlFile} is empty`;
|
|
340
400
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
401
|
+
return { ...content, html };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function loadRecipientFile(file: string, input: InputReader): Promise<readonly string[] | string> {
|
|
405
|
+
const list = await input.read(file);
|
|
406
|
+
if (list.text === null) return `cannot read the recipients file ${list.name}`;
|
|
407
|
+
return parseRecipientList(list.text);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The recipients from `listCampaignRecipients`, which the caller checked is set. */
|
|
411
|
+
async function collectRecipients(ctx: DeliveryContext, campaign: CampaignContent): Promise<readonly string[]> {
|
|
412
|
+
const recipients: string[] = [];
|
|
413
|
+
const source = await listConfiguredCampaignRecipients(ctx, campaign);
|
|
414
|
+
if (source === null) return recipients;
|
|
415
|
+
for await (const address of source) recipients.push(address);
|
|
416
|
+
return recipients;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** One line of a history file. Unknown keys are refused, so a typo does not drop a field. */
|
|
420
|
+
const historyRowSchema = z.strictObject({
|
|
421
|
+
scope: z.string(),
|
|
422
|
+
address: z.string(),
|
|
423
|
+
status: z.enum(["sent", "rejected"]),
|
|
424
|
+
finishedAt: z.string(),
|
|
425
|
+
providerMessageId: z.string().optional(),
|
|
426
|
+
reason: z.enum(["mailing.invalid_input", "mailing.rejected", "mailing.unavailable", "mailing.suppressed"]).optional(),
|
|
427
|
+
kind: z.string().optional(),
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
async function runImport(command: Extract<Command, { kind: "import" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
|
|
431
|
+
const config = options.config;
|
|
432
|
+
if (config === undefined) {
|
|
433
|
+
output.error("softure-mail import: needs the app's config");
|
|
434
|
+
return EXIT_FAILED;
|
|
435
|
+
}
|
|
436
|
+
const source = await createInputReader(options.cwd ?? process.cwd(), options.readStdin ?? readProcessStdin).read(command.historyFile);
|
|
437
|
+
if (source.text === null) {
|
|
438
|
+
output.error(`softure-mail import: cannot read the history file ${source.name}`);
|
|
439
|
+
return EXIT_FAILED;
|
|
440
|
+
}
|
|
441
|
+
const parsed = parseHistory(source.text);
|
|
442
|
+
if (parsed.problems.length > 0) return reportImportProblems(parsed.problems, output);
|
|
443
|
+
const rows = parsed.rows.map((row) => row.delivery);
|
|
444
|
+
const lineOf = (problem: ImportProblem) => parsed.rows[problem.index]?.line ?? 0;
|
|
445
|
+
|
|
446
|
+
// Checked before the database is opened, so a bad file costs no connection.
|
|
447
|
+
const checked = checkImportedDeliveries({ clock: systemClock }, rows);
|
|
448
|
+
if (!checked.ok) return reportImportProblems(checked.problems.map((problem) => ({ line: lineOf(problem), problem: problem.problem })), output);
|
|
449
|
+
if (command.dryRun) {
|
|
450
|
+
output.log(`import, dry run: nothing written; rows ${String(rows.length)}, duplicates ${String(checked.value.duplicates)}`);
|
|
451
|
+
return EXIT_OK;
|
|
452
|
+
}
|
|
453
|
+
if (config.database === null) {
|
|
454
|
+
output.error("softure-mail import: the config has no database; set database.url in softure.config");
|
|
455
|
+
return EXIT_FAILED;
|
|
456
|
+
}
|
|
457
|
+
let opened: CommandDatabase;
|
|
458
|
+
try {
|
|
459
|
+
opened = await openDatabase(config.database, options.openDatabase);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
output.error(`softure-mail import: ${describeError(error)}`);
|
|
462
|
+
return EXIT_FAILED;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const result = await importDeliveries({ db: opened.handle.db, clock: systemClock, config }, rows);
|
|
466
|
+
if (!result.ok) return reportImportProblems(result.problems.map((problem) => ({ line: lineOf(problem), problem: problem.problem })), output);
|
|
467
|
+
const summary = result.value;
|
|
468
|
+
output.log(`import: rows ${String(summary.rows)}, imported ${String(summary.imported)}, already present ${String(summary.alreadyPresent)}, duplicates ${String(summary.duplicates)}`);
|
|
469
|
+
return EXIT_OK;
|
|
470
|
+
} catch (error) {
|
|
471
|
+
output.error(`softure-mail import: ${describeError(error)} (did softure migrate run?)`);
|
|
472
|
+
return EXIT_FAILED;
|
|
473
|
+
} finally {
|
|
474
|
+
await opened.close();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
interface LineProblem {
|
|
479
|
+
readonly line: number;
|
|
480
|
+
readonly problem: string;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The history file's rows with their line numbers; problems name lines and fields, never values. */
|
|
484
|
+
function parseHistory(text: string): { readonly rows: readonly { readonly line: number; readonly delivery: ImportedDelivery }[]; readonly problems: readonly LineProblem[] } {
|
|
485
|
+
const rows: { line: number; delivery: ImportedDelivery }[] = [];
|
|
486
|
+
const problems: LineProblem[] = [];
|
|
487
|
+
text.split(/\r?\n/).forEach((source, index) => {
|
|
488
|
+
const line = index + 1;
|
|
489
|
+
if (source.trim() === "") return;
|
|
490
|
+
let value: unknown;
|
|
491
|
+
try {
|
|
492
|
+
value = JSON.parse(source);
|
|
493
|
+
} catch {
|
|
494
|
+
problems.push({ line, problem: "not a JSON object" });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const parsed = historyRowSchema.safeParse(value);
|
|
498
|
+
if (!parsed.success) {
|
|
499
|
+
for (const issue of parsed.error.issues) {
|
|
500
|
+
const field = issue.path.length === 0 ? "row" : issue.path.join(".");
|
|
501
|
+
problems.push({ line, problem: issue.code === "unrecognized_keys" ? `unknown field(s) ${issue.keys.join(", ")}` : `${field}: ${issue.message}` });
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const { providerMessageId, reason, kind, ...required } = parsed.data;
|
|
506
|
+
rows.push({
|
|
507
|
+
line,
|
|
508
|
+
delivery: { ...required, ...(providerMessageId === undefined ? {} : { providerMessageId }), ...(reason === undefined ? {} : { reason }), ...(kind === undefined ? {} : { kind }) },
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
return { rows, problems };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function reportImportProblems(problems: readonly LineProblem[], output: CliOutput): number {
|
|
515
|
+
output.error(`softure-mail import: nothing written, ${String(problems.length)} problem(s):`);
|
|
516
|
+
for (const { line, problem } of problems) output.error(` line ${String(line)}: ${problem}`);
|
|
517
|
+
return EXIT_FAILED;
|
|
344
518
|
}
|
|
345
519
|
|
|
346
520
|
async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, retakeUncertain: boolean, output: CliOutput): Promise<number> {
|
|
@@ -433,6 +607,12 @@ async function openDatabase(database: SoftureDatabaseConfig, open: ((url: string
|
|
|
433
607
|
return { handle, close: handle.close };
|
|
434
608
|
}
|
|
435
609
|
|
|
610
|
+
async function readProcessStdin(): Promise<string> {
|
|
611
|
+
const chunks: Buffer[] = [];
|
|
612
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer));
|
|
613
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
614
|
+
}
|
|
615
|
+
|
|
436
616
|
async function readText(path: string): Promise<string | null> {
|
|
437
617
|
try {
|
|
438
618
|
return await readFile(path, "utf8");
|
package/src/contract.ts
CHANGED
|
@@ -32,11 +32,18 @@ export const TRANSACTIONAL_KIND = "transactional";
|
|
|
32
32
|
/** How an opt-out arrived: a mail client's one-click POST, the page's button, or an operator. */
|
|
33
33
|
export type SuppressionSource = "one-click" | "page" | "operator";
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The link an unsubscribe came through, once verified: the module's signed link, or a legacy link with the values
|
|
37
|
+
* the app's `verify` accepted (only the names the link carried), so a hook can find the row that link named.
|
|
38
|
+
*/
|
|
39
|
+
export type VerifiedUnsubscribeLink = { readonly scheme: "signed" } | { readonly scheme: "legacy"; readonly values: Readonly<Record<string, string>> };
|
|
40
|
+
|
|
41
|
+
/** What `onUnsubscribed` receives: who opted out (never the address), how, and through which link. */
|
|
36
42
|
export interface UnsubscribeEvent {
|
|
37
43
|
/** The recipient key of the link: privacy's email key of the same address (`getEmailKey`). */
|
|
38
44
|
readonly recipientKey: string;
|
|
39
45
|
readonly source: Exclude<SuppressionSource, "operator">;
|
|
46
|
+
readonly link: VerifiedUnsubscribeLink;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
|
@@ -61,14 +68,32 @@ export interface CampaignRecipient {
|
|
|
61
68
|
*/
|
|
62
69
|
export type CampaignRecipientFilter = (recipient: CampaignRecipient, ctx: ModuleContext<Queryable>) => Promise<boolean>;
|
|
63
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Lists the addresses a campaign goes to, from the app's own data (e.g. the people who opted in to its kind), so a
|
|
73
|
+
* campaign can run where the database is without a recipients file. Duplicates are fine; `filterCampaignRecipient`
|
|
74
|
+
* and the suppression list still apply. A throw stops the campaign. Set in `mailing({ listCampaignRecipients })`.
|
|
75
|
+
*/
|
|
76
|
+
export type CampaignRecipientSource = (
|
|
77
|
+
campaign: { readonly id: string; readonly kind: string },
|
|
78
|
+
ctx: ModuleContext<Queryable>,
|
|
79
|
+
) => Iterable<string> | AsyncIterable<string> | Promise<Iterable<string>>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The query names of a legacy link. An array lists names the link must all carry. The object form adds names it
|
|
83
|
+
* may carry: a link is legacy when it has every `required` name, and the `optional` ones it has go to `verify` too.
|
|
84
|
+
* At most 8 names in all, none repeated, never `r` or `status`.
|
|
85
|
+
*/
|
|
86
|
+
export type LegacyUnsubscribeParams = readonly string[] | { readonly required: readonly string[]; readonly optional?: readonly string[] };
|
|
87
|
+
|
|
64
88
|
/**
|
|
65
89
|
* 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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
90
|
+
* such a link; `verify` receives the values the link carried (every required name, the optional names that are
|
|
91
|
+
* present and non-empty, each at most 512 characters) and returns the recipient's address when the link is genuine,
|
|
92
|
+
* else `null`. It must check the link's signature itself: whatever address it returns is unsubscribed. A throw
|
|
93
|
+
* reads as a failure (the person may try again), not as a bad link.
|
|
69
94
|
*/
|
|
70
95
|
export interface LegacyUnsubscribe {
|
|
71
|
-
readonly params:
|
|
96
|
+
readonly params: LegacyUnsubscribeParams;
|
|
72
97
|
readonly verify: (values: Readonly<Record<string, string>>, ctx: ModuleContext<Queryable>) => Promise<string | null>;
|
|
73
98
|
}
|
|
74
99
|
|
|
@@ -87,6 +112,11 @@ export interface OutgoingMail {
|
|
|
87
112
|
* the body's encoding: reserved names are refused (`RESERVED_HEADERS`).
|
|
88
113
|
*/
|
|
89
114
|
readonly headers?: Readonly<Record<string, string>>;
|
|
115
|
+
/**
|
|
116
|
+
* Where replies to this mail go, exactly one address (e.g. the buyer of a request mailed to the
|
|
117
|
+
* operator). Replaces `mailing({ replyTo })` for this mail; without it the configured one is used.
|
|
118
|
+
*/
|
|
119
|
+
readonly replyTo?: string;
|
|
90
120
|
/**
|
|
91
121
|
* `transactional` (the default) for mail the recipient needs whatever they unsubscribed from:
|
|
92
122
|
* password resets, receipts, account notices. Any other kebab-case name (e.g. `newsletter`) is
|
|
@@ -118,7 +148,7 @@ export type SendMailFailure = Err<MailingErrorCode> & { readonly httpStatus?: nu
|
|
|
118
148
|
|
|
119
149
|
export type SendMailResult = Ok<SentMail> | SendMailFailure;
|
|
120
150
|
|
|
121
|
-
/** What a provider receives: a validated mail with the sender and
|
|
151
|
+
/** What a provider receives: a validated mail with the sender from the configuration and the mail's or the configured reply-to. */
|
|
122
152
|
export interface ProviderMessage {
|
|
123
153
|
readonly from: string;
|
|
124
154
|
readonly to: string;
|