@pithy-sh/email 0.1.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +55 -0
- package/pithy.manifest.json +73 -0
- package/src/analytics.ts +39 -0
- package/src/audit/actions.ts +48 -0
- package/src/bounce/classify.ts +103 -0
- package/src/bounce/handler.ts +136 -0
- package/src/capability.ts +385 -0
- package/src/cloudflare-test.d.ts +19 -0
- package/src/crypto/signingKey.ts +44 -0
- package/src/crypto/token.ts +148 -0
- package/src/data/emailEvent.ts +42 -0
- package/src/data/emailJob.ts +138 -0
- package/src/data/emailSuppression.ts +40 -0
- package/src/data/enums.ts +75 -0
- package/src/data/tables.ts +47 -0
- package/src/error/errors.ts +129 -0
- package/src/http/callbacks.ts +200 -0
- package/src/http/guards.ts +154 -0
- package/src/http/responses.ts +192 -0
- package/src/http/routes.ts +467 -0
- package/src/http/schemas.ts +203 -0
- package/src/http/view.ts +139 -0
- package/src/index.ts +73 -0
- package/src/jobs/read.ts +273 -0
- package/src/jobs/retry.ts +214 -0
- package/src/migrations/0001_init.ts +174 -0
- package/src/migrations/0001_suppressions.ts +40 -0
- package/src/provision/devDelivery.ts +47 -0
- package/src/provision/hostCatalogs.ts +107 -0
- package/src/provision/provisionEmail.ts +179 -0
- package/src/provision/resolveEmailConfig.ts +225 -0
- package/src/provision/settingsCheck.ts +212 -0
- package/src/send/batchIdentity.ts +47 -0
- package/src/send/enqueue.ts +391 -0
- package/src/send/errorMapping.ts +73 -0
- package/src/send/events.ts +34 -0
- package/src/send/fromComposition.ts +57 -0
- package/src/send/retryPolicy.ts +42 -0
- package/src/send/runSend.ts +320 -0
- package/src/send/sendAt.ts +77 -0
- package/src/send/sender.ts +44 -0
- package/src/send/senderBinding.ts +56 -0
- package/src/send/suppression.ts +194 -0
- package/src/templates/engine.ts +392 -0
- package/src/templates/messages.es.ts +109 -0
- package/src/templates/messages.ts +315 -0
- package/src/templates/partials.ts +88 -0
- package/src/templates/precompiled.generated.ts +1342 -0
- package/src/templates/registry.ts +550 -0
- package/src/templates/samples.ts +75 -0
- package/src/templates/severity.ts +102 -0
- package/src/templates/theme.ts +212 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/hostApp.ts +54 -0
- package/src/workflows/hostEnv.ts +219 -0
- package/src/workflows/instanceLiveness.ts +39 -0
- package/src/workflows/instances.ts +16 -0
- package/src/workflows/params.ts +35 -0
- package/src/workflows/scheduler.ts +220 -0
- package/src/workflows/sendBatch.ts +154 -0
- package/src/workflows/worker.ts +203 -0
- package/src/workflows/wrangler.jsonc +75 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AdminRoute } from "@pithy-sh/core/src/controlPlane/discovery/adminRoute";
|
|
5
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Email's control-plane scopes, and the admin surface a manifest advertises.
|
|
9
|
+
*
|
|
10
|
+
* ## There is no `requireAuth` in this file, and that is deliberate
|
|
11
|
+
*
|
|
12
|
+
* Every other capability copies a local `requireAuth()` because it has an end-user surface to gate.
|
|
13
|
+
* Email has none: the three callback routes are public-plus-signed-token (a recipient tapping a
|
|
14
|
+
* tracking link has no session and must not need one), and everything below is `control-plane`. There
|
|
15
|
+
* is nothing here for an authenticated user to call, so there is no gate to copy.
|
|
16
|
+
*
|
|
17
|
+
* The seam's gate is **imported** from `@pithy-sh/core/src/controlPlane/http/guard` rather than copied.
|
|
18
|
+
* That is not the opposite of the copy rule; it is the same rule. The rule is never to import
|
|
19
|
+
* authorization from a package that might be absent — `@pithy-sh/auth` is optional, so its gate is
|
|
20
|
+
* copied. Core is a hard dependency of every capability there is, so importing its gate cannot leave a
|
|
21
|
+
* deployment without one, and with the seam uncomposed `requireControlPlane` raises
|
|
22
|
+
* `controlplane/not_connected` rather than passing. Both halves fail closed.
|
|
23
|
+
*
|
|
24
|
+
* **`requireAuth()` must never sit on one of these routes.** The seam deliberately leaves `c.var.auth`
|
|
25
|
+
* null so that a management credential cannot satisfy an ordinary `requireAuth()` anywhere in the tree.
|
|
26
|
+
* An auth gate on an admin route would therefore deny every legitimate management call, permanently,
|
|
27
|
+
* and no credential could fix it.
|
|
28
|
+
*
|
|
29
|
+
* ## Five scopes, because these are five different blast radii
|
|
30
|
+
*
|
|
31
|
+
* The temptation is one `email:admin` flag. It is wrong on the merits, and email is the capability
|
|
32
|
+
* where it is most obviously wrong, because the five operations here fail in five unrelated directions:
|
|
33
|
+
*
|
|
34
|
+
* - **Reading jobs** discloses who the adopter mailed and when. A privacy incident.
|
|
35
|
+
* - **Retrying a job** sends real mail to a real person under the adopter's domain and DKIM. Reach
|
|
36
|
+
* outside the adopter's own systems, from a credential that was only ever meant to look.
|
|
37
|
+
* - **Reading suppressions** discloses, in one list, every address in the whole project that ever
|
|
38
|
+
* bounced, complained, or unsubscribed — across every environment, since that database is global.
|
|
39
|
+
* - **Adding a suppression** is a silent, targeted denial of service: block one address and that
|
|
40
|
+
* person never receives another magic link, and nothing anywhere reports an error.
|
|
41
|
+
* - **Removing a suppression** re-opens sending to somebody who reported spam or asked to be left
|
|
42
|
+
* alone. That is a deliverability incident, a reputation hit on the sending domain, and depending on
|
|
43
|
+
* the jurisdiction a compliance one.
|
|
44
|
+
*
|
|
45
|
+
* A tool that retries stuck receipts needs `email:jobs:read` and `email:jobs:retry` and must never
|
|
46
|
+
* hold either suppression write. A deliverability dashboard needs `email:suppressions:read` and
|
|
47
|
+
* nothing else. One flag makes each of those the other. `scopeCovers` matches exactly, with no prefix
|
|
48
|
+
* and no wildcard rule, so holding one of these confers nothing about the rest — `email:jobs:read`
|
|
49
|
+
* does not imply `email:jobs:retry`, and `email:suppressions:write` does not imply the delete.
|
|
50
|
+
*
|
|
51
|
+
* The names are constants rather than config: a configurable scope name is a way to misconfigure a
|
|
52
|
+
* default-denied gate into a differently-named one, and they are the join key with what
|
|
53
|
+
* `pithy dashboard connect` offers an adopter to grant.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the job log — the list and one job in full.
|
|
58
|
+
*
|
|
59
|
+
* The list projects a masked recipient and the detail projects the whole address, which is a bulk-harvest
|
|
60
|
+
* control rather than two permissions: see `view.ts`. Both are the same scope because both are reads of
|
|
61
|
+
* the same table, and splitting them would only produce a credential that could page the log but never
|
|
62
|
+
* diagnose a single row in it.
|
|
63
|
+
*/
|
|
64
|
+
export const EMAIL_JOBS_READ_SCOPE: ControlPlaneScope = "email:jobs:read";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Put a failed job back in the queue. The only operation in this capability that causes mail to be
|
|
68
|
+
* sent, which is why it is granted separately from reading the log it is chosen from.
|
|
69
|
+
*/
|
|
70
|
+
export const EMAIL_JOBS_RETRY_SCOPE: ControlPlaneScope = "email:jobs:retry";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the global suppression list. Its own scope, and not folded into `email:jobs:read`, because that
|
|
74
|
+
* database is shared by every environment: a staging connection reading it sees production's bounces.
|
|
75
|
+
*/
|
|
76
|
+
export const EMAIL_SUPPRESSIONS_READ_SCOPE: ControlPlaneScope = "email:suppressions:read";
|
|
77
|
+
|
|
78
|
+
/** Block an address by hand. Silent to the recipient and effective on the project's next send. */
|
|
79
|
+
export const EMAIL_SUPPRESSIONS_WRITE_SCOPE: ControlPlaneScope = "email:suppressions:write";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Unblock an address. The most dangerous of the five: it undoes a hard bounce, a spam complaint, or a
|
|
83
|
+
* recipient's own opt-out, and the consequences land on the adopter's sending reputation rather than
|
|
84
|
+
* in a response body.
|
|
85
|
+
*/
|
|
86
|
+
export const EMAIL_SUPPRESSIONS_DELETE_SCOPE: ControlPlaneScope = "email:suppressions:delete";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every control-plane scope email defines — what `pithy dashboard connect` offers for this capability,
|
|
90
|
+
* and the list a manifest or a doc quotes rather than re-typing.
|
|
91
|
+
*/
|
|
92
|
+
export const EMAIL_CONTROL_PLANE_SCOPES: readonly ControlPlaneScope[] = [
|
|
93
|
+
EMAIL_JOBS_READ_SCOPE,
|
|
94
|
+
EMAIL_JOBS_RETRY_SCOPE,
|
|
95
|
+
EMAIL_SUPPRESSIONS_READ_SCOPE,
|
|
96
|
+
EMAIL_SUPPRESSIONS_WRITE_SCOPE,
|
|
97
|
+
EMAIL_SUPPRESSIONS_DELETE_SCOPE,
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Email's management surface, as `GET /control-plane/manifest` reports it.
|
|
102
|
+
*
|
|
103
|
+
* Declared beside the scopes so the scope a route demands and the scope a manifest advertises are the
|
|
104
|
+
* same constant, read from one place. `basePath` is a parameter and never a default: an adopter who
|
|
105
|
+
* mounted email at `/mail` must get a manifest naming `/mail/jobs`, or a management client composing
|
|
106
|
+
* its calls from it would 404 against exactly the adopters who customized anything.
|
|
107
|
+
*
|
|
108
|
+
* The callback routes are **not** here. They are a recipient's tracking and unsubscribe links, mounted
|
|
109
|
+
* at a fixed prefix and gated by a signature; they are not management surface and a dashboard has no
|
|
110
|
+
* business calling them.
|
|
111
|
+
*
|
|
112
|
+
* The summaries say what the operation is *for*. A client renders these next to a button somebody is
|
|
113
|
+
* about to press on a real person's mail.
|
|
114
|
+
*/
|
|
115
|
+
export function emailAdminRoutes(basePath: string): AdminRoute[] {
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
method: "GET",
|
|
119
|
+
path: `${basePath}/jobs`,
|
|
120
|
+
scope: EMAIL_JOBS_READ_SCOPE,
|
|
121
|
+
summary: "The send log, newest first, filtered by status. Recipients are masked in the list.",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
method: "GET",
|
|
125
|
+
path: `${basePath}/jobs/:id`,
|
|
126
|
+
scope: EMAIL_JOBS_READ_SCOPE,
|
|
127
|
+
summary: "One job in full — the recipient, the subject, and why it failed. Never the template variables.",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
method: "POST",
|
|
131
|
+
path: `${basePath}/jobs/:id/retry`,
|
|
132
|
+
scope: EMAIL_JOBS_RETRY_SCOPE,
|
|
133
|
+
summary: "Queue a failed job again with a fresh attempt budget. Sends real mail.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
method: "GET",
|
|
137
|
+
path: `${basePath}/suppressions`,
|
|
138
|
+
scope: EMAIL_SUPPRESSIONS_READ_SCOPE,
|
|
139
|
+
summary: "Addresses this project will not send to, and why. Global — every environment shares it.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
method: "POST",
|
|
143
|
+
path: `${basePath}/suppressions`,
|
|
144
|
+
scope: EMAIL_SUPPRESSIONS_WRITE_SCOPE,
|
|
145
|
+
summary: "Block an address by hand, permanently or until a date. Recorded as a manual block.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
method: "POST",
|
|
149
|
+
path: `${basePath}/suppressions/remove`,
|
|
150
|
+
scope: EMAIL_SUPPRESSIONS_DELETE_SCOPE,
|
|
151
|
+
summary: "Unblock an address. Re-opens sending to somebody who bounced, complained, or opted out.",
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { EmailJobStatus, SendMode, SuppressionReason, TemplateCategory } from "../data/enums";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the email management routes return, as Zod objects a management client can validate against.
|
|
9
|
+
*
|
|
10
|
+
* `schemas.ts` bounds what a caller may send; this file states what it gets back. Both halves are
|
|
11
|
+
* runtime values for the same reason: a management client reading a customer's Worker is crossing a
|
|
12
|
+
* trust boundary and must validate what comes back, and a TypeScript interface is erased before it
|
|
13
|
+
* can help. Every client that had only an interface hand-wrote a mirror of these, and the mirror
|
|
14
|
+
* drifted the first time a field landed here.
|
|
15
|
+
*
|
|
16
|
+
* **No codecs, and no transform anywhere in this file.** These describe JSON on the wire, so parsing
|
|
17
|
+
* one hands back exactly what went in — which is what lets `responses.test.ts` compare a parsed value
|
|
18
|
+
* with the projection's output and fail on a field either side forgot.
|
|
19
|
+
*
|
|
20
|
+
* The projections live in `view.ts`, which documents *why* the list masks a recipient and the detail
|
|
21
|
+
* does not, and why `payload` appears in neither. This file is the shape; that file is the argument.
|
|
22
|
+
*
|
|
23
|
+
* **A field added here later is `.optional()`, not merely `.nullable()`.** This module is read across a
|
|
24
|
+
* version boundary — a management client validates a response with this schema against a customer's
|
|
25
|
+
* Worker at whatever kit version it is on — so an additive required key fails `safeParse` for everyone
|
|
26
|
+
* below that release and takes the whole pane with it (#450). Absent then means *this Worker cannot
|
|
27
|
+
* say*, which is a different fact from `null`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Where a page resumes, or the end of the list. */
|
|
31
|
+
const NextCursor = z
|
|
32
|
+
.string()
|
|
33
|
+
.nullable()
|
|
34
|
+
.describe("Where the next page resumes. Null at the end of the list. Opaque — pass it back verbatim.");
|
|
35
|
+
|
|
36
|
+
/** One job as the list shows it: enough to scan, not enough to harvest. */
|
|
37
|
+
export const EmailJobListItem = z
|
|
38
|
+
.object({
|
|
39
|
+
id: z.string().describe("The job id — what the detail and retry routes take."),
|
|
40
|
+
recipient: z
|
|
41
|
+
.string()
|
|
42
|
+
.describe("The recipient, masked (`ad***@example.com`). The detail route has the whole address."),
|
|
43
|
+
template: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe("The template that produced this email. Structural, so it names the kind of mail with no content."),
|
|
46
|
+
category: TemplateCategory.describe("`transactional` or `marketing`."),
|
|
47
|
+
// Says what the tag *is*, and nothing about where it lives: this one declaration is also the
|
|
48
|
+
// detail's, so a sentence placing the field on the list would be false on the route that inherits
|
|
49
|
+
// it. Why the tag earns the list at all is argued in `view.ts`, which is where an argument belongs.
|
|
50
|
+
locale: z
|
|
51
|
+
.string()
|
|
52
|
+
.nullable()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe(
|
|
55
|
+
"The language this message was written in, as a BCP-47 tag; null when the recipient never chose one and it went out in the kit's English, and absent when the Worker that answered predates the field (#450).",
|
|
56
|
+
),
|
|
57
|
+
status: EmailJobStatus.describe("The lifecycle state."),
|
|
58
|
+
mode: SendMode.describe("How the send time was decided."),
|
|
59
|
+
attempts: z.number().int().describe("How many send attempts have been made."),
|
|
60
|
+
campaignId: z.string().nullable().describe("The campaign this belongs to; null for transactional mail."),
|
|
61
|
+
bounceType: z
|
|
62
|
+
.string()
|
|
63
|
+
.nullable()
|
|
64
|
+
.describe(
|
|
65
|
+
"How a bounce was classified (`hard` | `soft` | `complaint` | `auto_reply`), when one arrived. A loose string rather than the enum, because the column is one: a response schema that narrowed what the table admits would reject a row this Worker can legitimately hold, and the place to close that is the column, not the wire.",
|
|
66
|
+
),
|
|
67
|
+
failed: z
|
|
68
|
+
.boolean()
|
|
69
|
+
.describe(
|
|
70
|
+
"Whether the row carries an error, without carrying it — a provider error routinely embeds the recipient, so the text is on the detail route.",
|
|
71
|
+
),
|
|
72
|
+
sendAt: z.iso.datetime().describe("When this job is (or was) due to send, ISO-8601."),
|
|
73
|
+
createdAt: z.iso.datetime().describe("When the row was created, ISO-8601. The list's sort key."),
|
|
74
|
+
sentAt: z.iso.datetime().nullable().describe("When the send succeeded, ISO-8601; null until then."),
|
|
75
|
+
})
|
|
76
|
+
.describe("One email job as the send log lists it. The recipient is masked and no rendered content appears.");
|
|
77
|
+
export type EmailJobListItem = z.output<typeof EmailJobListItem>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One job in full.
|
|
81
|
+
*
|
|
82
|
+
* Re-described after `.omit()`/`.extend()`: each builds a new schema, and a description does not
|
|
83
|
+
* follow it.
|
|
84
|
+
*
|
|
85
|
+
* `locale` is **not** re-declared below. It is on the list item, so the detail inherits it and there is
|
|
86
|
+
* one sentence describing the tag rather than two free to drift — and the fact the detail's own copy
|
|
87
|
+
* used to carry, that null means nobody chose, is the fact the list needs most.
|
|
88
|
+
*/
|
|
89
|
+
export const EmailJobDetail = EmailJobListItem.omit({ recipient: true, failed: true })
|
|
90
|
+
.extend({
|
|
91
|
+
toAddress: z.string().describe("The recipient, in full. Disclosed one job at a time, and audited as such."),
|
|
92
|
+
subject: z
|
|
93
|
+
.string()
|
|
94
|
+
.describe("The rendered subject line — the one piece of rendered content a send cannot be diagnosed without."),
|
|
95
|
+
fromAddress: z.string().describe("The sending identity — the adopter's own configuration."),
|
|
96
|
+
fromName: z.string().describe("The sender display name recipients saw."),
|
|
97
|
+
messageId: z.string().nullable().describe("The Email Service message id a later bounce is attributed through."),
|
|
98
|
+
error: z.string().nullable().describe("The last error recorded against this job; null when healthy."),
|
|
99
|
+
bounceCode: z.string().nullable().describe("The SMTP or Email Service code from a bounce; null unless it bounced."),
|
|
100
|
+
timezone: z.string().nullable().describe("The recipient's IANA timezone, for a `timezone`-mode send."),
|
|
101
|
+
localTime: z.string().nullable().describe("The recipient-local time-of-day, for a `timezone`-mode send."),
|
|
102
|
+
openTracking: z.boolean().describe("Whether an open-tracking pixel was injected."),
|
|
103
|
+
clickTracking: z.boolean().describe("Whether links were rewritten to tracked callbacks."),
|
|
104
|
+
updatedAt: z.iso.datetime().describe("When the row was last written, ISO-8601."),
|
|
105
|
+
})
|
|
106
|
+
.describe("One email job in full. Never the template payload — that is the sign-in link and the OTP.");
|
|
107
|
+
export type EmailJobDetail = z.output<typeof EmailJobDetail>;
|
|
108
|
+
|
|
109
|
+
/** One suppressed address, as a management client sees it. */
|
|
110
|
+
export const EmailSuppressionView = z
|
|
111
|
+
.object({
|
|
112
|
+
id: z.number().int().describe("The row's surrogate key — the handle the audit trail names."),
|
|
113
|
+
email: z.string().describe("The blocked address, in full. The record is the address, so there is nothing to mask."),
|
|
114
|
+
reason: SuppressionReason.describe("Why it is blocked: hard bounce, complaint, unsubscribe, or a manual block."),
|
|
115
|
+
environment: z
|
|
116
|
+
.string()
|
|
117
|
+
.nullable()
|
|
118
|
+
.describe("The environment the triggering job came from; the block itself applies everywhere."),
|
|
119
|
+
jobId: z.string().nullable().describe("The job that triggered it, when one did."),
|
|
120
|
+
detail: z.string().nullable().describe("Free-form context — the bounce code, or what an operator typed."),
|
|
121
|
+
createdAt: z.iso.datetime().describe("When the address was blocked, ISO-8601."),
|
|
122
|
+
expiresAt: z.iso.datetime().nullable().describe("When a temporary block lifts, ISO-8601; null when permanent."),
|
|
123
|
+
active: z
|
|
124
|
+
.boolean()
|
|
125
|
+
.describe(
|
|
126
|
+
"Whether the block is in force right now. Computed here, so no client re-implements the comparison the send path already makes.",
|
|
127
|
+
),
|
|
128
|
+
})
|
|
129
|
+
.describe("One suppressed address, with the block's provenance and whether it is in force.");
|
|
130
|
+
export type EmailSuppressionView = z.output<typeof EmailSuppressionView>;
|
|
131
|
+
|
|
132
|
+
/** `GET {base}/jobs`. */
|
|
133
|
+
export const EmailJobsResponse = z
|
|
134
|
+
.object({
|
|
135
|
+
jobs: z.array(EmailJobListItem).describe("The page, newest first."),
|
|
136
|
+
nextCursor: NextCursor,
|
|
137
|
+
})
|
|
138
|
+
.describe("A page of the send log.");
|
|
139
|
+
export type EmailJobsResponse = z.output<typeof EmailJobsResponse>;
|
|
140
|
+
|
|
141
|
+
/** `GET {base}/jobs/:id`. */
|
|
142
|
+
export const EmailJobResponse = z
|
|
143
|
+
.object({ job: EmailJobDetail.describe("The job, whole recipient included.") })
|
|
144
|
+
.describe("One email job in full.");
|
|
145
|
+
export type EmailJobResponse = z.output<typeof EmailJobResponse>;
|
|
146
|
+
|
|
147
|
+
/** `POST {base}/jobs/:id/retry`. */
|
|
148
|
+
export const EmailJobRetryResponse = z
|
|
149
|
+
.object({
|
|
150
|
+
job: EmailJobDetail.describe("The job as the retry left it."),
|
|
151
|
+
dispatched: z
|
|
152
|
+
.boolean()
|
|
153
|
+
.describe(
|
|
154
|
+
"Whether a send Workflow actually started. False when no sender is bound — the row is queued, and nothing was mailed.",
|
|
155
|
+
),
|
|
156
|
+
})
|
|
157
|
+
.describe("The retried job, and whether a send actually started.");
|
|
158
|
+
export type EmailJobRetryResponse = z.output<typeof EmailJobRetryResponse>;
|
|
159
|
+
|
|
160
|
+
/** `GET {base}/suppressions`. */
|
|
161
|
+
export const EmailSuppressionsResponse = z
|
|
162
|
+
.object({
|
|
163
|
+
suppressions: z.array(EmailSuppressionView).describe("The page, newest first."),
|
|
164
|
+
nextCursor: NextCursor,
|
|
165
|
+
})
|
|
166
|
+
.describe("A page of the suppression list.");
|
|
167
|
+
export type EmailSuppressionsResponse = z.output<typeof EmailSuppressionsResponse>;
|
|
168
|
+
|
|
169
|
+
/** `POST {base}/suppressions`. */
|
|
170
|
+
export const EmailSuppressResponse = z
|
|
171
|
+
.object({
|
|
172
|
+
suppression: EmailSuppressionView.nullable().describe(
|
|
173
|
+
"The row as stored, read back so the response is the truth rather than the request echoed. Null only if the read-back found nothing.",
|
|
174
|
+
),
|
|
175
|
+
})
|
|
176
|
+
.describe("The address as the suppression list now holds it.");
|
|
177
|
+
export type EmailSuppressResponse = z.output<typeof EmailSuppressResponse>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `POST {base}/suppressions/remove`.
|
|
181
|
+
*
|
|
182
|
+
* The address is echoed because the caller sent it, normalized so the client learns which row was
|
|
183
|
+
* addressed rather than guessing at the lowercasing. Nothing about the block that was lifted appears:
|
|
184
|
+
* that is in the audit trail, where reading it needs its own grant.
|
|
185
|
+
*/
|
|
186
|
+
export const EmailUnsuppressResponse = z
|
|
187
|
+
.object({
|
|
188
|
+
email: z.string().describe("The address, normalized the way the suppression list stores it."),
|
|
189
|
+
removed: z.boolean().describe("Whether a row was removed. False when the address was not on the list."),
|
|
190
|
+
})
|
|
191
|
+
.describe("Which address was unblocked, and whether there was anything to unblock.");
|
|
192
|
+
export type EmailUnsuppressResponse = z.output<typeof EmailUnsuppressResponse>;
|