@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,12 @@
|
|
|
1
|
+
// Next.js has no `exports` map, so NodeNext resolution (tsconfig.base.json) only finds
|
|
2
|
+
// `next/navigation.js` and `next/headers.js`. The code must still import the bare specifiers:
|
|
3
|
+
// Next's bundler aliases them per runtime, and the `.js` form bypasses the alias (measured:
|
|
4
|
+
// `next build` failed to collect a route handler importing `next/navigation.js`, MODULE_UNPARSABLE
|
|
5
|
+
// on the vendored app-router context). These declarations give the bare specifiers their types.
|
|
6
|
+
declare module "next/navigation" {
|
|
7
|
+
export * from "next/navigation.js";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare module "next/headers" {
|
|
11
|
+
export * from "next/headers.js";
|
|
12
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The unsubscribe page, ready to mount with one line:
|
|
2
|
+
// `export { UnsubscribePage as default } from "@softure-ai/mailing/next"`.
|
|
3
|
+
// A server component without client JavaScript: the button is a plain form posting a server
|
|
4
|
+
// action, so it works in any browser a mail client opens. Opening the page changes nothing (mail
|
|
5
|
+
// scanners open links too) and does not check the signature: the submit does.
|
|
6
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
7
|
+
import { Button, Card, FormError } from "@softure-ai/ui";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { RECIPIENT_PARAM, SIGNATURE_PARAM } from "../server/unsubscribe-link.js";
|
|
10
|
+
import { unsubscribeAction } from "./actions.js";
|
|
11
|
+
import { getMailingMessages } from "./messages.js";
|
|
12
|
+
import { UNSUBSCRIBE_STATUS_PARAM } from "./params.js";
|
|
13
|
+
|
|
14
|
+
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
|
15
|
+
|
|
16
|
+
export interface UnsubscribePageProps {
|
|
17
|
+
readonly searchParams?: SearchParams;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-md sft:px-4 sft:py-4";
|
|
21
|
+
const FORM_CLASS = "sft:flex sft:flex-col sft:gap-3 sft:font-sans";
|
|
22
|
+
|
|
23
|
+
function readParam(params: Record<string, string | string[] | undefined>, name: string): string | undefined {
|
|
24
|
+
const value = params[name];
|
|
25
|
+
return Array.isArray(value) ? value[0] : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The link's token stays out of other sites' Referer headers; `same-origin` (not `no-referrer`)
|
|
30
|
+
* keeps the Origin header Next checks on server actions.
|
|
31
|
+
*/
|
|
32
|
+
function UnsubscribeLayout({ title, lead, children }: { title: string; lead: string; children?: ReactNode }) {
|
|
33
|
+
return (
|
|
34
|
+
<main className={LAYOUT_CLASS}>
|
|
35
|
+
<meta name="referrer" content="same-origin" />
|
|
36
|
+
<Card title={title} subtitle={lead}>
|
|
37
|
+
{children}
|
|
38
|
+
</Card>
|
|
39
|
+
</main>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function UnsubscribePage({ searchParams }: UnsubscribePageProps) {
|
|
44
|
+
const config = getSoftureConfig();
|
|
45
|
+
const messages = getMailingMessages(config).unsubscribe;
|
|
46
|
+
const params = (await searchParams) ?? {};
|
|
47
|
+
const status = readParam(params, UNSUBSCRIBE_STATUS_PARAM);
|
|
48
|
+
if (status === "done") return <UnsubscribeLayout title={messages.doneTitle} lead={messages.doneBody} />;
|
|
49
|
+
|
|
50
|
+
const recipientKey = readParam(params, RECIPIENT_PARAM) ?? "";
|
|
51
|
+
const signature = readParam(params, SIGNATURE_PARAM) ?? "";
|
|
52
|
+
if (status === "invalid" || recipientKey === "" || signature === "") {
|
|
53
|
+
return <UnsubscribeLayout title={messages.invalidTitle} lead={messages.invalidBody} />;
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<UnsubscribeLayout title={messages.title} lead={messages.lead}>
|
|
57
|
+
<form action={unsubscribeAction} className={FORM_CLASS}>
|
|
58
|
+
<input type="hidden" name={RECIPIENT_PARAM} value={recipientKey} />
|
|
59
|
+
<input type="hidden" name={SIGNATURE_PARAM} value={signature} />
|
|
60
|
+
<FormError message={status === "failed" ? messages.failed : undefined} />
|
|
61
|
+
<div>
|
|
62
|
+
<Button type="submit" variant="primary">
|
|
63
|
+
{messages.submit}
|
|
64
|
+
</Button>
|
|
65
|
+
</div>
|
|
66
|
+
</form>
|
|
67
|
+
</UnsubscribeLayout>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The query parameter the unsubscribe page and its action pass between each other.
|
|
2
|
+
|
|
3
|
+
/** Set by the action after a submit: `done`, `invalid` or `failed`. */
|
|
4
|
+
export const UNSUBSCRIBE_STATUS_PARAM = "status";
|
|
5
|
+
|
|
6
|
+
export type UnsubscribeStatus = "done" | "invalid" | "failed";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The RFC 8058 one-click route. Mount both handlers with a rename in
|
|
2
|
+
// app/api/mailing/unsubscribe/route.ts:
|
|
3
|
+
// `export { postUnsubscribeRoute as POST, getUnsubscribeRoute as GET } from "@softure-ai/mailing/next"`.
|
|
4
|
+
//
|
|
5
|
+
// Mail clients (Gmail, Outlook, Apple Mail) POST here from their own servers: no cookies, no
|
|
6
|
+
// server action id, a foreign Origin. That is why this is a route handler and not an action. It
|
|
7
|
+
// needs no session and must stay outside any auth guard. There is no per-IP rate limit on
|
|
8
|
+
// purpose: a few provider addresses act for millions of people, so a bucket would refuse real
|
|
9
|
+
// unsubscribes. The link is checked by shape and signature before the database is touched, and
|
|
10
|
+
// the only write is an idempotent insert.
|
|
11
|
+
import { errorLogLabel } from "@softure-ai/core";
|
|
12
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
13
|
+
import { getMailingRoutes } from "../server/options.js";
|
|
14
|
+
import { unsubscribe } from "../server/suppressions.js";
|
|
15
|
+
import { readUnsubscribeToken } from "../server/unsubscribe-link.js";
|
|
16
|
+
import { getMailingContext } from "./context.js";
|
|
17
|
+
|
|
18
|
+
const NO_STORE = { "cache-control": "no-store" };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
|
|
22
|
+
* anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
|
|
23
|
+
* that does not verify, 500 when the database fails: the client may retry, and 200 would claim an
|
|
24
|
+
* opt-out that did not happen.
|
|
25
|
+
*/
|
|
26
|
+
export async function postUnsubscribeRoute(request: Request): Promise<Response> {
|
|
27
|
+
const token = readUnsubscribeToken(new URL(request.url).searchParams);
|
|
28
|
+
try {
|
|
29
|
+
const result = await unsubscribe(await getMailingContext(), token, "one-click");
|
|
30
|
+
return new Response(null, { status: result.ok ? 200 : 400, headers: NO_STORE });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`@softure-ai/mailing: one-click unsubscribe failed: ${errorLogLabel(error)}`);
|
|
33
|
+
return new Response(null, { status: 500, headers: NO_STORE });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A client without RFC 8058 opens the header's URL in a browser: send it to the page with the
|
|
39
|
+
* same link (303, so nothing changes on GET). A 405 would also put the token in access logs that
|
|
40
|
+
* record failed requests.
|
|
41
|
+
*/
|
|
42
|
+
export function getUnsubscribeRoute(request: Request): Response {
|
|
43
|
+
const { search } = new URL(request.url);
|
|
44
|
+
return new Response(null, { status: 303, headers: { ...NO_STORE, location: `${getMailingRoutes(getSoftureConfig()).unsubscribe}${search}` } });
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `sendMail` on the app's registered configuration, for server actions and route handlers. List
|
|
2
|
+
// mail also gets the shared database handle (its suppression check); transactional mail never
|
|
3
|
+
// touches the database.
|
|
4
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
5
|
+
import { TRANSACTIONAL_KIND, type OutgoingMail, type SendMailOptions, type SendMailResult } from "../contract.js";
|
|
6
|
+
import { sendMail as sendMailWithContext } from "../server/send-mail.js";
|
|
7
|
+
import { getMailingContext } from "./context.js";
|
|
8
|
+
|
|
9
|
+
/** Sends one mail with the registered config; see `sendMail` in `@softure-ai/mailing/server`. */
|
|
10
|
+
export async function sendMail(mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
11
|
+
const config = getSoftureConfig();
|
|
12
|
+
if ((mail.kind ?? TRANSACTIONAL_KIND) === TRANSACTIONAL_KIND) return sendMailWithContext({ config }, mail, options);
|
|
13
|
+
const { db } = await getMailingContext(config);
|
|
14
|
+
return sendMailWithContext({ config, db }, mail, options);
|
|
15
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The options an app passes to `mailing({ ... })` in softure.config.ts, parsed at startup.
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isMailbox, isSingleAddress } from "./address.js";
|
|
4
|
+
import type { MailProvider, OnUnsubscribedHook } from "./contract.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
7
|
+
|
|
8
|
+
function isMailProvider(value: unknown): value is MailProvider {
|
|
9
|
+
if (typeof value !== "object" || value === null) return false;
|
|
10
|
+
const candidate = value as { name?: unknown; send?: unknown };
|
|
11
|
+
return typeof candidate.name === "string" && candidate.name !== "" && typeof candidate.send === "function";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const mailingOptionsSchema = z.strictObject({
|
|
15
|
+
/**
|
|
16
|
+
* The sender of every mail: `hello@example.com` or `Plan <hello@example.com>`. It must be on a
|
|
17
|
+
* domain the provider signs (DKIM), or DMARC rejects the mail.
|
|
18
|
+
*/
|
|
19
|
+
from: z.string().trim().refine(isMailbox, "must be an address or Name <address>, e.g. Plan <hello@example.com>"),
|
|
20
|
+
/** Where replies go; may sit on another domain than `from` (DMARC does not check it). */
|
|
21
|
+
replyTo: z.string().trim().refine(isSingleAddress, "must be one address, e.g. support@example.com").optional(),
|
|
22
|
+
/** The adapter that delivers mail: `resend()`, or `fakeMailProvider()` from `/testing`. */
|
|
23
|
+
provider: z.custom<MailProvider>(isMailProvider, "must be a mail provider such as resend()"),
|
|
24
|
+
/** How long one send may take before it reads as `mailing.unavailable`. */
|
|
25
|
+
timeoutMs: z.number().int().min(1_000).max(60_000).default(DEFAULT_TIMEOUT_MS),
|
|
26
|
+
/**
|
|
27
|
+
* Reacts to an unsubscribe in its transaction, e.g. `withdrawWaitlistConsents` of
|
|
28
|
+
* `@softure-ai/waitlist/server`, which records the withdrawal in privacy's consent ledger.
|
|
29
|
+
*/
|
|
30
|
+
onUnsubscribed: z.custom<OnUnsubscribedHook>((value) => typeof value === "function", "must be a function").optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type MailingOptionsInput = z.input<typeof mailingOptionsSchema>;
|
|
34
|
+
export type MailingOptions = z.output<typeof mailingOptionsSchema>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The Resend adapter (https://resend.com/docs/api-reference/emails/send-email). Ported from
|
|
2
|
+
// FIRE_TRACKER `src/lib/mail.ts`, where the sender, reply-to and timeout were constants; here they
|
|
3
|
+
// come from the module options and `sendMail` owns validation, the timeout and the log line.
|
|
4
|
+
import type { MailProvider, ProviderMessage, ProviderOutcome } from "../contract.js";
|
|
5
|
+
|
|
6
|
+
export const RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
7
|
+
export const RESEND_API_KEY_ENV = "RESEND_API_KEY";
|
|
8
|
+
|
|
9
|
+
export interface ResendOptions {
|
|
10
|
+
/**
|
|
11
|
+
* The API key. Default: `process.env.RESEND_API_KEY`, read on every send, so the configuration
|
|
12
|
+
* loads at build time without the secret.
|
|
13
|
+
*/
|
|
14
|
+
readonly apiKey?: string;
|
|
15
|
+
/** Default: Resend's `POST /emails`. For a proxy or a test server. */
|
|
16
|
+
readonly endpoint?: string;
|
|
17
|
+
/** Injected in tests; default: the global `fetch`. */
|
|
18
|
+
readonly fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resend's name for an idempotency key that another request is still using: retry later. */
|
|
22
|
+
const CONCURRENT_IDEMPOTENT_REQUESTS = "concurrent_idempotent_requests";
|
|
23
|
+
|
|
24
|
+
/** Statuses below 500 that mean "try again later", not "this mail is refused". */
|
|
25
|
+
const RETRYABLE_CLIENT_STATUSES: ReadonlySet<number> = new Set([408, 429]);
|
|
26
|
+
|
|
27
|
+
/** Sends through Resend's HTTP API. */
|
|
28
|
+
export function resend(options: ResendOptions = {}): MailProvider {
|
|
29
|
+
const endpoint = options.endpoint ?? RESEND_ENDPOINT;
|
|
30
|
+
return {
|
|
31
|
+
name: "resend",
|
|
32
|
+
send: async (message, { signal }) => {
|
|
33
|
+
const apiKey = (options.apiKey ?? process.env[RESEND_API_KEY_ENV] ?? "").trim();
|
|
34
|
+
if (apiKey === "") {
|
|
35
|
+
// A deployment without the key cannot send anything until it is set; never a caller error.
|
|
36
|
+
console.error(`mailing: resend has no API key; set ${RESEND_API_KEY_ENV} or pass resend({ apiKey })`);
|
|
37
|
+
return { status: "unavailable" };
|
|
38
|
+
}
|
|
39
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
40
|
+
const response = await fetchImpl(endpoint, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${apiKey}`,
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
...(message.idempotencyKey === null ? {} : { "Idempotency-Key": message.idempotencyKey }),
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify(toResendBody(message)),
|
|
48
|
+
signal,
|
|
49
|
+
cache: "no-store",
|
|
50
|
+
});
|
|
51
|
+
return readOutcome(response);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toResendBody(message: ProviderMessage): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
from: message.from,
|
|
59
|
+
to: [message.to],
|
|
60
|
+
subject: message.subject,
|
|
61
|
+
text: message.text,
|
|
62
|
+
...(message.html === null ? {} : { html: message.html }),
|
|
63
|
+
...(message.replyTo === null ? {} : { reply_to: message.replyTo }),
|
|
64
|
+
...(Object.keys(message.headers).length === 0 ? {} : { headers: message.headers }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readOutcome(response: Response): Promise<ProviderOutcome> {
|
|
69
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
70
|
+
if (response.ok) {
|
|
71
|
+
const id = readString(payload, "id");
|
|
72
|
+
return id === null ? { status: "unavailable", httpStatus: response.status } : { status: "sent", id };
|
|
73
|
+
}
|
|
74
|
+
// Only the error's `name` (an enum) is read: its `message` can echo the address back.
|
|
75
|
+
const isRetryable =
|
|
76
|
+
response.status >= 500 ||
|
|
77
|
+
RETRYABLE_CLIENT_STATUSES.has(response.status) ||
|
|
78
|
+
(response.status === 409 && readString(payload, "name") === CONCURRENT_IDEMPOTENT_REQUESTS);
|
|
79
|
+
return { status: isRetryable ? "unavailable" : "rejected", httpStatus: response.status };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readString(payload: unknown, key: string): string | null {
|
|
83
|
+
if (typeof payload !== "object" || payload === null || !Object.hasOwn(payload, key)) return null;
|
|
84
|
+
const value: unknown = (payload as Record<string, unknown>)[key];
|
|
85
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
86
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Drizzle view of the module's tables (migrations/). The migrations are the source of truth; this
|
|
2
|
+
// file only types the queries.
|
|
3
|
+
import { integer, pgSchema, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
|
|
4
|
+
|
|
5
|
+
export const mailingSchema = pgSchema("mailing");
|
|
6
|
+
|
|
7
|
+
export const suppressions = mailingSchema.table("suppressions", {
|
|
8
|
+
recipientKey: text("recipient_key").primaryKey(),
|
|
9
|
+
source: text("source").notNull(),
|
|
10
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const campaigns = mailingSchema.table("campaigns", {
|
|
14
|
+
id: text("id").primaryKey(),
|
|
15
|
+
kind: text("kind").notNull(),
|
|
16
|
+
subject: text("subject").notNull(),
|
|
17
|
+
contentHash: text("content_hash").notNull(),
|
|
18
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const deliveries = mailingSchema.table(
|
|
22
|
+
"deliveries",
|
|
23
|
+
{
|
|
24
|
+
scope: text("scope").notNull(),
|
|
25
|
+
recipientKey: text("recipient_key").notNull(),
|
|
26
|
+
kind: text("kind").notNull(),
|
|
27
|
+
campaignId: text("campaign_id"),
|
|
28
|
+
status: text("status").$type<DeliveryStatus>().notNull(),
|
|
29
|
+
attempts: integer("attempts").notNull(),
|
|
30
|
+
claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull(),
|
|
31
|
+
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
|
32
|
+
providerMessageId: text("provider_message_id"),
|
|
33
|
+
reason: text("reason"),
|
|
34
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
35
|
+
},
|
|
36
|
+
(table) => [primaryKey({ columns: [table.scope, table.recipientKey] })],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** A ledger row's state: `pending` (released for a retry), `claimed`, or a final `sent` / `rejected`. */
|
|
40
|
+
export type DeliveryStatus = "pending" | "claimed" | "sent" | "rejected";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The campaign content file of `softure-mail campaign`: a frontmatter block and a plain-text body.
|
|
2
|
+
//
|
|
3
|
+
// ---
|
|
4
|
+
// id: 2026-10-launch
|
|
5
|
+
// kind: newsletter
|
|
6
|
+
// subject: Something new in Plan
|
|
7
|
+
// html: launch.html
|
|
8
|
+
// ---
|
|
9
|
+
// Hello,
|
|
10
|
+
// ...
|
|
11
|
+
//
|
|
12
|
+
// `html` is optional and names a file next to the content file, rendered however the operator
|
|
13
|
+
// likes. There is no markdown: the text body is sent as written.
|
|
14
|
+
import { isMailKind, MAX_SUBJECT_LENGTH } from "../address.js";
|
|
15
|
+
import { TRANSACTIONAL_KIND } from "../contract.js";
|
|
16
|
+
|
|
17
|
+
/** A campaign's content, as `sendCampaign` sends it. */
|
|
18
|
+
export interface CampaignContent {
|
|
19
|
+
/** Kebab-case, at most 64 characters, e.g. `2026-10-launch`. Never reuse an id for other content. */
|
|
20
|
+
readonly id: string;
|
|
21
|
+
/** The list it belongs to, e.g. `newsletter`; never `transactional`. */
|
|
22
|
+
readonly kind: string;
|
|
23
|
+
readonly subject: string;
|
|
24
|
+
readonly text: string;
|
|
25
|
+
readonly html: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A parsed content file: the HTML body is still a path relative to the file. */
|
|
29
|
+
export interface CampaignFile extends Omit<CampaignContent, "html"> {
|
|
30
|
+
readonly htmlPath: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CampaignFileResult = { readonly ok: true; readonly value: CampaignFile } | { readonly ok: false; readonly problems: readonly string[] };
|
|
34
|
+
|
|
35
|
+
const FENCE = "---";
|
|
36
|
+
const KEYS = new Set(["id", "kind", "subject", "html"]);
|
|
37
|
+
const CAMPAIGN_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
38
|
+
const MAX_CAMPAIGN_ID_LENGTH = 64;
|
|
39
|
+
|
|
40
|
+
/** Parses a content file, listing every problem at once. */
|
|
41
|
+
export function parseCampaignFile(source: string): CampaignFileResult {
|
|
42
|
+
const lines = source.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
43
|
+
if (lines[0]?.trim() !== FENCE) return { ok: false, problems: ["the file must start with a --- line opening the frontmatter"] };
|
|
44
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === FENCE);
|
|
45
|
+
if (end === -1) return { ok: false, problems: ["the frontmatter has no closing --- line"] };
|
|
46
|
+
|
|
47
|
+
const problems: string[] = [];
|
|
48
|
+
const fields = new Map<string, string>();
|
|
49
|
+
lines.slice(1, end).forEach((line, index) => {
|
|
50
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) return;
|
|
51
|
+
const match = /^([a-z]+):(.*)$/.exec(line);
|
|
52
|
+
const key = match?.[1];
|
|
53
|
+
const value = match?.[2]?.trim();
|
|
54
|
+
if (key === undefined || value === undefined) {
|
|
55
|
+
problems.push(`frontmatter line ${String(index + 2)} must read "key: value"`);
|
|
56
|
+
} else if (!KEYS.has(key)) {
|
|
57
|
+
problems.push(`unknown frontmatter key "${key}"; use ${[...KEYS].join(", ")}`);
|
|
58
|
+
} else if (fields.has(key)) {
|
|
59
|
+
problems.push(`frontmatter key "${key}" appears twice`);
|
|
60
|
+
} else {
|
|
61
|
+
fields.set(key, value);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const text = lines.slice(end + 1).join("\n").trim();
|
|
66
|
+
const file: CampaignFile = {
|
|
67
|
+
id: fields.get("id") ?? "",
|
|
68
|
+
kind: fields.get("kind") ?? "",
|
|
69
|
+
subject: fields.get("subject") ?? "",
|
|
70
|
+
text,
|
|
71
|
+
htmlPath: fields.get("html") ?? null,
|
|
72
|
+
};
|
|
73
|
+
problems.push(...getCampaignProblems({ ...file, html: null }));
|
|
74
|
+
if (file.htmlPath === "") problems.push("html: give a file name or leave the key out");
|
|
75
|
+
return problems.length === 0 ? { ok: true, value: file } : { ok: false, problems };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What makes `content` unsendable as a campaign; empty when it is fine. */
|
|
79
|
+
export function getCampaignProblems(content: CampaignContent): string[] {
|
|
80
|
+
const problems: string[] = [];
|
|
81
|
+
if (content.id.length > MAX_CAMPAIGN_ID_LENGTH || !CAMPAIGN_ID.test(content.id)) {
|
|
82
|
+
problems.push("id: kebab-case, at most 64 characters, e.g. 2026-10-launch");
|
|
83
|
+
}
|
|
84
|
+
if (!isMailKind(content.kind) || content.kind === TRANSACTIONAL_KIND) {
|
|
85
|
+
problems.push("kind: a kebab-case list name such as newsletter; campaigns are never transactional");
|
|
86
|
+
}
|
|
87
|
+
if (content.subject.trim() === "" || content.subject.length > MAX_SUBJECT_LENGTH || /[\r\n]/.test(content.subject)) {
|
|
88
|
+
problems.push(`subject: one line, 1 to ${String(MAX_SUBJECT_LENGTH)} characters`);
|
|
89
|
+
}
|
|
90
|
+
if (content.text.trim() === "") problems.push("the text body under the frontmatter is empty");
|
|
91
|
+
if (content.html?.trim() === "") problems.push("the HTML body is empty");
|
|
92
|
+
return problems;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The addresses of a recipients file: one per line; blank lines and `#` comments are skipped. */
|
|
96
|
+
export function parseRecipientList(source: string): string[] {
|
|
97
|
+
return source
|
|
98
|
+
.split(/\r?\n/)
|
|
99
|
+
.map((line) => line.trim())
|
|
100
|
+
.filter((line) => line !== "" && !line.startsWith("#"));
|
|
101
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Campaigns: one list mail to many recipients through the delivery ledger. The campaign row pins
|
|
2
|
+
// the content (a hash of kind, subject and bodies), so a re-run with the same id sends the rest of
|
|
3
|
+
// the same mail and nothing twice; other content under that id is refused.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { err, ok, type Result } from "@softure-ai/core";
|
|
6
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
7
|
+
import type { MailingErrorCode } from "../contract.js";
|
|
8
|
+
import { campaigns, deliveries } from "../schema.js";
|
|
9
|
+
import { getCampaignProblems, type CampaignContent } from "./campaign-file.js";
|
|
10
|
+
import { deliverOnce, type DeliverOptions, type DeliveryContext, type DeliveryOutcome } from "./deliveries.js";
|
|
11
|
+
import { isSuppressed } from "./suppressions.js";
|
|
12
|
+
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
13
|
+
|
|
14
|
+
/** Rejections the provider answered (the others never left the process). */
|
|
15
|
+
const PROVIDER_REASONS: ReadonlySet<MailingErrorCode> = new Set(["mailing.rejected", "mailing.unavailable"]);
|
|
16
|
+
|
|
17
|
+
/** The campaign id is taken by other content. Give the new content a new id. */
|
|
18
|
+
export type CampaignErrorCode = "mailing.campaign_changed";
|
|
19
|
+
|
|
20
|
+
export interface CampaignSummary {
|
|
21
|
+
/** Distinct recipients in the list (addresses that differ only in case or spaces count once). */
|
|
22
|
+
readonly recipients: number;
|
|
23
|
+
readonly sent: number;
|
|
24
|
+
/** Rejected in this run, by reason. Suppressed recipients are here, under `mailing.suppressed`. */
|
|
25
|
+
readonly rejected: Readonly<Record<MailingErrorCode, number>>;
|
|
26
|
+
/** Closed by an earlier run; nothing sent. */
|
|
27
|
+
readonly done: number;
|
|
28
|
+
/** Claimed by another run that is still sending. */
|
|
29
|
+
readonly inFlight: number;
|
|
30
|
+
/** The provider was unavailable; the next run tries them again. */
|
|
31
|
+
readonly retryLater: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SendCampaignOptions extends DeliverOptions {
|
|
35
|
+
/** Pause after every mail that reached the provider, for its rate limit. Default 0. */
|
|
36
|
+
readonly pauseMs?: number;
|
|
37
|
+
/** Called after every recipient, e.g. for progress output. Never receives the address. */
|
|
38
|
+
readonly onDelivery?: (outcome: DeliveryOutcome) => void;
|
|
39
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** sha256 (hex) of what a recipient reads: kind, subject, text and HTML. */
|
|
43
|
+
export function getCampaignContentHash(content: CampaignContent): string {
|
|
44
|
+
return createHash("sha256").update(JSON.stringify([content.kind, content.subject, content.text, content.html])).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Records the campaign, or confirms that the stored one has the same content. Throws on a
|
|
49
|
+
* database failure and for content `getCampaignProblems` refuses.
|
|
50
|
+
*/
|
|
51
|
+
export async function registerCampaign(ctx: DeliveryContext, content: CampaignContent): Promise<Result<undefined, CampaignErrorCode>> {
|
|
52
|
+
assertCampaign(content);
|
|
53
|
+
const contentHash = getCampaignContentHash(content);
|
|
54
|
+
await ctx.db
|
|
55
|
+
.insert(campaigns)
|
|
56
|
+
.values({ id: content.id, kind: content.kind, subject: content.subject, contentHash, createdAt: ctx.clock.now() })
|
|
57
|
+
.onConflictDoNothing();
|
|
58
|
+
const rows = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, content.id)).limit(1);
|
|
59
|
+
return rows[0]?.contentHash === contentHash ? ok() : err("mailing.campaign_changed");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sends `campaign` to every recipient that has no outcome yet, one at a time. Recipients who
|
|
64
|
+
* unsubscribed are rejected (`mailing.suppressed`) and never retried. Throws on a database failure.
|
|
65
|
+
*/
|
|
66
|
+
export async function sendCampaign(
|
|
67
|
+
ctx: DeliveryContext,
|
|
68
|
+
input: { readonly campaign: CampaignContent; readonly recipients: Iterable<string> | AsyncIterable<string> },
|
|
69
|
+
options: SendCampaignOptions = {},
|
|
70
|
+
): Promise<Result<CampaignSummary, CampaignErrorCode>> {
|
|
71
|
+
const { pauseMs = 0, onDelivery, sleep = wait, ...deliverOptions } = options;
|
|
72
|
+
const { campaign } = input;
|
|
73
|
+
const registered = await registerCampaign(ctx, campaign);
|
|
74
|
+
if (!registered.ok) return registered;
|
|
75
|
+
|
|
76
|
+
const counts = { sent: 0, done: 0, inFlight: 0, retryLater: 0, rejected: emptyRejections() };
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
const scope = `campaign:${campaign.id}`;
|
|
79
|
+
for await (const address of input.recipients) {
|
|
80
|
+
const recipientKey = getRecipientKey(address);
|
|
81
|
+
if (seen.has(recipientKey)) continue;
|
|
82
|
+
seen.add(recipientKey);
|
|
83
|
+
|
|
84
|
+
const html = campaign.html === null ? {} : { html: campaign.html };
|
|
85
|
+
const outcome = await deliverOnce(
|
|
86
|
+
ctx,
|
|
87
|
+
{ scope, campaignId: campaign.id, mail: { to: address, subject: campaign.subject, text: campaign.text, kind: campaign.kind, ...html } },
|
|
88
|
+
deliverOptions,
|
|
89
|
+
);
|
|
90
|
+
countOutcome(counts, outcome);
|
|
91
|
+
onDelivery?.(outcome);
|
|
92
|
+
const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
|
|
93
|
+
if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
|
|
94
|
+
}
|
|
95
|
+
return ok({ recipients: seen.size, ...counts });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CampaignPlan {
|
|
99
|
+
readonly recipients: number;
|
|
100
|
+
/** Already closed by an earlier run. */
|
|
101
|
+
readonly done: number;
|
|
102
|
+
/** Not closed yet, but unsubscribed: they will be rejected without a send. */
|
|
103
|
+
readonly suppressed: number;
|
|
104
|
+
/** Would be sent now. */
|
|
105
|
+
readonly toSend: number;
|
|
106
|
+
/** The campaign id is stored with other content: a real run would be refused. */
|
|
107
|
+
readonly contentChanged: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What `sendCampaign` would do, without writing or sending anything. Throws on a database failure. */
|
|
111
|
+
export async function planCampaign(
|
|
112
|
+
ctx: DeliveryContext,
|
|
113
|
+
input: { readonly campaign: CampaignContent; readonly recipients: Iterable<string> | AsyncIterable<string> },
|
|
114
|
+
): Promise<CampaignPlan> {
|
|
115
|
+
const { campaign } = input;
|
|
116
|
+
assertCampaign(campaign);
|
|
117
|
+
const stored = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, campaign.id)).limit(1);
|
|
118
|
+
const contentChanged = stored[0] !== undefined && stored[0].contentHash !== getCampaignContentHash(campaign);
|
|
119
|
+
|
|
120
|
+
const addresses = new Map<string, string>();
|
|
121
|
+
for await (const address of input.recipients) {
|
|
122
|
+
const key = getRecipientKey(address);
|
|
123
|
+
if (!addresses.has(key)) addresses.set(key, address);
|
|
124
|
+
}
|
|
125
|
+
const closed = new Set<string>();
|
|
126
|
+
const keys = [...addresses.keys()];
|
|
127
|
+
for (let start = 0; start < keys.length; start += 1_000) {
|
|
128
|
+
const rows = await ctx.db
|
|
129
|
+
.select({ recipientKey: deliveries.recipientKey })
|
|
130
|
+
.from(deliveries)
|
|
131
|
+
.where(and(eq(deliveries.scope, `campaign:${campaign.id}`), inArray(deliveries.status, ["sent", "rejected"]), inArray(deliveries.recipientKey, keys.slice(start, start + 1_000))));
|
|
132
|
+
rows.forEach((row) => closed.add(row.recipientKey));
|
|
133
|
+
}
|
|
134
|
+
let suppressed = 0;
|
|
135
|
+
for (const [key, address] of addresses) {
|
|
136
|
+
if (!closed.has(key) && (await isSuppressed(ctx, address))) suppressed += 1;
|
|
137
|
+
}
|
|
138
|
+
return { recipients: addresses.size, done: closed.size, suppressed, toSend: addresses.size - closed.size - suppressed, contentChanged };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertCampaign(content: CampaignContent): void {
|
|
142
|
+
const problems = getCampaignProblems(content);
|
|
143
|
+
if (problems.length > 0) throw new Error(`@softure-ai/mailing: campaign "${content.id}" cannot be sent: ${problems.join("; ")}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function emptyRejections(): Record<MailingErrorCode, number> {
|
|
147
|
+
return { "mailing.invalid_input": 0, "mailing.rejected": 0, "mailing.unavailable": 0, "mailing.suppressed": 0 };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function countOutcome(
|
|
151
|
+
counts: { sent: number; done: number; inFlight: number; retryLater: number; rejected: Record<MailingErrorCode, number> },
|
|
152
|
+
outcome: DeliveryOutcome,
|
|
153
|
+
): void {
|
|
154
|
+
switch (outcome.status) {
|
|
155
|
+
case "sent":
|
|
156
|
+
counts.sent += 1;
|
|
157
|
+
return;
|
|
158
|
+
case "rejected":
|
|
159
|
+
counts.rejected[outcome.reason] += 1;
|
|
160
|
+
return;
|
|
161
|
+
case "done":
|
|
162
|
+
counts.done += 1;
|
|
163
|
+
return;
|
|
164
|
+
case "in-flight":
|
|
165
|
+
counts.inFlight += 1;
|
|
166
|
+
return;
|
|
167
|
+
case "retry-later":
|
|
168
|
+
counts.retryLater += 1;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function wait(ms: number): Promise<void> {
|
|
174
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
175
|
+
}
|