@softure-ai/mailing 0.0.0-stage → 0.1.6
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 +9 -0
- package/LICENSE +21 -0
- package/README.md +346 -2
- package/dist/address.d.ts +27 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +76 -0
- package/dist/address.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +36 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +31 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +288 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/contract.d.ts +102 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +26 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +26 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +59 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +11 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +27 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/actions.d.ts +2 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +35 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +14 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/deliver-once.d.ts +4 -0
- package/dist/next/deliver-once.d.ts.map +1 -0
- package/dist/next/deliver-once.js +7 -0
- package/dist/next/deliver-once.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +11 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/messages.d.ts +5 -0
- package/dist/next/messages.d.ts.map +1 -0
- package/dist/next/messages.js +7 -0
- package/dist/next/messages.js.map +1 -0
- package/dist/next/pages.d.ts +7 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +40 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/params.d.ts +4 -0
- package/dist/next/params.d.ts.map +1 -0
- package/dist/next/params.js +4 -0
- package/dist/next/params.js.map +1 -0
- package/dist/next/route.d.ts +14 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +44 -0
- package/dist/next/route.js.map +1 -0
- package/dist/next/send-mail.d.ts +4 -0
- package/dist/next/send-mail.d.ts.map +1 -0
- package/dist/next/send-mail.js +16 -0
- package/dist/next/send-mail.js.map +1 -0
- package/dist/options.d.ts +13 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +29 -0
- package/dist/options.js.map +1 -0
- package/dist/providers/resend.d.ts +17 -0
- package/dist/providers/resend.d.ts.map +1 -0
- package/dist/providers/resend.js +64 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/schema.d.ts +350 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +30 -0
- package/dist/schema.js.map +1 -0
- package/dist/server/campaign-file.d.ts +28 -0
- package/dist/server/campaign-file.d.ts.map +1 -0
- package/dist/server/campaign-file.js +87 -0
- package/dist/server/campaign-file.js.map +1 -0
- package/dist/server/campaigns.d.ts +58 -0
- package/dist/server/campaigns.d.ts.map +1 -0
- package/dist/server/campaigns.js +118 -0
- package/dist/server/campaigns.js.map +1 -0
- package/dist/server/deliveries.d.ts +56 -0
- package/dist/server/deliveries.d.ts.map +1 -0
- package/dist/server/deliveries.js +100 -0
- package/dist/server/deliveries.js.map +1 -0
- package/dist/server/dns.d.ts +33 -0
- package/dist/server/dns.d.ts.map +1 -0
- package/dist/server/dns.js +113 -0
- package/dist/server/dns.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +13 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/list-mail.d.ts +20 -0
- package/dist/server/list-mail.d.ts.map +1 -0
- package/dist/server/list-mail.js +31 -0
- package/dist/server/list-mail.js.map +1 -0
- package/dist/server/options.d.ts +14 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +22 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/send-mail.d.ts +19 -0
- package/dist/server/send-mail.d.ts.map +1 -0
- package/dist/server/send-mail.js +129 -0
- package/dist/server/send-mail.js.map +1 -0
- package/dist/server/suppressions.d.ts +27 -0
- package/dist/server/suppressions.d.ts.map +1 -0
- package/dist/server/suppressions.js +64 -0
- package/dist/server/suppressions.js.map +1 -0
- package/dist/server/unsubscribe-link.d.ts +51 -0
- package/dist/server/unsubscribe-link.d.ts.map +1 -0
- package/dist/server/unsubscribe-link.js +78 -0
- package/dist/server/unsubscribe-link.js.map +1 -0
- package/dist/server/validate-mail.d.ts +23 -0
- package/dist/server/validate-mail.d.ts.map +1 -0
- package/dist/server/validate-mail.js +52 -0
- package/dist/server/validate-mail.js.map +1 -0
- package/dist/testing/fake-provider.d.ts +24 -0
- package/dist/testing/fake-provider.d.ts.map +1 -0
- package/dist/testing/fake-provider.js +43 -0
- package/dist/testing/fake-provider.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/outbox.d.ts +6 -0
- package/dist/testing/outbox.d.ts.map +1 -0
- package/dist/testing/outbox.js +32 -0
- package/dist/testing/outbox.js.map +1 -0
- package/migrations/0001_create_suppressions.sql +13 -0
- package/migrations/0002_create_campaigns_and_deliveries.sql +41 -0
- package/module.json +32 -0
- package/package.json +74 -4
- package/src/address.ts +89 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +44 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +317 -0
- package/src/contract.ts +108 -0
- package/src/index.ts +85 -0
- package/src/messages/en.ts +25 -0
- package/src/messages/index.ts +15 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +35 -0
- package/src/next/context.ts +15 -0
- package/src/next/deliver-once.ts +10 -0
- package/src/next/index.ts +10 -0
- package/src/next/messages.ts +9 -0
- package/src/next/next-modules.d.ts +12 -0
- package/src/next/pages.tsx +69 -0
- package/src/next/params.ts +6 -0
- package/src/next/route.ts +45 -0
- package/src/next/send-mail.ts +15 -0
- package/src/options.ts +34 -0
- package/src/providers/resend.ts +86 -0
- package/src/schema.ts +40 -0
- package/src/server/campaign-file.ts +101 -0
- package/src/server/campaigns.ts +175 -0
- package/src/server/deliveries.ts +155 -0
- package/src/server/dns.ts +153 -0
- package/src/server/health.ts +14 -0
- package/src/server/index.ts +56 -0
- package/src/server/list-mail.ts +42 -0
- package/src/server/options.ts +32 -0
- package/src/server/send-mail.ts +152 -0
- package/src/server/suppressions.ts +77 -0
- package/src/server/unsubscribe-link.ts +109 -0
- package/src/server/validate-mail.ts +68 -0
- package/src/testing/fake-provider.ts +66 -0
- package/src/testing/index.ts +4 -0
- package/src/testing/outbox.ts +32 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,6DAA6D;AAC7D,OAAO,EAAE,gBAAgB,EAA0E,MAAM,oBAAoB,CAAC;AAC9H,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CapturedMail } from "./fake-provider.js";
|
|
2
|
+
/** Every mail in the outbox, oldest first, optionally only those to `to`. A missing file is an empty outbox. */
|
|
3
|
+
export declare function readMailOutbox(file: string, filter?: {
|
|
4
|
+
readonly to?: string;
|
|
5
|
+
}): Promise<CapturedMail[]>;
|
|
6
|
+
//# sourceMappingURL=outbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../src/testing/outbox.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAcvD,gHAAgH;AAChH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAajH"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Reads the outbox file of `fakeMailProvider({ outboxFile })`, e.g. from a Playwright test.
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const capturedMailSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
from: z.string(),
|
|
7
|
+
to: z.string(),
|
|
8
|
+
replyTo: z.string().nullable(),
|
|
9
|
+
subject: z.string(),
|
|
10
|
+
text: z.string(),
|
|
11
|
+
html: z.string().nullable(),
|
|
12
|
+
headers: z.record(z.string(), z.string()),
|
|
13
|
+
idempotencyKey: z.string().nullable(),
|
|
14
|
+
});
|
|
15
|
+
/** Every mail in the outbox, oldest first, optionally only those to `to`. A missing file is an empty outbox. */
|
|
16
|
+
export async function readMailOutbox(file, filter = {}) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = await readFile(file, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return [];
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return text
|
|
27
|
+
.split("\n")
|
|
28
|
+
.filter((line) => line !== "")
|
|
29
|
+
.map((line) => capturedMailSchema.parse(JSON.parse(line)))
|
|
30
|
+
.filter((mail) => filter.to === undefined || mail.to === filter.to);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=outbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox.js","sourceRoot":"","sources":["../../src/testing/outbox.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC9B,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC3B,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;IACzC,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CACtC,CAAC,CAAC;AAEH,gHAAgH;AAChH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY,EAAE,SAAmC,EAAE;IACtF,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QAClE,MAAM,KAAK,CAAC;IACd,CAAC;IACD,OAAO,IAAI;SACR,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC;SAC7B,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,kBAAkB,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;SACzD,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,SAAS,IAAI,IAAI,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC,CAAC;AACxE,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- The recipients who unsubscribed from list mail (every kind but `transactional`). One row per
|
|
2
|
+
-- address, global across kinds. The address itself is never stored: `recipient_key` is the
|
|
3
|
+
-- base64url SHA-256 of the trimmed, lowercased address (getRecipientKey), the same value the
|
|
4
|
+
-- signed unsubscribe link carries. A second unsubscribe keeps the first row.
|
|
5
|
+
-- Rollback: DROP TABLE mailing.suppressions; then
|
|
6
|
+
-- DELETE FROM softure.migrations WHERE module = 'mailing' AND version = 1;
|
|
7
|
+
CREATE TABLE suppressions (
|
|
8
|
+
recipient_key text PRIMARY KEY CHECK (recipient_key ~ '^[A-Za-z0-9_-]{43}$'),
|
|
9
|
+
-- How the opt-out arrived: a mail client's one-click POST, the unsubscribe page's button, or an
|
|
10
|
+
-- operator (suppressRecipient from a script or another module).
|
|
11
|
+
source text NOT NULL CHECK (source IN ('one-click', 'page', 'operator')),
|
|
12
|
+
created_at timestamptz NOT NULL
|
|
13
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- The delivery ledger: every mail that must go out at most once to a recipient within a scope (a
|
|
2
|
+
-- campaign, or a lifecycle event such as `billing.trial-ending:sub_42`). A sender claims the row
|
|
3
|
+
-- before calling the provider and closes it with one outcome, `sent` or `rejected`; `pending` is a
|
|
4
|
+
-- claim released after the provider was unavailable. Recipients are keys (getRecipientKey), never
|
|
5
|
+
-- addresses, as in mailing.suppressions.
|
|
6
|
+
-- Rollback: DROP TABLE mailing.deliveries; DROP TABLE mailing.campaigns; then
|
|
7
|
+
-- DELETE FROM softure.migrations WHERE module = 'mailing' AND version = 2;
|
|
8
|
+
|
|
9
|
+
-- A campaign pins its content: a second run with the same id and other content is refused.
|
|
10
|
+
CREATE TABLE campaigns (
|
|
11
|
+
id text PRIMARY KEY CHECK (id ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(id) <= 64),
|
|
12
|
+
-- Campaigns are list mail: they carry the unsubscribe link and skip suppressed recipients.
|
|
13
|
+
kind text NOT NULL CHECK (kind ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(kind) <= 64 AND kind <> 'transactional'),
|
|
14
|
+
subject text NOT NULL CHECK (length(subject) BETWEEN 1 AND 998),
|
|
15
|
+
-- sha256 (hex) of the kind, subject, text and HTML bodies.
|
|
16
|
+
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
|
|
17
|
+
created_at timestamptz NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE deliveries (
|
|
21
|
+
scope text NOT NULL CHECK (scope ~ '^[a-z0-9][a-z0-9._:-]*$' AND length(scope) <= 128),
|
|
22
|
+
recipient_key text NOT NULL CHECK (recipient_key ~ '^[A-Za-z0-9_-]{43}$'),
|
|
23
|
+
kind text NOT NULL CHECK (kind ~ '^[a-z0-9]+(-[a-z0-9]+)*$' AND length(kind) <= 64),
|
|
24
|
+
campaign_id text REFERENCES campaigns (id),
|
|
25
|
+
status text NOT NULL CHECK (status IN ('pending', 'claimed', 'sent', 'rejected')),
|
|
26
|
+
-- Claims so far; an outcome is written only by the holder of the latest claim.
|
|
27
|
+
attempts integer NOT NULL CHECK (attempts >= 1),
|
|
28
|
+
claimed_at timestamptz NOT NULL,
|
|
29
|
+
finished_at timestamptz,
|
|
30
|
+
provider_message_id text,
|
|
31
|
+
-- Why a rejected delivery is final: a mailing.* error code of sendMail.
|
|
32
|
+
reason text CHECK (reason IN ('mailing.invalid_input', 'mailing.rejected', 'mailing.unavailable', 'mailing.suppressed')),
|
|
33
|
+
created_at timestamptz NOT NULL,
|
|
34
|
+
PRIMARY KEY (scope, recipient_key),
|
|
35
|
+
CHECK (campaign_id IS NULL OR scope = 'campaign:' || campaign_id),
|
|
36
|
+
CHECK ((status = 'sent') = (provider_message_id IS NOT NULL)),
|
|
37
|
+
CHECK ((status = 'rejected') = (reason IS NOT NULL)),
|
|
38
|
+
CHECK ((status IN ('sent', 'rejected')) = (finished_at IS NOT NULL))
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX deliveries_campaign_status ON deliveries (campaign_id, status) WHERE campaign_id IS NOT NULL;
|
package/module.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "mailing",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"dependsOn": {},
|
|
5
|
+
"dbSchema": "mailing",
|
|
6
|
+
"tables": ["suppressions", "campaigns", "deliveries"],
|
|
7
|
+
"env": [
|
|
8
|
+
{
|
|
9
|
+
"name": "RESEND_API_KEY",
|
|
10
|
+
"required": false,
|
|
11
|
+
"description": "API key of the resend() provider, read on every send; not needed with resend({ apiKey }) or another provider."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "MAILING_UNSUBSCRIBE_SECRET",
|
|
15
|
+
"required": false,
|
|
16
|
+
"description": "Signs and verifies unsubscribe links, at least 32 characters; required to send list mail (any kind but transactional). Never change it without moving the old value to MAILING_UNSUBSCRIBE_SECRET_PREVIOUS."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "MAILING_UNSUBSCRIBE_SECRET_PREVIOUS",
|
|
20
|
+
"required": false,
|
|
21
|
+
"description": "The secret before a rotation, at least 32 characters: verifies links in mail already sent, never signs."
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"switches": [],
|
|
25
|
+
"routes": { "unsubscribe": "/unsubscribe", "oneClick": "/api/mailing/unsubscribe" },
|
|
26
|
+
"mount": [
|
|
27
|
+
{ "kind": "page", "path": "app/unsubscribe/page.tsx", "export": "UnsubscribePage" },
|
|
28
|
+
{ "kind": "route-handler", "path": "app/api/mailing/unsubscribe/route.ts", "export": "postUnsubscribeRoute" },
|
|
29
|
+
{ "kind": "route-handler", "path": "app/api/mailing/unsubscribe/route.ts", "export": "getUnsubscribeRoute" }
|
|
30
|
+
],
|
|
31
|
+
"privacy": { "exports": false, "deletes": false }
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/mailing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.6",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/SOFTURE/AI.git",
|
|
14
|
+
"directory": "modules/mailing"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"src",
|
|
20
|
+
"!src/**/*.test.ts",
|
|
21
|
+
"!src/**/*.test.tsx",
|
|
22
|
+
"migrations",
|
|
23
|
+
"module.json"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"provenance": true
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"softure-mail": "./dist/cli/bin.js"
|
|
31
|
+
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"@softure-ai/source": "./src/index.ts",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"default": "./dist/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./server": {
|
|
39
|
+
"@softure-ai/source": "./src/server/index.ts",
|
|
40
|
+
"types": "./dist/server/index.d.ts",
|
|
41
|
+
"default": "./dist/server/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./next": {
|
|
44
|
+
"@softure-ai/source": "./src/next/index.ts",
|
|
45
|
+
"types": "./dist/next/index.d.ts",
|
|
46
|
+
"default": "./dist/next/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./testing": {
|
|
49
|
+
"@softure-ai/source": "./src/testing/index.ts",
|
|
50
|
+
"types": "./dist/testing/index.d.ts",
|
|
51
|
+
"default": "./dist/testing/index.js"
|
|
52
|
+
},
|
|
53
|
+
"./cli": {
|
|
54
|
+
"@softure-ai/source": "./src/cli/index.ts",
|
|
55
|
+
"types": "./dist/cli/index.d.ts",
|
|
56
|
+
"default": "./dist/cli/index.js"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.build.json"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@softure-ai/core": "^0.1.0",
|
|
64
|
+
"@softure-ai/db": "^0.1.0",
|
|
65
|
+
"zod": "^4.6.5"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@softure-ai/ui": "^0.1.0",
|
|
69
|
+
"drizzle-orm": "^0.45.2",
|
|
70
|
+
"next": "^16.0.0",
|
|
71
|
+
"react": "^19.0.0"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@softure-ai/ui": "^0.1.7"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/address.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Address and header rules shared by the options schema and the mail validation.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One address: an `@`, a dot-separated domain, no list separators, no whitespace, no angle
|
|
5
|
+
* brackets. Each part excludes its own separator, so matching stays linear on any input.
|
|
6
|
+
*/
|
|
7
|
+
const ADDRESS = /^[^\s@,;<>"]+@[^\s@,;<>".]+(?:\.[^\s@,;<>".]+)+$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A display name: no commas, semicolons or quotes. Unquoted, RFC 5322 reads a comma as a second
|
|
11
|
+
* address, and quoting is left out on purpose.
|
|
12
|
+
*/
|
|
13
|
+
const DISPLAY_NAME = /^[^<>",;\r\n]+$/;
|
|
14
|
+
|
|
15
|
+
/** The longest address SMTP carries (RFC 5321 path limit). */
|
|
16
|
+
export const MAX_ADDRESS_LENGTH = 254;
|
|
17
|
+
|
|
18
|
+
/** RFC 5322 field name: printable ASCII except the colon. */
|
|
19
|
+
const HEADER_NAME = /^[!-9;-~]+$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Headers that would take over the envelope, the sender or the body's encoding. The `headers`
|
|
23
|
+
* field is for list headers (RFC 8058) and similar; a different `From:` fails DMARC, an added
|
|
24
|
+
* recipient is mail nobody asked for.
|
|
25
|
+
*/
|
|
26
|
+
export const RESERVED_HEADERS: ReadonlySet<string> = new Set([
|
|
27
|
+
"from",
|
|
28
|
+
"sender",
|
|
29
|
+
"to",
|
|
30
|
+
"cc",
|
|
31
|
+
"bcc",
|
|
32
|
+
"reply-to",
|
|
33
|
+
"subject",
|
|
34
|
+
"return-path",
|
|
35
|
+
"content-type",
|
|
36
|
+
"content-transfer-encoding",
|
|
37
|
+
"mime-version",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Headers the module writes on every list mail. A list mail that brings its own is refused: two
|
|
42
|
+
* `List-Unsubscribe` headers, one of them unsigned, is worse than none.
|
|
43
|
+
*/
|
|
44
|
+
export const LIST_MAIL_HEADERS: ReadonlySet<string> = new Set(["list-unsubscribe", "list-unsubscribe-post"]);
|
|
45
|
+
|
|
46
|
+
/** A mail kind: kebab-case, 1 to 64 characters, e.g. `newsletter`. */
|
|
47
|
+
const MAIL_KIND = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
48
|
+
export const MAX_MAIL_KIND_LENGTH = 64;
|
|
49
|
+
|
|
50
|
+
export const MAX_SUBJECT_LENGTH = 998;
|
|
51
|
+
export const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
|
|
52
|
+
|
|
53
|
+
export function isSingleAddress(value: string): boolean {
|
|
54
|
+
return value.length <= MAX_ADDRESS_LENGTH && ADDRESS.test(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `addr` or `Name <addr>`, on one line. */
|
|
58
|
+
export function isMailbox(value: string): boolean {
|
|
59
|
+
if (hasLineBreak(value)) return false;
|
|
60
|
+
if (!value.endsWith(">")) return isSingleAddress(value);
|
|
61
|
+
const open = value.lastIndexOf("<");
|
|
62
|
+
const name = value.slice(0, Math.max(open, 0)).trim();
|
|
63
|
+
return open > 0 && name !== "" && DISPLAY_NAME.test(name) && isSingleAddress(value.slice(open + 1, -1));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hasLineBreak(value: string): boolean {
|
|
67
|
+
return /[\r\n]/.test(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isHeaderName(name: string): boolean {
|
|
71
|
+
return HEADER_NAME.test(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isReservedHeader(name: string): boolean {
|
|
75
|
+
return RESERVED_HEADERS.has(name.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isListMailHeader(name: string): boolean {
|
|
79
|
+
return LIST_MAIL_HEADERS.has(name.toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isMailKind(kind: string): boolean {
|
|
83
|
+
return kind.length <= MAX_MAIL_KIND_LENGTH && MAIL_KIND.test(kind);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 1 to 256 visible ASCII characters. */
|
|
87
|
+
export function isIdempotencyKey(key: string): boolean {
|
|
88
|
+
return key.length <= MAX_IDEMPOTENCY_KEY_LENGTH && /^[!-~]+$/.test(key);
|
|
89
|
+
}
|
package/src/cli/bin.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `softure-mail` executable. All logic lives in command.ts and run.ts, which the tests call directly.
|
|
3
|
+
import { runMailCommand } from "./command.js";
|
|
4
|
+
|
|
5
|
+
process.exitCode = await runMailCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The `softure-mail` bin. It loads the app's config through `@softure-ai/core/cli`, like
|
|
2
|
+
// `softure migrate` (@softure-ai/db): the file must be loadable by Node itself (`.js`/`.mjs`, or `.ts`
|
|
3
|
+
// where Node strips types). Anything else uses an app script with `runMailCli` instead (see run.ts).
|
|
4
|
+
import { loadAppConfig, takeConfigOption } from "@softure-ai/core/cli";
|
|
5
|
+
import { EXIT_FAILED, EXIT_USAGE, MAIL_USAGE, runMailCli, type CliOutput } from "./run.js";
|
|
6
|
+
|
|
7
|
+
export interface RunMailCommandOptions {
|
|
8
|
+
readonly argv: readonly string[];
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
readonly output?: CliOutput;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const APP_SCRIPT = { runner: "runMailCli", packageName: "@softure-ai/mailing" };
|
|
14
|
+
|
|
15
|
+
const consoleOutput: CliOutput = {
|
|
16
|
+
log: (line) => console.log(line),
|
|
17
|
+
error: (line) => console.error(line),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Loads the config (unless the command needs none) and runs `runMailCli`. */
|
|
21
|
+
export async function runMailCommand(options: RunMailCommandOptions): Promise<number> {
|
|
22
|
+
const output = options.output ?? consoleOutput;
|
|
23
|
+
const taken = takeConfigOption(options.argv);
|
|
24
|
+
if (!taken.ok) {
|
|
25
|
+
output.error(`softure-mail: ${taken.problem}`);
|
|
26
|
+
output.error(MAIL_USAGE);
|
|
27
|
+
return EXIT_USAGE;
|
|
28
|
+
}
|
|
29
|
+
const { configPath, argv } = taken;
|
|
30
|
+
if (!needsConfig(argv) && configPath === undefined) return runMailCli({ argv, cwd: options.cwd, output });
|
|
31
|
+
|
|
32
|
+
const loaded = await loadAppConfig({ cwd: options.cwd, configPath, appScript: APP_SCRIPT });
|
|
33
|
+
if (!loaded.ok) {
|
|
34
|
+
output.error(`softure-mail: ${loaded.problem}`);
|
|
35
|
+
return EXIT_FAILED;
|
|
36
|
+
}
|
|
37
|
+
return runMailCli({ config: loaded.config, argv, cwd: options.cwd, output });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `dns --domain` and help need no config; everything else reads it. */
|
|
41
|
+
function needsConfig(argv: readonly string[]): boolean {
|
|
42
|
+
if (argv.length === 0 || argv.includes("--help")) return false;
|
|
43
|
+
return !(argv[0] === "dns" && argv.some((arg) => arg === "--domain" || arg.startsWith("--domain=")));
|
|
44
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// `@softure-ai/mailing/cli`: the `softure-mail` command for app scripts and container bundles.
|
|
2
|
+
export { runMailCommand, type RunMailCommandOptions } from "./command.js";
|
|
3
|
+
export { DEFAULT_PAUSE_MS, EXIT_FAILED, EXIT_OK, EXIT_USAGE, MAIL_USAGE, runMailCli, type CliOutput, type RunMailCliOptions } from "./run.js";
|