@benclmnt/postmock 0.0.1
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 +251 -0
- package/dist/seeds/conformance/00-core.d.ts +4 -0
- package/dist/seeds/conformance/00-core.js +12 -0
- package/dist/seeds/conformance/20-bounces.d.ts +3 -0
- package/dist/seeds/conformance/20-bounces.js +108 -0
- package/dist/seeds/conformance/40-messages.d.ts +3 -0
- package/dist/seeds/conformance/40-messages.js +144 -0
- package/dist/seeds/conformance/50-server-color.d.ts +8 -0
- package/dist/seeds/conformance/50-server-color.js +13 -0
- package/dist/seeds/conformance/70-account.d.ts +9 -0
- package/dist/seeds/conformance/70-account.js +29 -0
- package/dist/seeds/conformance/80-templates.d.ts +4 -0
- package/dist/seeds/conformance/80-templates.js +23 -0
- package/dist/seeds/conformance.d.ts +2 -0
- package/dist/seeds/conformance.js +3 -0
- package/dist/seeds/empty.d.ts +4 -0
- package/dist/seeds/empty.js +3 -0
- package/dist/seeds/lib/conformance.d.ts +16 -0
- package/dist/seeds/lib/conformance.js +18 -0
- package/dist/seeds/lib/history.d.ts +27 -0
- package/dist/seeds/lib/history.js +129 -0
- package/dist/seeds/lib/read-server.d.ts +10 -0
- package/dist/seeds/lib/read-server.js +10 -0
- package/dist/seeds/lib/template-server.d.ts +13 -0
- package/dist/seeds/lib/template-server.js +13 -0
- package/dist/src/api/account/authentication.d.ts +39 -0
- package/dist/src/api/account/authentication.js +97 -0
- package/dist/src/api/account/domains.d.ts +59 -0
- package/dist/src/api/account/domains.js +78 -0
- package/dist/src/api/account/paging.d.ts +12 -0
- package/dist/src/api/account/paging.js +35 -0
- package/dist/src/api/account/push.d.ts +14 -0
- package/dist/src/api/account/push.js +84 -0
- package/dist/src/api/account/routes.d.ts +1 -0
- package/dist/src/api/account/routes.js +288 -0
- package/dist/src/api/account/senders.d.ts +54 -0
- package/dist/src/api/account/senders.js +87 -0
- package/dist/src/api/account/servers.d.ts +40 -0
- package/dist/src/api/account/servers.js +148 -0
- package/dist/src/api/bounces/json.d.ts +25 -0
- package/dist/src/api/bounces/json.js +27 -0
- package/dist/src/api/bounces/routes.d.ts +1 -0
- package/dist/src/api/bounces/routes.js +127 -0
- package/dist/src/api/bulk/bulk.d.ts +43 -0
- package/dist/src/api/bulk/bulk.js +81 -0
- package/dist/src/api/bulk/routes.d.ts +1 -0
- package/dist/src/api/bulk/routes.js +252 -0
- package/dist/src/api/data-removals/routes.d.ts +1 -0
- package/dist/src/api/data-removals/routes.js +59 -0
- package/dist/src/api/email/json.d.ts +11 -0
- package/dist/src/api/email/json.js +33 -0
- package/dist/src/api/email/routes.d.ts +1 -0
- package/dist/src/api/email/routes.js +64 -0
- package/dist/src/api/inbound/routes.d.ts +1 -0
- package/dist/src/api/inbound/routes.js +40 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +3 -0
- package/dist/src/api/message-streams/routes.d.ts +1 -0
- package/dist/src/api/message-streams/routes.js +211 -0
- package/dist/src/api/message-streams/streams.d.ts +28 -0
- package/dist/src/api/message-streams/streams.js +50 -0
- package/dist/src/api/messages/json.d.ts +149 -0
- package/dist/src/api/messages/json.js +114 -0
- package/dist/src/api/messages/query.d.ts +19 -0
- package/dist/src/api/messages/query.js +64 -0
- package/dist/src/api/messages/routes.d.ts +1 -0
- package/dist/src/api/messages/routes.js +203 -0
- package/dist/src/api/server/json.d.ts +26 -0
- package/dist/src/api/server/json.js +25 -0
- package/dist/src/api/server/routes.d.ts +1 -0
- package/dist/src/api/server/routes.js +17 -0
- package/dist/src/api/stats/aggregate.d.ts +58 -0
- package/dist/src/api/stats/aggregate.js +66 -0
- package/dist/src/api/stats/routes.d.ts +1 -0
- package/dist/src/api/stats/routes.js +72 -0
- package/dist/src/api/suppressions/routes.d.ts +1 -0
- package/dist/src/api/suppressions/routes.js +98 -0
- package/dist/src/api/templates/content.d.ts +21 -0
- package/dist/src/api/templates/content.js +39 -0
- package/dist/src/api/templates/routes.d.ts +1 -0
- package/dist/src/api/templates/routes.js +164 -0
- package/dist/src/api/templates/send.d.ts +13 -0
- package/dist/src/api/templates/send.js +105 -0
- package/dist/src/api/templates/templates.d.ts +100 -0
- package/dist/src/api/templates/templates.js +218 -0
- package/dist/src/api/templates/validate.d.ts +15 -0
- package/dist/src/api/templates/validate.js +77 -0
- package/dist/src/api/triggers/routes.d.ts +1 -0
- package/dist/src/api/triggers/routes.js +62 -0
- package/dist/src/api/webhooks/routes.d.ts +14 -0
- package/dist/src/api/webhooks/routes.js +192 -0
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +95 -0
- package/dist/src/control/app.d.ts +5 -0
- package/dist/src/control/app.js +42 -0
- package/dist/src/control/endpoints/account.d.ts +1 -0
- package/dist/src/control/endpoints/account.js +68 -0
- package/dist/src/control/endpoints/bulk.d.ts +1 -0
- package/dist/src/control/endpoints/bulk.js +29 -0
- package/dist/src/control/endpoints/core.d.ts +1 -0
- package/dist/src/control/endpoints/core.js +104 -0
- package/dist/src/control/endpoints/inbound.d.ts +1 -0
- package/dist/src/control/endpoints/inbound.js +61 -0
- package/dist/src/control/endpoints/recipients.d.ts +1 -0
- package/dist/src/control/endpoints/recipients.js +136 -0
- package/dist/src/control/endpoints/smtp.d.ts +1 -0
- package/dist/src/control/endpoints/smtp.js +74 -0
- package/dist/src/control/endpoints/tracking.d.ts +1 -0
- package/dist/src/control/endpoints/tracking.js +95 -0
- package/dist/src/control/endpoints/webhooks.d.ts +1 -0
- package/dist/src/control/endpoints/webhooks.js +36 -0
- package/dist/src/control/index.d.ts +1 -0
- package/dist/src/control/index.js +3 -0
- package/dist/src/control/registry.d.ts +25 -0
- package/dist/src/control/registry.js +19 -0
- package/dist/src/control/seed.d.ts +11 -0
- package/dist/src/control/seed.js +32 -0
- package/dist/src/control/seeding.d.ts +10 -0
- package/dist/src/control/seeding.js +27 -0
- package/dist/src/discover.d.ts +16 -0
- package/dist/src/discover.js +49 -0
- package/dist/src/errors.d.ts +49 -0
- package/dist/src/errors.js +701 -0
- package/dist/src/events.d.ts +49 -0
- package/dist/src/events.js +19 -0
- package/dist/src/http/app.d.ts +8 -0
- package/dist/src/http/app.js +51 -0
- package/dist/src/http/auth.d.ts +7 -0
- package/dist/src/http/auth.js +32 -0
- package/dist/src/http/faults.d.ts +8 -0
- package/dist/src/http/faults.js +33 -0
- package/dist/src/http/normalize.d.ts +57 -0
- package/dist/src/http/normalize.js +225 -0
- package/dist/src/http/respond.d.ts +23 -0
- package/dist/src/http/respond.js +37 -0
- package/dist/src/http/routes.d.ts +60 -0
- package/dist/src/http/routes.js +64 -0
- package/dist/src/inbound/deliver.d.ts +9 -0
- package/dist/src/inbound/deliver.js +34 -0
- package/dist/src/inbound/parse.d.ts +21 -0
- package/dist/src/inbound/parse.js +71 -0
- package/dist/src/inbound/receive.d.ts +17 -0
- package/dist/src/inbound/receive.js +89 -0
- package/dist/src/main.d.ts +2 -0
- package/dist/src/main.js +14 -0
- package/dist/src/mime/compose.d.ts +33 -0
- package/dist/src/mime/compose.js +58 -0
- package/dist/src/pipeline/addresses.d.ts +6 -0
- package/dist/src/pipeline/addresses.js +38 -0
- package/dist/src/pipeline/inactive.d.ts +8 -0
- package/dist/src/pipeline/inactive.js +20 -0
- package/dist/src/pipeline/submit.d.ts +138 -0
- package/dist/src/pipeline/submit.js +415 -0
- package/dist/src/plugins/message-events.d.ts +7 -0
- package/dist/src/plugins/message-events.js +76 -0
- package/dist/src/plugins/smtp.d.ts +15 -0
- package/dist/src/plugins/smtp.js +42 -0
- package/dist/src/plugins/stats.d.ts +7 -0
- package/dist/src/plugins/stats.js +78 -0
- package/dist/src/plugins/webhooks.d.ts +4 -0
- package/dist/src/plugins/webhooks.js +80 -0
- package/dist/src/plugins.d.ts +20 -0
- package/dist/src/plugins.js +9 -0
- package/dist/src/recipients/test-bounces.d.ts +15 -0
- package/dist/src/recipients/test-bounces.js +31 -0
- package/dist/src/recipients/transitions.d.ts +57 -0
- package/dist/src/recipients/transitions.js +279 -0
- package/dist/src/render/mustachio.d.ts +60 -0
- package/dist/src/render/mustachio.js +265 -0
- package/dist/src/runtime.d.ts +12 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/server.d.ts +25 -0
- package/dist/src/server.js +49 -0
- package/dist/src/smtp/auth.d.ts +35 -0
- package/dist/src/smtp/auth.js +66 -0
- package/dist/src/smtp/listener.d.ts +16 -0
- package/dist/src/smtp/listener.js +121 -0
- package/dist/src/smtp/mime.d.ts +36 -0
- package/dist/src/smtp/mime.js +181 -0
- package/dist/src/smtp/receive.d.ts +21 -0
- package/dist/src/smtp/receive.js +89 -0
- package/dist/src/smtp/reply.d.ts +14 -0
- package/dist/src/smtp/reply.js +24 -0
- package/dist/src/state/clock.d.ts +35 -0
- package/dist/src/state/clock.js +107 -0
- package/dist/src/state/ids.d.ts +6 -0
- package/dist/src/state/ids.js +7 -0
- package/dist/src/state/servers.d.ts +27 -0
- package/dist/src/state/servers.js +101 -0
- package/dist/src/state/store.d.ts +57 -0
- package/dist/src/state/store.js +98 -0
- package/dist/src/state/suppressions.d.ts +9 -0
- package/dist/src/state/suppressions.js +8 -0
- package/dist/src/state/types.d.ts +530 -0
- package/dist/src/state/types.js +41 -0
- package/dist/src/time.d.ts +11 -0
- package/dist/src/time.js +48 -0
- package/dist/src/tracking.d.ts +53 -0
- package/dist/src/tracking.js +150 -0
- package/dist/src/webhooks/deliver.d.ts +60 -0
- package/dist/src/webhooks/deliver.js +172 -0
- package/dist/src/webhooks/egress.d.ts +19 -0
- package/dist/src/webhooks/egress.js +40 -0
- package/dist/src/webhooks/outbound.d.ts +26 -0
- package/dist/src/webhooks/outbound.js +47 -0
- package/dist/src/webhooks/payloads.d.ts +111 -0
- package/dist/src/webhooks/payloads.js +128 -0
- package/package.json +55 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ApiError, apiError } from "../../errors.js";
|
|
3
|
+
import { absent, parseBody, queryInt } from "../../http/normalize.js";
|
|
4
|
+
import { Unsupported } from "../../http/respond.js";
|
|
5
|
+
import { defineRoute } from "../../http/routes.js";
|
|
6
|
+
import { draftFromJson, refuseSenderAfterDataError, validateOutbound, } from "../../pipeline/submit.js";
|
|
7
|
+
import { newMessageId } from "../../state/ids.js";
|
|
8
|
+
import { findStream } from "../../state/servers.js";
|
|
9
|
+
import { formatTimestamp } from "../../time.js";
|
|
10
|
+
import { renderContent } from "../templates/content.js";
|
|
11
|
+
import { invalidField, nullable, sendTemplate, templateIdField, templateModelField, } from "../templates/templates.js";
|
|
12
|
+
import { bulkStatusJson, bulkSubmission, scheduleBulk } from "./bulk.js";
|
|
13
|
+
// Bulk API (docs/03 §1.6; refs/api_bulk-email.md). Not in the Swagger spec.
|
|
14
|
+
/** 422 / 14 on all three endpoints until the account is approved (refs/api_bulk-email.md:411-420). */
|
|
15
|
+
function requireBulkApproval(state) {
|
|
16
|
+
if (!state.account.bulkApiEnabled) {
|
|
17
|
+
throw apiError(14, {
|
|
18
|
+
message: "This endpoint requires approval to access. Contact support to use the Bulk API postmarkapp.com/contact",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const header = z.object({ Name: z.string(), Value: z.unknown().optional() });
|
|
23
|
+
const metadata = nullable(z.record(z.string(), z.unknown()));
|
|
24
|
+
const headers = nullable(z.array(header));
|
|
25
|
+
const passed = z.unknown().optional();
|
|
26
|
+
const bulkMessage = z.object({
|
|
27
|
+
To: passed,
|
|
28
|
+
Cc: passed,
|
|
29
|
+
Bcc: passed,
|
|
30
|
+
TemplateModel: templateModelField,
|
|
31
|
+
Metadata: metadata,
|
|
32
|
+
Headers: headers,
|
|
33
|
+
});
|
|
34
|
+
const bulkBody = z.object({
|
|
35
|
+
From: passed,
|
|
36
|
+
ReplyTo: passed,
|
|
37
|
+
Subject: absent(z.string()),
|
|
38
|
+
HtmlBody: absent(z.string()),
|
|
39
|
+
TextBody: absent(z.string()),
|
|
40
|
+
TemplateId: templateIdField,
|
|
41
|
+
TemplateAlias: absent(z.string()),
|
|
42
|
+
InlineCss: absent(z.boolean()),
|
|
43
|
+
Tag: passed,
|
|
44
|
+
Metadata: metadata,
|
|
45
|
+
MessageStream: absent(z.string()),
|
|
46
|
+
TrackOpens: passed,
|
|
47
|
+
TrackLinks: passed,
|
|
48
|
+
Attachments: passed,
|
|
49
|
+
Headers: headers,
|
|
50
|
+
Messages: z.array(bulkMessage),
|
|
51
|
+
});
|
|
52
|
+
function mergeHeaders(request, message) {
|
|
53
|
+
if (request === undefined)
|
|
54
|
+
return message;
|
|
55
|
+
const names = new Set((message ?? []).map((h) => h.Name.toLowerCase()));
|
|
56
|
+
return [...request.filter((h) => !names.has(h.Name.toLowerCase())), ...(message ?? [])];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Validates every message before any is accepted: one malformed field rejects the request with its
|
|
60
|
+
* own code, several with ErrorCode 11 and an `Errors` map keyed by field (refs/api_bulk-email.md:185,
|
|
61
|
+
* :213-231, :406-407). A message that fails to render is checked with its source content, so the
|
|
62
|
+
* render failure stays a `FailedCount` (refs/api_bulk-email.md:308).
|
|
63
|
+
*/
|
|
64
|
+
function checkMessages(ctx, auth, jobs, source) {
|
|
65
|
+
const errors = new Map();
|
|
66
|
+
for (const job of jobs) {
|
|
67
|
+
const draft = job.rendered.ok ? job.draft : withContent(job.draft, source);
|
|
68
|
+
const validation = validateOutbound(ctx, bulkSubmission(auth, draft, job.request, null, null));
|
|
69
|
+
if (validation.outcome === "valid")
|
|
70
|
+
continue;
|
|
71
|
+
const { field, error } = validation;
|
|
72
|
+
errors.set(`${field}/${error.ErrorCode}/${error.Message}`, { field, error });
|
|
73
|
+
}
|
|
74
|
+
const found = [...errors.values()];
|
|
75
|
+
const [only] = found;
|
|
76
|
+
if (only === undefined)
|
|
77
|
+
return;
|
|
78
|
+
// Every send rejection is HTTP 422 (docs/03 §3.1).
|
|
79
|
+
if (found.length === 1)
|
|
80
|
+
throw new ApiError(422, only.error);
|
|
81
|
+
const Errors = {};
|
|
82
|
+
for (const { field, error } of found)
|
|
83
|
+
Errors[field] = [...(Errors[field] ?? []), error];
|
|
84
|
+
throw apiError(11, {
|
|
85
|
+
message: "Multiple errors occurred. Inspect the Errors property for more information.",
|
|
86
|
+
extra: { Errors },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const withContent = (draft, content) => ({
|
|
90
|
+
...draft,
|
|
91
|
+
Subject: content.Subject ?? undefined,
|
|
92
|
+
HtmlBody: content.HtmlBody ?? undefined,
|
|
93
|
+
TextBody: content.TextBody ?? undefined,
|
|
94
|
+
});
|
|
95
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
|
+
/** The request-level checks: body fields, template, stream. */
|
|
97
|
+
function checkRequest(state, auth, body) {
|
|
98
|
+
const parsed = parseBody(bulkBody, body ?? {});
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
const path = parsed.error.issues[0]?.path ?? [];
|
|
101
|
+
throw invalidField(String(path.findLast((p) => typeof p === "string")));
|
|
102
|
+
}
|
|
103
|
+
const input = parsed.data;
|
|
104
|
+
if (input.Messages.length === 0)
|
|
105
|
+
throw new Unsupported("a bulk request with no Messages");
|
|
106
|
+
const templated = input.TemplateId !== undefined || input.TemplateAlias !== undefined;
|
|
107
|
+
let content = {
|
|
108
|
+
Subject: input.Subject ?? null,
|
|
109
|
+
HtmlBody: input.HtmlBody ?? null,
|
|
110
|
+
TextBody: input.TextBody ?? null,
|
|
111
|
+
};
|
|
112
|
+
let layout = null;
|
|
113
|
+
let templateId = null;
|
|
114
|
+
if (templated) {
|
|
115
|
+
// Content beside a template: 1123, as on /email/withTemplate (INFERRED).
|
|
116
|
+
if (Object.values(content).some((v) => v !== null)) {
|
|
117
|
+
throw apiError(1123, {
|
|
118
|
+
message: "Subject, HtmlBody and TextBody cannot be used when sending with a template.",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const sent = sendTemplate(state, auth.server.ID, input);
|
|
122
|
+
content = sent.template;
|
|
123
|
+
templateId = sent.template.TemplateId;
|
|
124
|
+
layout = sent.layout;
|
|
125
|
+
}
|
|
126
|
+
// Absent: the default broadcast stream (refs/api_bulk-email.md:81; docs/04 Q7 for its ID).
|
|
127
|
+
const streamId = input.MessageStream ?? "broadcast";
|
|
128
|
+
const stream = findStream(state, auth, streamId);
|
|
129
|
+
if (stream === undefined)
|
|
130
|
+
throw apiError(1226, { family: "streams" });
|
|
131
|
+
if (stream.MessageStreamType !== "Broadcasts") {
|
|
132
|
+
throw new Unsupported(`bulk sends on a ${stream.MessageStreamType} stream are not captured`);
|
|
133
|
+
}
|
|
134
|
+
return { input, content, layout, templateId, streamId };
|
|
135
|
+
}
|
|
136
|
+
defineRoute({
|
|
137
|
+
method: "POST",
|
|
138
|
+
path: "/email/bulk",
|
|
139
|
+
auth: "server",
|
|
140
|
+
handler: async (ctx) => {
|
|
141
|
+
const { store, auth } = ctx;
|
|
142
|
+
const { state } = store;
|
|
143
|
+
requireBulkApproval(state);
|
|
144
|
+
let request;
|
|
145
|
+
try {
|
|
146
|
+
request = checkRequest(state, auth, ctx.body);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof ApiError && isObject(ctx.body)) {
|
|
150
|
+
refuseSenderAfterDataError(ctx, auth, draftFromJson(ctx.body).From);
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const { input, content, layout, templateId, streamId } = request;
|
|
155
|
+
// Body keys arrive in any case (docs/08 R8); `request` keeps each entry as sent.
|
|
156
|
+
const rawMessages = Object.entries(ctx.body).find(([key]) => key.toLowerCase() === "messages")?.[1];
|
|
157
|
+
const jobs = input.Messages.map((message, i) => {
|
|
158
|
+
// Message-level metadata keys win over request-level keys (INFERRED per key).
|
|
159
|
+
const Metadata = input.Metadata === undefined && message.Metadata === undefined
|
|
160
|
+
? undefined
|
|
161
|
+
: { ...input.Metadata, ...message.Metadata };
|
|
162
|
+
const rendered = renderContent(content, layout, message.TemplateModel ?? {}, input.InlineCss ?? true);
|
|
163
|
+
// Keys are canonical here; `draftFromJson` keeps one folding rule for every JSON draft.
|
|
164
|
+
const draft = draftFromJson({
|
|
165
|
+
From: input.From,
|
|
166
|
+
To: message.To,
|
|
167
|
+
Cc: message.Cc,
|
|
168
|
+
Bcc: message.Bcc,
|
|
169
|
+
ReplyTo: input.ReplyTo,
|
|
170
|
+
Tag: input.Tag,
|
|
171
|
+
MessageStream: streamId,
|
|
172
|
+
Headers: mergeHeaders(input.Headers, message.Headers),
|
|
173
|
+
Attachments: input.Attachments,
|
|
174
|
+
Metadata,
|
|
175
|
+
TrackOpens: input.TrackOpens,
|
|
176
|
+
TrackLinks: input.TrackLinks,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
draft: rendered.ok ? withContent(draft, rendered.content) : draft,
|
|
180
|
+
rendered,
|
|
181
|
+
request: rawMessages[i],
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
checkMessages(ctx, auth, jobs, content);
|
|
185
|
+
const bulk = {
|
|
186
|
+
Id: newMessageId(),
|
|
187
|
+
ServerID: auth.server.ID,
|
|
188
|
+
SubmittedAt: ctx.clock.now(),
|
|
189
|
+
Status: "Accepted",
|
|
190
|
+
TotalMessages: jobs.length,
|
|
191
|
+
PercentageCompleted: 0,
|
|
192
|
+
ReleasedCount: 0,
|
|
193
|
+
FailedCount: 0,
|
|
194
|
+
// A templated request has no request Subject, so the key is omitted (INFERRED).
|
|
195
|
+
Subject: input.Subject ?? null,
|
|
196
|
+
messageIds: [],
|
|
197
|
+
unsupported: null,
|
|
198
|
+
};
|
|
199
|
+
state.bulkRequests.set(bulk.Id, bulk);
|
|
200
|
+
scheduleBulk(ctx, auth, bulk, jobs, templateId);
|
|
201
|
+
// The dotnet live test reads TotalMessages from the POST answer, so it is the full status
|
|
202
|
+
// object (sdk/postmark-dotnet/src/Postmark.Tests/ClientBulkSendingTests.cs:51-54).
|
|
203
|
+
return bulkStatusJson(bulk);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
/** Another server's request is 404 / 12 like an unknown one (refs/api_bulk-email.md:427-429). */
|
|
207
|
+
defineRoute({
|
|
208
|
+
method: "GET",
|
|
209
|
+
path: "/email/bulk/:id",
|
|
210
|
+
auth: "server",
|
|
211
|
+
handler: ({ store, params, auth }) => {
|
|
212
|
+
requireBulkApproval(store.state);
|
|
213
|
+
const bulk = store.state.bulkRequests.get(params.id ?? "");
|
|
214
|
+
if (bulk === undefined || bulk.ServerID !== auth.server.ID)
|
|
215
|
+
throw apiError(12);
|
|
216
|
+
return bulkStatusJson(bulk);
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
// PaginationKey: unpadded base64 of the next page's first request, like the doc example
|
|
220
|
+
// `{"SubmittedAt":…}` (refs/api_bulk-email.md:380, :395). postmock adds its Id and uses the URL-safe
|
|
221
|
+
// alphabet, so the key needs no escaping in a query string (INFERRED).
|
|
222
|
+
const encodeKey = (bulk) => Buffer.from(JSON.stringify({ SubmittedAt: formatTimestamp(bulk.SubmittedAt, "utc"), Id: bulk.Id })).toString("base64url");
|
|
223
|
+
defineRoute({
|
|
224
|
+
method: "GET",
|
|
225
|
+
path: "/email/bulk",
|
|
226
|
+
auth: "server",
|
|
227
|
+
handler: ({ store, query, auth }) => {
|
|
228
|
+
requireBulkApproval(store.state);
|
|
229
|
+
const paging = query.pick(z.object({ count: queryInt }));
|
|
230
|
+
// docs/03 Q20: the count default and range are not documented.
|
|
231
|
+
if (!paging.success || paging.data.count < 1 || paging.data.count > 500) {
|
|
232
|
+
throw new Unsupported("GET /email/bulk needs a count from 1 to 500; other values are not captured");
|
|
233
|
+
}
|
|
234
|
+
const requests = [...store.state.bulkRequests.values()]
|
|
235
|
+
.filter((b) => b.ServerID === auth.server.ID)
|
|
236
|
+
.reverse()
|
|
237
|
+
.sort((a, b) => b.SubmittedAt.getTime() - a.SubmittedAt.getTime());
|
|
238
|
+
const key = query.get("paginationKey");
|
|
239
|
+
let start = 0;
|
|
240
|
+
if (key !== undefined) {
|
|
241
|
+
start = requests.findIndex((b) => encodeKey(b) === key);
|
|
242
|
+
if (start === -1)
|
|
243
|
+
throw apiError(13);
|
|
244
|
+
}
|
|
245
|
+
const page = requests.slice(start, start + paging.data.count);
|
|
246
|
+
const next = requests[start + paging.data.count];
|
|
247
|
+
return {
|
|
248
|
+
Requests: page.map(bulkStatusJson),
|
|
249
|
+
...(next !== undefined && { PaginationKey: encodeKey(next) }),
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError } from "../../errors.js";
|
|
3
|
+
import { parseBody } from "../../http/normalize.js";
|
|
4
|
+
import { Unsupported } from "../../http/respond.js";
|
|
5
|
+
import { defineRoute } from "../../http/routes.js";
|
|
6
|
+
import { isEmailAddress } from "../../recipients/transitions.js";
|
|
7
|
+
// Data Removals API: docs/04 §5. A request stays Pending: the time to Done and what it erases are
|
|
8
|
+
// not captured (Q16). The 1300 and 1301 texts are INFERRED from their summary rows.
|
|
9
|
+
const EMPTY = "Empty request.";
|
|
10
|
+
const NOT_FOUND = "Missing or incorrect data removal request ID.";
|
|
11
|
+
const json = (r) => ({ ID: r.ID, Status: r.Status });
|
|
12
|
+
function checkAccess(enabled) {
|
|
13
|
+
if (!enabled)
|
|
14
|
+
throw apiError(1302);
|
|
15
|
+
}
|
|
16
|
+
const createSchema = z.object({
|
|
17
|
+
RequestedBy: z.string(),
|
|
18
|
+
RequestedFor: z.string(),
|
|
19
|
+
NotifyWhenCompleted: z.boolean(),
|
|
20
|
+
});
|
|
21
|
+
defineRoute({
|
|
22
|
+
method: "POST",
|
|
23
|
+
path: "/data-removals",
|
|
24
|
+
auth: "account",
|
|
25
|
+
handler: ({ store, clock, body }) => {
|
|
26
|
+
checkAccess(store.state.account.dataRemovalsEnabled);
|
|
27
|
+
if (body === undefined || z.object({}).strict().safeParse(body).success) {
|
|
28
|
+
throw apiError(1300, { message: EMPTY });
|
|
29
|
+
}
|
|
30
|
+
const parsed = parseBody(createSchema, body);
|
|
31
|
+
if (!parsed.success)
|
|
32
|
+
throw new Unsupported("malformed data removal body: error not captured (Q16)");
|
|
33
|
+
const request = parsed.data;
|
|
34
|
+
if (!isEmailAddress(request.RequestedFor)) {
|
|
35
|
+
throw new Unsupported("invalid RequestedFor: error not captured (docs/04 Q16)");
|
|
36
|
+
}
|
|
37
|
+
const removal = {
|
|
38
|
+
ID: store.nextId("dataRemoval"),
|
|
39
|
+
...request,
|
|
40
|
+
Status: "Pending",
|
|
41
|
+
createdAt: clock.now(),
|
|
42
|
+
};
|
|
43
|
+
store.state.dataRemovals.set(removal.ID, removal);
|
|
44
|
+
return json(removal);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
defineRoute({
|
|
48
|
+
method: "GET",
|
|
49
|
+
path: "/data-removals/:id",
|
|
50
|
+
auth: "account",
|
|
51
|
+
handler: ({ store, params }) => {
|
|
52
|
+
checkAccess(store.state.account.dataRemovalsEnabled);
|
|
53
|
+
const raw = params.id;
|
|
54
|
+
const removal = /^\d+$/.test(raw) ? store.state.dataRemovals.get(Number(raw)) : undefined;
|
|
55
|
+
if (removal === undefined)
|
|
56
|
+
throw apiError(1301, { message: NOT_FOUND });
|
|
57
|
+
return json(removal);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SubmitResult } from "../../pipeline/submit.ts";
|
|
2
|
+
/** The `POST /email` response, or the error envelope (HTTP 422 for every send code). */
|
|
3
|
+
export declare function sendResponse(result: SubmitResult, to: string): {
|
|
4
|
+
To: string;
|
|
5
|
+
SubmittedAt: string;
|
|
6
|
+
MessageID: string;
|
|
7
|
+
ErrorCode: number;
|
|
8
|
+
Message: string;
|
|
9
|
+
};
|
|
10
|
+
/** One batch item: the send response, or `{ErrorCode, Message}` only (docs/03 §2). */
|
|
11
|
+
export declare function batchItem(result: SubmitResult, to: string): import("../../errors.ts").ErrorBody;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { apiErrorOf } from "../../errors.js";
|
|
2
|
+
import { batchItemError } from "../../pipeline/inactive.js";
|
|
3
|
+
import { formatTimestamp } from "../../time.js";
|
|
4
|
+
/**
|
|
5
|
+
* A send result, in the order of refs/api_email-api.md "Example response". `to` is the request `To`
|
|
6
|
+
* value, echoed as sent (docs/03 §1.2). The test token answers "Test job accepted" (docs/03 §4).
|
|
7
|
+
*/
|
|
8
|
+
function success(result, to) {
|
|
9
|
+
const [submittedAt, messageId, text] = result.outcome === "accepted"
|
|
10
|
+
? [result.message.ReceivedAt, result.message.MessageID, "OK"]
|
|
11
|
+
: [result.submittedAt, result.messageId, "Test job accepted"];
|
|
12
|
+
return {
|
|
13
|
+
To: to,
|
|
14
|
+
SubmittedAt: formatTimestamp(submittedAt),
|
|
15
|
+
MessageID: messageId,
|
|
16
|
+
ErrorCode: 0,
|
|
17
|
+
Message: text,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** The `POST /email` response, or the error envelope (HTTP 422 for every send code). */
|
|
21
|
+
export function sendResponse(result, to) {
|
|
22
|
+
if (result.outcome === "rejected" || result.outcome === "partiallySuppressed") {
|
|
23
|
+
throw apiErrorOf(result.error);
|
|
24
|
+
}
|
|
25
|
+
return success(result, to);
|
|
26
|
+
}
|
|
27
|
+
/** One batch item: the send response, or `{ErrorCode, Message}` only (docs/03 §2). */
|
|
28
|
+
export function batchItem(result, to) {
|
|
29
|
+
if (result.outcome === "rejected" || result.outcome === "partiallySuppressed") {
|
|
30
|
+
return batchItemError(result.error);
|
|
31
|
+
}
|
|
32
|
+
return success(result, to);
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { apiError } from "../../errors.js";
|
|
2
|
+
import { Unsupported } from "../../http/respond.js";
|
|
3
|
+
import { defineRoute } from "../../http/routes.js";
|
|
4
|
+
import { acceptOutbound, acceptOutbounds, draftFromJson, validateOutbound, } from "../../pipeline/submit.js";
|
|
5
|
+
import { batchItem, sendResponse } from "./json.js";
|
|
6
|
+
const MAX_BATCH_MESSAGES = 500;
|
|
7
|
+
const MAX_BATCH_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
const validateJson = (runtime, auth, body) => validateOutbound(runtime, {
|
|
10
|
+
auth,
|
|
11
|
+
channel: "rest",
|
|
12
|
+
draft: draftFromJson(body),
|
|
13
|
+
request: body,
|
|
14
|
+
bulkRequestId: null,
|
|
15
|
+
templateId: null,
|
|
16
|
+
});
|
|
17
|
+
// refs/api_email-api.md (docs/03 §1.2).
|
|
18
|
+
defineRoute({
|
|
19
|
+
method: "POST",
|
|
20
|
+
path: "/email",
|
|
21
|
+
auth: "serverOrTest",
|
|
22
|
+
handler: async (ctx) => {
|
|
23
|
+
// An empty or non-object body is not captured (docs/02 Q16).
|
|
24
|
+
if (!isObject(ctx.body))
|
|
25
|
+
throw new Unsupported("an /email body that is not a JSON object");
|
|
26
|
+
const validation = validateJson(ctx, ctx.auth, ctx.body);
|
|
27
|
+
if (validation.outcome === "rejected")
|
|
28
|
+
return sendResponse(validation, "");
|
|
29
|
+
const { outbound } = validation;
|
|
30
|
+
return sendResponse(await acceptOutbound(ctx, outbound), outbound.draft.To);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// refs/api_email-api.md (docs/03 §1.3, §2): 200 with one item per message, in request order.
|
|
34
|
+
defineRoute({
|
|
35
|
+
method: "POST",
|
|
36
|
+
path: "/email/batch",
|
|
37
|
+
auth: "serverOrTest",
|
|
38
|
+
handler: async (ctx) => {
|
|
39
|
+
// An empty body, a non-array, or a non-object item is not captured (docs/02 Q16).
|
|
40
|
+
if (!Array.isArray(ctx.body) || !ctx.body.every(isObject)) {
|
|
41
|
+
throw new Unsupported("a batch body that is not a JSON array of objects");
|
|
42
|
+
}
|
|
43
|
+
if (ctx.body.length > MAX_BATCH_MESSAGES)
|
|
44
|
+
throw apiError(410);
|
|
45
|
+
// HTTP 413 above 50 MB (refs/api_overview.md:30); its body is not captured (docs/02 Q10).
|
|
46
|
+
if (Buffer.byteLength(JSON.stringify(ctx.body)) > MAX_BATCH_BYTES) {
|
|
47
|
+
throw new Unsupported("HTTP 413 for an oversized batch: body not captured (docs/02 Q10)");
|
|
48
|
+
}
|
|
49
|
+
// Every item is validated before any is stored, so a 501 leaves no partial batch behind.
|
|
50
|
+
const validations = ctx.body.map((body) => validateJson(ctx, ctx.auth, body));
|
|
51
|
+
// Whether 1235 is per item or for the whole request is not captured (docs/03 §8 Q14).
|
|
52
|
+
if (validations.some((v) => v.outcome === "rejected" && v.error.ErrorCode === 1235)) {
|
|
53
|
+
throw new Unsupported("an unknown MessageStream in a batch (docs/03 §8 Q14)");
|
|
54
|
+
}
|
|
55
|
+
const accepted = await acceptOutbounds(ctx, validations.flatMap((v) => (v.outcome === "valid" ? [v.outbound] : [])));
|
|
56
|
+
let next = 0;
|
|
57
|
+
const items = validations.map((validation) => {
|
|
58
|
+
if (validation.outcome === "rejected")
|
|
59
|
+
return batchItem(validation, "");
|
|
60
|
+
return batchItem(accepted[next++], validation.outbound.draft.To);
|
|
61
|
+
});
|
|
62
|
+
return items;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { apiError } from "../../errors.js";
|
|
2
|
+
import { defineRoute } from "../../http/routes.js";
|
|
3
|
+
import { deliverInbound } from "../../inbound/deliver.js";
|
|
4
|
+
// Bypass and retry of inbound messages (refs/api_messages-api.md:525-601, docs/06 §1).
|
|
5
|
+
// The 701 texts are INFERRED from the docs/02 §4.4 summary.
|
|
6
|
+
function transition({ store, params, auth }, from, action) {
|
|
7
|
+
const message = store.state.inbound.get(params.id ?? "");
|
|
8
|
+
if (message === undefined || message.ServerID !== auth.server.ID) {
|
|
9
|
+
throw apiError(701, { message: "This message was not found." });
|
|
10
|
+
}
|
|
11
|
+
if (message.Status !== from)
|
|
12
|
+
throw apiError(701, { message: `This message cannot be ${action}.` });
|
|
13
|
+
return message;
|
|
14
|
+
}
|
|
15
|
+
defineRoute({
|
|
16
|
+
method: "PUT",
|
|
17
|
+
path: "/messages/inbound/:id/bypass",
|
|
18
|
+
auth: "server",
|
|
19
|
+
handler: async (ctx) => {
|
|
20
|
+
const message = transition(ctx, "Blocked", "bypassed");
|
|
21
|
+
message.Status = "Queued";
|
|
22
|
+
await deliverInbound(ctx, message);
|
|
23
|
+
return { ErrorCode: 0, Message: `Successfully bypassed message: ${message.MessageID}.` };
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
// A retry starts the retry schedule again (INFERRED).
|
|
27
|
+
defineRoute({
|
|
28
|
+
method: "PUT",
|
|
29
|
+
path: "/messages/inbound/:id/retry",
|
|
30
|
+
auth: "server",
|
|
31
|
+
handler: async (ctx) => {
|
|
32
|
+
const message = transition(ctx, "Failed", "retried");
|
|
33
|
+
message.Status = "Queued";
|
|
34
|
+
await deliverInbound(ctx, message);
|
|
35
|
+
return {
|
|
36
|
+
ErrorCode: 0,
|
|
37
|
+
Message: `Successfully rescheduled failed message: ${message.MessageID}.`,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|