@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,148 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError } from "../../errors.js";
|
|
3
|
+
import { absent, intLike, parseBody } from "../../http/normalize.js";
|
|
4
|
+
import { Unsupported } from "../../http/respond.js";
|
|
5
|
+
import { SERVER_COLORS } from "../../state/types.js";
|
|
6
|
+
import { pathId } from "./paging.js";
|
|
7
|
+
// Servers API (docs/06 §4.2; refs/api_servers-api.md). Messages of summary rows are INFERRED.
|
|
8
|
+
/** A doc enum value matched without case (INFERRED), answered in the doc spelling. */
|
|
9
|
+
const oneOf = (values) => z.string().transform((value, ctx) => {
|
|
10
|
+
const found = values.find((v) => v.toLowerCase() === value.toLowerCase());
|
|
11
|
+
if (found !== undefined)
|
|
12
|
+
return found;
|
|
13
|
+
ctx.issues.push({ code: "custom", message: `not one of ${values.join(", ")}`, input: value });
|
|
14
|
+
return z.NEVER;
|
|
15
|
+
});
|
|
16
|
+
const isHttpUrl = (value) => {
|
|
17
|
+
try {
|
|
18
|
+
return ["http:", "https:"].includes(new URL(value).protocol);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// `null` keeps a hook URL; `""` clears it (the dotnet server test resets hooks to "",
|
|
25
|
+
// sdk/postmark-dotnet/src/Postmark.Tests/ClientServerInformationTests.cs:87-94).
|
|
26
|
+
const hookUrl = z.preprocess((v) => (v === null ? undefined : v), z
|
|
27
|
+
.string()
|
|
28
|
+
.refine((v) => v === "" || isHttpUrl(v))
|
|
29
|
+
.optional());
|
|
30
|
+
const flag = absent(z.boolean());
|
|
31
|
+
// refs/api_servers-api.md:121-137 (create body); edit takes the same fields but DeliveryType (:256-271).
|
|
32
|
+
const settingsSchema = z.object({
|
|
33
|
+
Name: absent(z.string()),
|
|
34
|
+
Color: absent(oneOf(SERVER_COLORS)),
|
|
35
|
+
SmtpApiActivated: flag,
|
|
36
|
+
RawEmailEnabled: flag,
|
|
37
|
+
DeliveryType: absent(oneOf(["Live", "Sandbox"])),
|
|
38
|
+
InboundHookUrl: hookUrl,
|
|
39
|
+
BounceHookUrl: hookUrl,
|
|
40
|
+
OpenHookUrl: hookUrl,
|
|
41
|
+
DeliveryHookUrl: hookUrl,
|
|
42
|
+
ClickHookUrl: hookUrl,
|
|
43
|
+
PostFirstOpenOnly: flag,
|
|
44
|
+
InboundDomain: absent(z.string()),
|
|
45
|
+
InboundSpamThreshold: absent(intLike.pipe(z.number().min(0).max(30))),
|
|
46
|
+
TrackOpens: flag,
|
|
47
|
+
TrackLinks: absent(oneOf(["None", "HtmlAndText", "HtmlOnly", "TextOnly"])),
|
|
48
|
+
IncludeBounceContentInHook: flag,
|
|
49
|
+
EnableSmtpApiErrorHooks: flag,
|
|
50
|
+
});
|
|
51
|
+
const HOOK_FIELDS = new Set([
|
|
52
|
+
"InboundHookUrl",
|
|
53
|
+
"BounceHookUrl",
|
|
54
|
+
"OpenHookUrl",
|
|
55
|
+
"DeliveryHookUrl",
|
|
56
|
+
"ClickHookUrl",
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* The settings of a create or edit body. A body that is no JSON object is
|
|
60
|
+
* 609. A bad value gets its field's ErrorCode (refs/api_overview.md:101-108); a field of the wrong
|
|
61
|
+
* JSON type has no documented code, so it answers 501.
|
|
62
|
+
*/
|
|
63
|
+
export function parseServerInput(state, body, self) {
|
|
64
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
65
|
+
throw apiError(609);
|
|
66
|
+
const parsed = parseBody(settingsSchema, body);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
const field = String(parsed.error.issues[0]?.path[0]);
|
|
69
|
+
if (field === "Color")
|
|
70
|
+
throw apiError(607);
|
|
71
|
+
if (field === "TrackLinks")
|
|
72
|
+
throw apiError(612);
|
|
73
|
+
if (field === "DeliveryType")
|
|
74
|
+
throw apiError(613);
|
|
75
|
+
if (field === "InboundSpamThreshold")
|
|
76
|
+
throw apiError(611);
|
|
77
|
+
if (HOOK_FIELDS.has(field)) {
|
|
78
|
+
throw apiError(606, { message: `The '${field}' is not a valid URL.` });
|
|
79
|
+
}
|
|
80
|
+
throw new Unsupported(`server field '${field}' has the wrong type: Postmark's answer is not captured`);
|
|
81
|
+
}
|
|
82
|
+
const input = parsed.data;
|
|
83
|
+
const others = [...state.servers.values()].filter((s) => s.ID !== self?.ID);
|
|
84
|
+
if (input.Name !== undefined) {
|
|
85
|
+
const name = input.Name.toLowerCase();
|
|
86
|
+
if (others.some((s) => s.Name.toLowerCase() === name))
|
|
87
|
+
throw apiError(603);
|
|
88
|
+
}
|
|
89
|
+
if (input.InboundDomain !== undefined) {
|
|
90
|
+
const domain = input.InboundDomain.toLowerCase();
|
|
91
|
+
if (domain.includes("postmarkapp.com")) {
|
|
92
|
+
throw apiError(608, { message: "The inbound domain cannot contain postmarkapp.com." });
|
|
93
|
+
}
|
|
94
|
+
// The MX check (ErrorCode 610) needs DNS; postmock accepts every inbound domain.
|
|
95
|
+
if (others.some((s) => s.InboundDomain.toLowerCase() === domain))
|
|
96
|
+
throw apiError(602);
|
|
97
|
+
}
|
|
98
|
+
return input;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Applies an edit body to a server: `PUT /servers/{id}` (account token) and `PUT /server` (server
|
|
102
|
+
* token) take the same fields. DeliveryType is fixed at create (refs/api_servers-api.md:38).
|
|
103
|
+
*/
|
|
104
|
+
export function editServer(state, server, body) {
|
|
105
|
+
const input = parseServerInput(state, body, server);
|
|
106
|
+
if (input.DeliveryType !== undefined && input.DeliveryType !== server.DeliveryType) {
|
|
107
|
+
throw new Unsupported("a DeliveryType change: Postmark's answer is not captured");
|
|
108
|
+
}
|
|
109
|
+
Object.assign(server, definedSettings(input));
|
|
110
|
+
return server;
|
|
111
|
+
}
|
|
112
|
+
/** The settings a create or edit sets: absent fields keep their value (docs/08 R9). */
|
|
113
|
+
export const definedSettings = (input) => Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined));
|
|
114
|
+
export function findServer(state, id) {
|
|
115
|
+
const server = state.servers.get(pathId(id) ?? -1);
|
|
116
|
+
// No ErrorCode in the servers table names a missing server (docs/02 §4.4).
|
|
117
|
+
if (server === undefined)
|
|
118
|
+
throw new Unsupported(`server ${id} not found: Postmark's answer is not captured`);
|
|
119
|
+
return server;
|
|
120
|
+
}
|
|
121
|
+
const ownerOf = (item) => ("ServerID" in item ? item.ServerID : item.serverId);
|
|
122
|
+
function dropOwned(map, serverId) {
|
|
123
|
+
for (const [key, item] of map)
|
|
124
|
+
if (ownerOf(item) === serverId)
|
|
125
|
+
map.delete(key);
|
|
126
|
+
}
|
|
127
|
+
/** Removes a server and everything it owns. */
|
|
128
|
+
export function deleteServer(state, server) {
|
|
129
|
+
state.servers.delete(server.ID);
|
|
130
|
+
const id = server.ID;
|
|
131
|
+
for (const map of [
|
|
132
|
+
state.streams,
|
|
133
|
+
state.outbound,
|
|
134
|
+
state.inbound,
|
|
135
|
+
state.bounces,
|
|
136
|
+
state.suppressions,
|
|
137
|
+
state.templates,
|
|
138
|
+
state.bulkRequests,
|
|
139
|
+
state.webhooks,
|
|
140
|
+
state.inboundRules,
|
|
141
|
+
state.smtpTokens,
|
|
142
|
+
]) {
|
|
143
|
+
dropOwned(map, id);
|
|
144
|
+
}
|
|
145
|
+
state.opens = state.opens.filter((o) => o.ServerID !== id);
|
|
146
|
+
state.clicks = state.clicks.filter((c) => c.ServerID !== id);
|
|
147
|
+
state.webhookAttempts = state.webhookAttempts.filter((a) => a.serverId !== id);
|
|
148
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Bounce } from "../../state/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A bounce in doc field order (refs/api_bounce-api.md:155-176, 247-270). A list item has a `Z`
|
|
4
|
+
* `BouncedAt` and no `Content`; a single bounce has an Eastern offset and `Content` (Q12).
|
|
5
|
+
*/
|
|
6
|
+
export declare const bounceJson: (b: Bounce, now: Date, shape: "list" | "single") => {
|
|
7
|
+
ID: number;
|
|
8
|
+
Type: "AddressChange" | "AutoResponder" | "BadEmailAddress" | "Blocked" | "ChallengeVerification" | "DMARCPolicy" | "DnsError" | "HardBounce" | "InboundError" | "ManuallyDeactivated" | "OpenRelayTest" | "SMTPApiError" | "SoftBounce" | "SpamComplaint" | "SpamNotification" | "Subscribe" | "TemplateRenderingFailed" | "Transient" | "Unconfirmed" | "Unknown" | "Unsubscribe" | "VirusNotification";
|
|
9
|
+
TypeCode: 1 | 2 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 100000 | 100001 | 100002 | 100003 | 100006 | 100007 | 100008 | 100009 | 100010;
|
|
10
|
+
Name: "Address change" | "Auto responder" | "DMARC Policy" | "DNS error" | "Hard bounce" | "ISP block" | "Invalid email address" | "Manually deactivated" | "Message delayed/Undeliverable" | "Open relay test" | "Processing failed" | "Registration not confirmed" | "SMTP API error" | "Soft bounce" | "Spam challenge verification" | "Spam complaint" | "Spam notification" | "Subscribe request" | "Template rendering failed" | "Unknown" | "Unsubscribe request" | "Virus notification";
|
|
11
|
+
Tag: string | null;
|
|
12
|
+
MessageID: string;
|
|
13
|
+
ServerID: number;
|
|
14
|
+
MessageStream: string;
|
|
15
|
+
Description: string;
|
|
16
|
+
Details: string;
|
|
17
|
+
Email: string;
|
|
18
|
+
From: string | null;
|
|
19
|
+
BouncedAt: string;
|
|
20
|
+
DumpAvailable: boolean;
|
|
21
|
+
Inactive: boolean;
|
|
22
|
+
CanActivate: boolean;
|
|
23
|
+
Subject: string;
|
|
24
|
+
Content?: string;
|
|
25
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { dumpAvailable } from "../../recipients/transitions.js";
|
|
2
|
+
import { BOUNCE_TYPES } from "../../state/types.js";
|
|
3
|
+
import { formatTimestamp } from "../../time.js";
|
|
4
|
+
/**
|
|
5
|
+
* A bounce in doc field order (refs/api_bounce-api.md:155-176, 247-270). A list item has a `Z`
|
|
6
|
+
* `BouncedAt` and no `Content`; a single bounce has an Eastern offset and `Content` (Q12).
|
|
7
|
+
*/
|
|
8
|
+
export const bounceJson = (b, now, shape) => ({
|
|
9
|
+
ID: b.ID,
|
|
10
|
+
Type: b.Type,
|
|
11
|
+
TypeCode: BOUNCE_TYPES[b.Type].TypeCode,
|
|
12
|
+
Name: BOUNCE_TYPES[b.Type].Name,
|
|
13
|
+
Tag: b.Tag,
|
|
14
|
+
MessageID: b.MessageID,
|
|
15
|
+
ServerID: b.ServerID,
|
|
16
|
+
MessageStream: b.MessageStream,
|
|
17
|
+
Description: b.Description,
|
|
18
|
+
Details: b.Details,
|
|
19
|
+
Email: b.Email,
|
|
20
|
+
From: b.From,
|
|
21
|
+
BouncedAt: formatTimestamp(b.BouncedAt, shape === "list" ? "utc" : "eastern"),
|
|
22
|
+
DumpAvailable: dumpAvailable(b, now),
|
|
23
|
+
Inactive: b.Inactive,
|
|
24
|
+
CanActivate: b.CanActivate,
|
|
25
|
+
Subject: b.Subject,
|
|
26
|
+
...(shape === "single" && { Content: b.Content }),
|
|
27
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError } from "../../errors.js";
|
|
3
|
+
import { inclusiveUpperBound, queryBool, queryDate, queryInt } from "../../http/normalize.js";
|
|
4
|
+
import { paged } from "../../http/respond.js";
|
|
5
|
+
import { defineRoute } from "../../http/routes.js";
|
|
6
|
+
import { activateBounce, bounceVisible, dumpAvailable, findBounce, } from "../../recipients/transitions.js";
|
|
7
|
+
import { BOUNCE_TYPES } from "../../state/types.js";
|
|
8
|
+
import { bounceJson } from "./json.js";
|
|
9
|
+
// Bounce API: docs/04 §1. Every 1000 and 1001 message text is INFERRED (summary rows,
|
|
10
|
+
// refs/api_overview.md:116-117).
|
|
11
|
+
const bounceType = z.string().refine((t) => Object.hasOwn(BOUNCE_TYPES, t));
|
|
12
|
+
const listQuery = z.object({
|
|
13
|
+
count: queryInt.pipe(z.number().int().min(1).max(500)),
|
|
14
|
+
offset: queryInt.pipe(z.number().int().min(0)),
|
|
15
|
+
type: bounceType.optional(),
|
|
16
|
+
inactive: queryBool.optional(),
|
|
17
|
+
emailFilter: z.string().optional(),
|
|
18
|
+
tag: z.string().optional(),
|
|
19
|
+
messageID: z.string().optional(),
|
|
20
|
+
fromdate: queryDate.optional(),
|
|
21
|
+
todate: queryDate.optional(),
|
|
22
|
+
messagestream: z.string().default("outbound"),
|
|
23
|
+
});
|
|
24
|
+
const QUERY_MESSAGES = {
|
|
25
|
+
count: "Count must be an integer between 1 and 500.",
|
|
26
|
+
offset: "Offset must be a non-negative integer.",
|
|
27
|
+
type: "Illegal bounce type.",
|
|
28
|
+
inactive: "Inactive must be true or false.",
|
|
29
|
+
fromdate: "FromDate must be a date.",
|
|
30
|
+
todate: "ToDate must be a date.",
|
|
31
|
+
};
|
|
32
|
+
defineRoute({
|
|
33
|
+
method: "GET",
|
|
34
|
+
path: "/bounces",
|
|
35
|
+
auth: "server",
|
|
36
|
+
handler: ({ store, clock, query, auth }) => {
|
|
37
|
+
const parsed = query.pick(listQuery);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
const field = String(parsed.error.issues[0]?.path[0]);
|
|
40
|
+
throw apiError(1000, { message: QUERY_MESSAGES[field] ?? `Invalid ${field}.` });
|
|
41
|
+
}
|
|
42
|
+
const q = parsed.data;
|
|
43
|
+
if (q.count + q.offset > 10000) {
|
|
44
|
+
throw apiError(1000, { message: "Count plus offset cannot exceed 10,000." });
|
|
45
|
+
}
|
|
46
|
+
const now = clock.now();
|
|
47
|
+
const from = q.fromdate?.instant.getTime();
|
|
48
|
+
const to = q.todate && inclusiveUpperBound(q.todate).getTime();
|
|
49
|
+
const matches = [...store.state.bounces.values()]
|
|
50
|
+
.filter((b) => b.ServerID === auth.server.ID &&
|
|
51
|
+
bounceVisible(b, now) &&
|
|
52
|
+
b.MessageStream === q.messagestream &&
|
|
53
|
+
(q.type === undefined || b.Type === q.type) &&
|
|
54
|
+
(q.inactive === undefined || b.Inactive === q.inactive) &&
|
|
55
|
+
// Substring match, case rule INFERRED (docs/04 §1.2, Q1).
|
|
56
|
+
(q.emailFilter === undefined ||
|
|
57
|
+
b.Email.toLowerCase().includes(q.emailFilter.toLowerCase())) &&
|
|
58
|
+
(q.tag === undefined || b.Tag === q.tag) &&
|
|
59
|
+
(q.messageID === undefined || b.MessageID.toLowerCase() === q.messageID.toLowerCase()) &&
|
|
60
|
+
(from === undefined || b.BouncedAt.getTime() >= from) &&
|
|
61
|
+
(to === undefined || b.BouncedAt.getTime() < to))
|
|
62
|
+
// Newest first (refs/api_bounce-api.md:151-196 example order).
|
|
63
|
+
.sort((a, b) => b.BouncedAt.getTime() - a.BouncedAt.getTime() || b.ID - a.ID)
|
|
64
|
+
.map((b) => ({ RecordType: "Bounce", ...bounceJson(b, now, "list") }));
|
|
65
|
+
return paged("Bounces", matches, q.count, q.offset);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
/** The bounce ID from the path; a non-integer is an unknown bounce (INFERRED). */
|
|
69
|
+
function bounceOf(ctx) {
|
|
70
|
+
const raw = ctx.params.bounceid;
|
|
71
|
+
if (!/^\d+$/.test(raw))
|
|
72
|
+
throw apiError(1001, { message: "The bounce was not found." });
|
|
73
|
+
return findBounce(ctx, ctx.auth.server, Number(raw));
|
|
74
|
+
}
|
|
75
|
+
defineRoute({
|
|
76
|
+
method: "GET",
|
|
77
|
+
path: "/bounces/:bounceid",
|
|
78
|
+
auth: "server",
|
|
79
|
+
handler: (ctx) => bounceJson(bounceOf(ctx), ctx.clock.now(), "single"),
|
|
80
|
+
});
|
|
81
|
+
defineRoute({
|
|
82
|
+
method: "GET",
|
|
83
|
+
path: "/bounces/:bounceid/dump",
|
|
84
|
+
auth: "server",
|
|
85
|
+
handler: (ctx) => {
|
|
86
|
+
const bounce = bounceOf(ctx);
|
|
87
|
+
if (bounce.Content === "")
|
|
88
|
+
return { Body: "" };
|
|
89
|
+
if (!dumpAvailable(bounce, ctx.clock.now())) {
|
|
90
|
+
throw apiError(1001, { message: "The bounce dump is no longer available." });
|
|
91
|
+
}
|
|
92
|
+
return { Body: bounce.Content };
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
// Accepts an empty body and `{}` (docs/04 §3.4); the body is not read.
|
|
96
|
+
defineRoute({
|
|
97
|
+
method: "PUT",
|
|
98
|
+
path: "/bounces/:bounceid/activate",
|
|
99
|
+
auth: "server",
|
|
100
|
+
handler: async (ctx) => {
|
|
101
|
+
const bounce = bounceOf(ctx);
|
|
102
|
+
await activateBounce(ctx, bounce);
|
|
103
|
+
return { Message: "OK", Bounce: bounceJson(bounce, ctx.clock.now(), "single") };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
// postmark.js calls `/deliveryStats`; literals match without case (docs/04 §6).
|
|
107
|
+
defineRoute({
|
|
108
|
+
method: "GET",
|
|
109
|
+
path: "/deliverystats",
|
|
110
|
+
auth: "server",
|
|
111
|
+
handler: ({ store, clock, auth }) => {
|
|
112
|
+
const now = clock.now();
|
|
113
|
+
const bounces = [...store.state.bounces.values()].filter((b) => b.ServerID === auth.server.ID && bounceVisible(b, now));
|
|
114
|
+
const types = Object.keys(BOUNCE_TYPES)
|
|
115
|
+
.map((type) => ({
|
|
116
|
+
Type: type,
|
|
117
|
+
Name: BOUNCE_TYPES[type].Name,
|
|
118
|
+
Count: bounces.filter((b) => b.Type === type).length,
|
|
119
|
+
}))
|
|
120
|
+
.filter((row) => row.Count > 0);
|
|
121
|
+
return {
|
|
122
|
+
// Inactive bounces, INFERRED from the example ratio (refs/api_bounce-api.md:41-47).
|
|
123
|
+
InactiveMails: bounces.filter((b) => b.Inactive).length,
|
|
124
|
+
Bounces: [{ Name: "All", Count: bounces.length }, ...types],
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ServerAuth } from "../../http/routes.ts";
|
|
2
|
+
import { type OutboundDraft, type Submission } from "../../pipeline/submit.ts";
|
|
3
|
+
import type { Runtime } from "../../runtime.ts";
|
|
4
|
+
import type { BulkRequest } from "../../state/types.ts";
|
|
5
|
+
import type { RenderOutcome } from "../templates/content.ts";
|
|
6
|
+
/** Processing starts after this delay, then releases one message per step (INFERRED timings). */
|
|
7
|
+
export declare const BULK_START_MS = 1000;
|
|
8
|
+
export declare const BULK_STEP_MS = 10;
|
|
9
|
+
export interface BulkJob {
|
|
10
|
+
/** The draft with rendered content, or the render failure that counts it as failed. */
|
|
11
|
+
draft: OutboundDraft;
|
|
12
|
+
rendered: RenderOutcome;
|
|
13
|
+
/** The `Messages[]` entry, for `GET /control/messages`. */
|
|
14
|
+
request: unknown;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The status object of all three endpoints. Absent values are omitted, never null
|
|
18
|
+
* (refs/api_bulk-email.md:238-240); `SubmittedAt` uses `Z` (refs/api_bulk-email.md:204).
|
|
19
|
+
*/
|
|
20
|
+
export declare const bulkStatusJson: (bulk: BulkRequest) => {
|
|
21
|
+
Id: string;
|
|
22
|
+
SubmittedAt: string;
|
|
23
|
+
Status: "Accepted" | "Cancelled" | "Completed" | "Processing";
|
|
24
|
+
TotalMessages: number;
|
|
25
|
+
PercentageCompleted: number;
|
|
26
|
+
ReleasedCount: number;
|
|
27
|
+
FailedCount: number;
|
|
28
|
+
Subject?: string;
|
|
29
|
+
};
|
|
30
|
+
/** A bulk message submission. */
|
|
31
|
+
export declare const bulkSubmission: (auth: ServerAuth, draft: OutboundDraft, request: unknown, bulkRequestId: string | null, templateId: number | null) => Submission;
|
|
32
|
+
/**
|
|
33
|
+
* Accepted → Processing → Completed. A message with every recipient suppressed, or one that failed
|
|
34
|
+
* to render, counts in `FailedCount`; the rest in `ReleasedCount` (refs/api_bulk-email.md:303-316).
|
|
35
|
+
* A message that state changed since accept (a deleted stream) fails its checks and counts as
|
|
36
|
+
* failed too (INFERRED). A message with only some recipients suppressed counts as released
|
|
37
|
+
* (INFERRED, docs/03 Q21). A cancelled request releases nothing more.
|
|
38
|
+
*
|
|
39
|
+
* `Unsupported` in a job (an archived stream, a listener) has no request to answer with 501, and
|
|
40
|
+
* the docs give no count for it. The request stops releasing and keeps its counters, so it never
|
|
41
|
+
* reaches Completed; the reason goes to stderr and `GET /control/bulk/:id` (AGENTS.md rule 5).
|
|
42
|
+
*/
|
|
43
|
+
export declare function scheduleBulk(runtime: Runtime, auth: ServerAuth, bulk: BulkRequest, jobs: readonly BulkJob[], templateId: number | null): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Unsupported } from "../../http/respond.js";
|
|
2
|
+
import { acceptOutbound, validateOutbound, } from "../../pipeline/submit.js";
|
|
3
|
+
import { formatTimestamp } from "../../time.js";
|
|
4
|
+
// The bulk request status object and its processing on the clock (docs/03 §1.6; refs/api_bulk-email.md).
|
|
5
|
+
/** Processing starts after this delay, then releases one message per step (INFERRED timings). */
|
|
6
|
+
export const BULK_START_MS = 1000;
|
|
7
|
+
export const BULK_STEP_MS = 10;
|
|
8
|
+
/**
|
|
9
|
+
* The status object of all three endpoints. Absent values are omitted, never null
|
|
10
|
+
* (refs/api_bulk-email.md:238-240); `SubmittedAt` uses `Z` (refs/api_bulk-email.md:204).
|
|
11
|
+
*/
|
|
12
|
+
export const bulkStatusJson = (bulk) => ({
|
|
13
|
+
Id: bulk.Id,
|
|
14
|
+
SubmittedAt: formatTimestamp(bulk.SubmittedAt, "utc"),
|
|
15
|
+
Status: bulk.Status,
|
|
16
|
+
TotalMessages: bulk.TotalMessages,
|
|
17
|
+
PercentageCompleted: bulk.PercentageCompleted,
|
|
18
|
+
ReleasedCount: bulk.ReleasedCount,
|
|
19
|
+
FailedCount: bulk.FailedCount,
|
|
20
|
+
...(bulk.Subject !== null && { Subject: bulk.Subject }),
|
|
21
|
+
});
|
|
22
|
+
/** A bulk message submission. */
|
|
23
|
+
export const bulkSubmission = (auth, draft, request, bulkRequestId, templateId) => ({
|
|
24
|
+
auth,
|
|
25
|
+
channel: "rest",
|
|
26
|
+
draft,
|
|
27
|
+
request,
|
|
28
|
+
bulkRequestId,
|
|
29
|
+
templateId,
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Accepted → Processing → Completed. A message with every recipient suppressed, or one that failed
|
|
33
|
+
* to render, counts in `FailedCount`; the rest in `ReleasedCount` (refs/api_bulk-email.md:303-316).
|
|
34
|
+
* A message that state changed since accept (a deleted stream) fails its checks and counts as
|
|
35
|
+
* failed too (INFERRED). A message with only some recipients suppressed counts as released
|
|
36
|
+
* (INFERRED, docs/03 Q21). A cancelled request releases nothing more.
|
|
37
|
+
*
|
|
38
|
+
* `Unsupported` in a job (an archived stream, a listener) has no request to answer with 501, and
|
|
39
|
+
* the docs give no count for it. The request stops releasing and keeps its counters, so it never
|
|
40
|
+
* reaches Completed; the reason goes to stderr and `GET /control/bulk/:id` (AGENTS.md rule 5).
|
|
41
|
+
*/
|
|
42
|
+
export function scheduleBulk(runtime, auth, bulk, jobs, templateId) {
|
|
43
|
+
const { clock } = runtime;
|
|
44
|
+
clock.schedule(BULK_START_MS, () => {
|
|
45
|
+
if (bulk.Status === "Accepted")
|
|
46
|
+
bulk.Status = "Processing";
|
|
47
|
+
});
|
|
48
|
+
jobs.forEach((job, i) => {
|
|
49
|
+
clock.schedule(BULK_START_MS + (i + 1) * BULK_STEP_MS, async () => {
|
|
50
|
+
if (bulk.Status !== "Processing" || bulk.unsupported !== null)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
bulk[(await release(runtime, auth, bulk, job, templateId)) ? "ReleasedCount" : "FailedCount"] += 1;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (!(error instanceof Unsupported))
|
|
57
|
+
throw error;
|
|
58
|
+
bulk.unsupported = error.message;
|
|
59
|
+
console.error(`postmock: bulk request ${bulk.Id} stopped: ${error.message}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const done = bulk.ReleasedCount + bulk.FailedCount;
|
|
63
|
+
bulk.PercentageCompleted = (done / bulk.TotalMessages) * 100;
|
|
64
|
+
if (done === bulk.TotalMessages)
|
|
65
|
+
bulk.Status = "Completed";
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Sends one bulk message; true when it was released. */
|
|
70
|
+
async function release(runtime, auth, bulk, job, templateId) {
|
|
71
|
+
if (!job.rendered.ok)
|
|
72
|
+
return false;
|
|
73
|
+
const validation = validateOutbound(runtime, bulkSubmission(auth, job.draft, job.request, bulk.Id, templateId));
|
|
74
|
+
if (validation.outcome === "rejected")
|
|
75
|
+
return false;
|
|
76
|
+
const result = await acceptOutbound(runtime, validation.outbound);
|
|
77
|
+
if (result.outcome !== "accepted" && result.outcome !== "partiallySuppressed")
|
|
78
|
+
return false;
|
|
79
|
+
bulk.messageIds.push(result.message.MessageID);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|