@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,203 @@
|
|
|
1
|
+
import { apiError } from "../../errors.js";
|
|
2
|
+
import { paged, Unsupported } from "../../http/respond.js";
|
|
3
|
+
import { defineRoute } from "../../http/routes.js";
|
|
4
|
+
import { parseAddressList } from "../../pipeline/addresses.js";
|
|
5
|
+
import { recipientsOf } from "../../recipients/transitions.js";
|
|
6
|
+
import { findStream } from "../../state/servers.js";
|
|
7
|
+
import { clickJson, inboundDetailsJson, inboundJson, openJson, outboundDetailsJson, outboundJson, } from "./json.js";
|
|
8
|
+
import { dateRange, paging, retained, sameText, status } from "./query.js";
|
|
9
|
+
const newestFirst = (items) => items.sort((a, b) => b.ReceivedAt.getTime() - a.ReceivedAt.getTime());
|
|
10
|
+
// 701 is a summary row; the SDK live test expects "not found" in the text
|
|
11
|
+
// (sdk/postmark.js/test/integration/Messages.test.ts:43-47). The exact text is INFERRED.
|
|
12
|
+
const notFound = () => apiError(701, { message: "This message was not found." });
|
|
13
|
+
/** An optional filter; each present value must match. */
|
|
14
|
+
const filter = (query, name, matches) => {
|
|
15
|
+
const value = query.get(name);
|
|
16
|
+
return value === undefined || matches(value);
|
|
17
|
+
};
|
|
18
|
+
// Subject matches as a substring without case: the dotnet live test asserts that shape
|
|
19
|
+
// (sdk/postmark-dotnet/src/Postmark.Tests/ClientMessageSearchingTests.cs:38-41). INFERRED otherwise.
|
|
20
|
+
const subjectMatches = (subject, wanted) => (subject ?? "").toLowerCase().includes(wanted.toLowerCase());
|
|
21
|
+
const OUTBOUND_STATUS = {
|
|
22
|
+
queued: ["Queued"],
|
|
23
|
+
// "sent and processed will return the same results" (refs/api_messages-api.md:39).
|
|
24
|
+
sent: ["Sent", "Processed"],
|
|
25
|
+
processed: ["Sent", "Processed"],
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* `messagestream`, default `outbound` (refs/api_messages-api.md:43, :634, :853). Postmark's answer
|
|
29
|
+
* for a stream the server lacks is not documented.
|
|
30
|
+
*/
|
|
31
|
+
function knownStream({ store, auth, query }) {
|
|
32
|
+
const stream = query.get("messagestream") ?? "outbound";
|
|
33
|
+
if (findStream(store.state, auth, stream) === undefined) {
|
|
34
|
+
throw new Unsupported(`messagestream '${stream}' does not exist on this server`);
|
|
35
|
+
}
|
|
36
|
+
return stream;
|
|
37
|
+
}
|
|
38
|
+
function outboundMessage({ store, clock, auth, params }) {
|
|
39
|
+
const message = store.state.outbound.get(params.id);
|
|
40
|
+
if (message === undefined ||
|
|
41
|
+
message.ServerID !== auth.server.ID ||
|
|
42
|
+
!retained(message.ReceivedAt, clock.now())) {
|
|
43
|
+
throw notFound();
|
|
44
|
+
}
|
|
45
|
+
return message;
|
|
46
|
+
}
|
|
47
|
+
defineRoute({
|
|
48
|
+
method: "GET",
|
|
49
|
+
path: "/messages/outbound",
|
|
50
|
+
auth: "server",
|
|
51
|
+
handler: (ctx) => {
|
|
52
|
+
const { store, clock, auth, query } = ctx;
|
|
53
|
+
const { count, offset } = paging(query);
|
|
54
|
+
const inRange = dateRange(query);
|
|
55
|
+
const statuses = status(query, OUTBOUND_STATUS);
|
|
56
|
+
const stream = knownStream(ctx);
|
|
57
|
+
const metadata = Object.entries(query.prefixed("metadata_"));
|
|
58
|
+
// "You can currently only search by a single metadata field at a time"
|
|
59
|
+
// (refs/api_messages-api.md:44). Postmark's answer to two is not known.
|
|
60
|
+
if (metadata.length > 1)
|
|
61
|
+
throw new Unsupported("more than one metadata_ filter");
|
|
62
|
+
const now = clock.now();
|
|
63
|
+
const matches = [...store.state.outbound.values()].filter((m) => m.ServerID === auth.server.ID &&
|
|
64
|
+
m.MessageStream === stream &&
|
|
65
|
+
retained(m.ReceivedAt, now) &&
|
|
66
|
+
inRange(m.ReceivedAt) &&
|
|
67
|
+
(statuses === undefined || statuses.includes(m.Status)) &&
|
|
68
|
+
filter(query, "recipient", (v) => recipientsOf(m).some((r) => sameText(r, v))) &&
|
|
69
|
+
filter(query, "fromemail", (v) => sameText(parseAddressList(m.From)?.[0]?.Email, v)) &&
|
|
70
|
+
filter(query, "tag", (v) => m.Tag === v) &&
|
|
71
|
+
filter(query, "subject", (v) => subjectMatches(m.Subject, v)) &&
|
|
72
|
+
metadata.every(([key, value]) => m.Metadata[key] === value));
|
|
73
|
+
return paged("Messages", newestFirst(matches).map(outboundJson), count, offset);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
defineRoute({
|
|
77
|
+
method: "GET",
|
|
78
|
+
path: "/messages/outbound/:id/details",
|
|
79
|
+
auth: "server",
|
|
80
|
+
handler: (ctx) => outboundDetailsJson(outboundMessage(ctx)),
|
|
81
|
+
});
|
|
82
|
+
defineRoute({
|
|
83
|
+
method: "GET",
|
|
84
|
+
path: "/messages/outbound/:id/dump",
|
|
85
|
+
auth: "server",
|
|
86
|
+
handler: (ctx) => ({ Body: outboundMessage(ctx).rawSource }),
|
|
87
|
+
});
|
|
88
|
+
const INBOUND_STATUS = {
|
|
89
|
+
blocked: ["Blocked"],
|
|
90
|
+
processed: ["Processed"],
|
|
91
|
+
queued: ["Queued"],
|
|
92
|
+
failed: ["Failed"],
|
|
93
|
+
scheduled: ["Scheduled"],
|
|
94
|
+
// postmark.js sends `sent` (sdk/postmark.js/src/client/models/messages/MessageFilteringParameters.ts:9-16).
|
|
95
|
+
sent: ["Sent"],
|
|
96
|
+
};
|
|
97
|
+
function inboundMessage({ store, clock, auth, params }) {
|
|
98
|
+
const message = store.state.inbound.get(params.id);
|
|
99
|
+
if (message === undefined ||
|
|
100
|
+
message.ServerID !== auth.server.ID ||
|
|
101
|
+
!retained(message.ReceivedAt, clock.now())) {
|
|
102
|
+
throw notFound();
|
|
103
|
+
}
|
|
104
|
+
return message;
|
|
105
|
+
}
|
|
106
|
+
defineRoute({
|
|
107
|
+
method: "GET",
|
|
108
|
+
path: "/messages/inbound",
|
|
109
|
+
auth: "server",
|
|
110
|
+
handler: ({ store, clock, auth, query }) => {
|
|
111
|
+
const { count, offset } = paging(query);
|
|
112
|
+
const inRange = dateRange(query);
|
|
113
|
+
// "if no status is specified, only processed messages are returned" (refs/api_messages-api.md:315).
|
|
114
|
+
const statuses = status(query, INBOUND_STATUS) ?? ["Processed"];
|
|
115
|
+
const now = clock.now();
|
|
116
|
+
const matches = [...store.state.inbound.values()].filter((m) => m.ServerID === auth.server.ID &&
|
|
117
|
+
retained(m.ReceivedAt, now) &&
|
|
118
|
+
inRange(m.ReceivedAt) &&
|
|
119
|
+
statuses.includes(m.Status) &&
|
|
120
|
+
filter(query, "recipient", (v) => [
|
|
121
|
+
m.OriginalRecipient,
|
|
122
|
+
...[...m.ToFull, ...m.CcFull, ...m.BccFull].map((a) => a.Email),
|
|
123
|
+
].some((r) => sameText(r, v))) &&
|
|
124
|
+
filter(query, "fromemail", (v) => sameText(m.FromFull.Email, v)) &&
|
|
125
|
+
filter(query, "tag", (v) => m.Tag === v) &&
|
|
126
|
+
filter(query, "subject", (v) => subjectMatches(m.Subject, v)) &&
|
|
127
|
+
filter(query, "mailboxhash", (v) => m.MailboxHash === v));
|
|
128
|
+
return paged("InboundMessages", newestFirst(matches).map(inboundJson), count, offset);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
defineRoute({
|
|
132
|
+
method: "GET",
|
|
133
|
+
path: "/messages/inbound/:id/details",
|
|
134
|
+
auth: "server",
|
|
135
|
+
handler: (ctx) => inboundDetailsJson(inboundMessage(ctx)),
|
|
136
|
+
});
|
|
137
|
+
// Opens and clicks (refs/api_messages-api.md:626-653, :835-853). Filter values match without case
|
|
138
|
+
// (INFERRED, docs/06 Q10).
|
|
139
|
+
function trackingFilters(ctx, events) {
|
|
140
|
+
const { query, auth, clock } = ctx;
|
|
141
|
+
const stream = knownStream(ctx);
|
|
142
|
+
const now = clock.now();
|
|
143
|
+
return events.filter((e) => e.ServerID === auth.server.ID &&
|
|
144
|
+
e.MessageStream === stream &&
|
|
145
|
+
retained(e.ReceivedAt, now) &&
|
|
146
|
+
filter(query, "recipient", (v) => sameText(e.Recipient, v)) &&
|
|
147
|
+
filter(query, "tag", (v) => e.Tag === v) &&
|
|
148
|
+
filter(query, "client_name", (v) => sameText(e.Client?.Name, v)) &&
|
|
149
|
+
filter(query, "client_company", (v) => sameText(e.Client?.Company, v)) &&
|
|
150
|
+
filter(query, "client_family", (v) => sameText(e.Client?.Family, v)) &&
|
|
151
|
+
filter(query, "os_name", (v) => sameText(e.OS?.Name, v)) &&
|
|
152
|
+
filter(query, "os_family", (v) => sameText(e.OS?.Family, v)) &&
|
|
153
|
+
filter(query, "os_company", (v) => sameText(e.OS?.Company, v)) &&
|
|
154
|
+
filter(query, "platform", (v) => sameText(e.Platform, v)) &&
|
|
155
|
+
filter(query, "country", (v) => sameText(e.Geo?.Country, v)) &&
|
|
156
|
+
filter(query, "region", (v) => sameText(e.Geo?.Region, v)) &&
|
|
157
|
+
filter(query, "city", (v) => sameText(e.Geo?.City, v)));
|
|
158
|
+
}
|
|
159
|
+
/** Events of one message. Single-message reads take only `count` and `offset` (docs/06 §1.7). */
|
|
160
|
+
function ofMessage(ctx, events) {
|
|
161
|
+
const message = outboundMessage(ctx);
|
|
162
|
+
return events.filter((e) => e.MessageID === message.MessageID);
|
|
163
|
+
}
|
|
164
|
+
defineRoute({
|
|
165
|
+
method: "GET",
|
|
166
|
+
path: "/messages/outbound/opens",
|
|
167
|
+
auth: "server",
|
|
168
|
+
handler: (ctx) => {
|
|
169
|
+
const { count, offset } = paging(ctx.query);
|
|
170
|
+
const opens = newestFirst(trackingFilters(ctx, [...ctx.store.state.opens]));
|
|
171
|
+
return paged("Opens", opens.map((o) => openJson(o, false)), count, offset);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
defineRoute({
|
|
175
|
+
method: "GET",
|
|
176
|
+
path: "/messages/outbound/opens/:id",
|
|
177
|
+
auth: "server",
|
|
178
|
+
handler: (ctx) => {
|
|
179
|
+
const { count, offset } = paging(ctx.query);
|
|
180
|
+
const opens = newestFirst(ofMessage(ctx, ctx.store.state.opens));
|
|
181
|
+
return paged("Opens", opens.map((o) => openJson(o, true)), count, offset);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
defineRoute({
|
|
185
|
+
method: "GET",
|
|
186
|
+
path: "/messages/outbound/clicks",
|
|
187
|
+
auth: "server",
|
|
188
|
+
handler: (ctx) => {
|
|
189
|
+
const { count, offset } = paging(ctx.query);
|
|
190
|
+
const clicks = newestFirst(trackingFilters(ctx, [...ctx.store.state.clicks]));
|
|
191
|
+
return paged("Clicks", clicks.map((c) => clickJson(c, false)), count, offset);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
defineRoute({
|
|
195
|
+
method: "GET",
|
|
196
|
+
path: "/messages/outbound/clicks/:id",
|
|
197
|
+
auth: "server",
|
|
198
|
+
handler: (ctx) => {
|
|
199
|
+
const { count, offset } = paging(ctx.query);
|
|
200
|
+
const clicks = newestFirst(ofMessage(ctx, ctx.store.state.clicks));
|
|
201
|
+
return paged("Clicks", clicks.map((c) => clickJson(c, true)), count, offset);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Server } from "../../state/types.ts";
|
|
2
|
+
/** The server object of `GET /server` and the Servers API, in doc field order (docs/06 §4.1). */
|
|
3
|
+
export declare const serverJson: (s: Server) => {
|
|
4
|
+
ID: number;
|
|
5
|
+
Name: string;
|
|
6
|
+
ApiTokens: string[];
|
|
7
|
+
Color: "blue" | "green" | "grey" | "orange" | "purple" | "red" | "turquoise" | "yellow";
|
|
8
|
+
SmtpApiActivated: boolean;
|
|
9
|
+
RawEmailEnabled: boolean;
|
|
10
|
+
DeliveryType: "Live" | "Sandbox";
|
|
11
|
+
ServerLink: string;
|
|
12
|
+
InboundAddress: string;
|
|
13
|
+
InboundHookUrl: string;
|
|
14
|
+
BounceHookUrl: string;
|
|
15
|
+
OpenHookUrl: string;
|
|
16
|
+
DeliveryHookUrl: string;
|
|
17
|
+
PostFirstOpenOnly: boolean;
|
|
18
|
+
InboundDomain: string;
|
|
19
|
+
InboundHash: string;
|
|
20
|
+
InboundSpamThreshold: number;
|
|
21
|
+
TrackOpens: boolean;
|
|
22
|
+
TrackLinks: import("../../state/types.ts").TrackLinks;
|
|
23
|
+
IncludeBounceContentInHook: boolean;
|
|
24
|
+
ClickHookUrl: string;
|
|
25
|
+
EnableSmtpApiErrorHooks: boolean;
|
|
26
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** The server object of `GET /server` and the Servers API, in doc field order (docs/06 §4.1). */
|
|
2
|
+
export const serverJson = (s) => ({
|
|
3
|
+
ID: s.ID,
|
|
4
|
+
Name: s.Name,
|
|
5
|
+
ApiTokens: s.ApiTokens,
|
|
6
|
+
Color: s.Color,
|
|
7
|
+
SmtpApiActivated: s.SmtpApiActivated,
|
|
8
|
+
RawEmailEnabled: s.RawEmailEnabled,
|
|
9
|
+
DeliveryType: s.DeliveryType,
|
|
10
|
+
ServerLink: s.ServerLink,
|
|
11
|
+
InboundAddress: s.InboundAddress,
|
|
12
|
+
InboundHookUrl: s.InboundHookUrl,
|
|
13
|
+
BounceHookUrl: s.BounceHookUrl,
|
|
14
|
+
OpenHookUrl: s.OpenHookUrl,
|
|
15
|
+
DeliveryHookUrl: s.DeliveryHookUrl,
|
|
16
|
+
PostFirstOpenOnly: s.PostFirstOpenOnly,
|
|
17
|
+
InboundDomain: s.InboundDomain,
|
|
18
|
+
InboundHash: s.InboundHash,
|
|
19
|
+
InboundSpamThreshold: s.InboundSpamThreshold,
|
|
20
|
+
TrackOpens: s.TrackOpens,
|
|
21
|
+
TrackLinks: s.TrackLinks,
|
|
22
|
+
IncludeBounceContentInHook: s.IncludeBounceContentInHook,
|
|
23
|
+
ClickHookUrl: s.ClickHookUrl,
|
|
24
|
+
EnableSmtpApiErrorHooks: s.EnableSmtpApiErrorHooks,
|
|
25
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineRoute } from "../../http/routes.js";
|
|
2
|
+
import { editServer } from "../account/servers.js";
|
|
3
|
+
import { serverJson } from "./json.js";
|
|
4
|
+
// refs/api_server-api.md:10-80 (docs/06 §4.1). `PUT /server` below.
|
|
5
|
+
defineRoute({
|
|
6
|
+
method: "GET",
|
|
7
|
+
path: "/server",
|
|
8
|
+
auth: "server",
|
|
9
|
+
handler: ({ auth }) => serverJson(auth.server),
|
|
10
|
+
});
|
|
11
|
+
// refs/api_server-api.md:93-200 (docs/06 §4.1). The Servers API edit takes the same body.
|
|
12
|
+
defineRoute({
|
|
13
|
+
method: "PUT",
|
|
14
|
+
path: "/server",
|
|
15
|
+
auth: "server",
|
|
16
|
+
handler: ({ store, body, auth }) => serverJson(editServer(store.state, auth.server, body)),
|
|
17
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { StatsFact } from "../../state/types.ts";
|
|
2
|
+
type Fact<K extends StatsFact["kind"]> = Extract<StatsFact, {
|
|
3
|
+
kind: K;
|
|
4
|
+
}>;
|
|
5
|
+
export type Count = {
|
|
6
|
+
at: Date;
|
|
7
|
+
key: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const ofKind: <K extends StatsFact["kind"]>(facts: StatsFact[], kind: K) => Fact<K>[];
|
|
10
|
+
/** `{Days: [{Date, <key>: n}], <key>: total}`, days ascending. */
|
|
11
|
+
export declare function series(counts: Count[], documentedKeys: readonly string[]): {
|
|
12
|
+
Days: {
|
|
13
|
+
Date: string;
|
|
14
|
+
}[];
|
|
15
|
+
};
|
|
16
|
+
export declare const uniqueOpens: (facts: StatsFact[]) => ({
|
|
17
|
+
ServerID: number;
|
|
18
|
+
MessageStream: string;
|
|
19
|
+
Tag: string | null;
|
|
20
|
+
at: Date;
|
|
21
|
+
} & {
|
|
22
|
+
kind: "open";
|
|
23
|
+
MessageID: string;
|
|
24
|
+
Recipient: string;
|
|
25
|
+
first: boolean;
|
|
26
|
+
platform: import("../../state/types.ts").OpenEvent["Platform"];
|
|
27
|
+
client: string | null;
|
|
28
|
+
readSeconds: number;
|
|
29
|
+
})[];
|
|
30
|
+
export declare const opensCounts: (facts: StatsFact[]) => Count[];
|
|
31
|
+
export declare const clicksCounts: (facts: StatsFact[]) => Count[];
|
|
32
|
+
/**
|
|
33
|
+
* `/stats/outbound` (refs/api_stats-api.md:43-83). The example's arithmetic gives the rules:
|
|
34
|
+
* `Bounced` is hard + soft + transient without SMTP API errors (64 = 12 + 36 + 16, :209-232), and
|
|
35
|
+
* rates are percentages of `Sent` cut to 3 decimals (10.406 = 64 / 615). `WithReadTimeRecorded` comes
|
|
36
|
+
* from the SDK models (sdk/postmark.js/src/client/models/stats/Stats.ts:18). The `With*Recorded`
|
|
37
|
+
* keys count unique opens (INFERRED).
|
|
38
|
+
*/
|
|
39
|
+
export declare function overview(facts: StatsFact[]): {
|
|
40
|
+
Sent: number;
|
|
41
|
+
Bounced: number;
|
|
42
|
+
SMTPApiErrors: number;
|
|
43
|
+
BounceRate: number;
|
|
44
|
+
SpamComplaints: number;
|
|
45
|
+
SpamComplaintsRate: number;
|
|
46
|
+
Opens: number;
|
|
47
|
+
UniqueOpens: number;
|
|
48
|
+
Tracked: number;
|
|
49
|
+
WithLinkTracking: number;
|
|
50
|
+
WithOpenTracking: number;
|
|
51
|
+
TotalTrackedLinksSent: number;
|
|
52
|
+
UniqueLinksClicked: number;
|
|
53
|
+
TotalClicks: number;
|
|
54
|
+
WithClientRecorded: number;
|
|
55
|
+
WithPlatformRecorded: number;
|
|
56
|
+
WithReadTimeRecorded: number;
|
|
57
|
+
};
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { formatEasternDate } from "../../time.js";
|
|
2
|
+
export const ofKind = (facts, kind) => facts.filter((f) => f.kind === kind);
|
|
3
|
+
/** `{Days: [{Date, <key>: n}], <key>: total}`, days ascending. */
|
|
4
|
+
export function series(counts, documentedKeys) {
|
|
5
|
+
const days = new Map();
|
|
6
|
+
const totals = new Map(documentedKeys.map((k) => [k, 0]));
|
|
7
|
+
for (const { at, key } of counts) {
|
|
8
|
+
const date = formatEasternDate(at);
|
|
9
|
+
const day = days.get(date) ?? new Map();
|
|
10
|
+
days.set(date, day);
|
|
11
|
+
day.set(key, (day.get(key) ?? 0) + 1);
|
|
12
|
+
totals.set(key, (totals.get(key) ?? 0) + 1);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
Days: [...days.keys()].sort().map((date) => ({
|
|
16
|
+
Date: date,
|
|
17
|
+
...Object.fromEntries(days.get(date)),
|
|
18
|
+
})),
|
|
19
|
+
...Object.fromEntries(totals),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// "Unique" opens are per recipient per message; unique clicks per recipient, message and link
|
|
23
|
+
// (refs/api_stats-api.md:419, :648). A unique counts on the day of the first event ever, so a range
|
|
24
|
+
// that starts after it has none (INFERRED).
|
|
25
|
+
export const uniqueOpens = (facts) => ofKind(facts, "open").filter((o) => o.first);
|
|
26
|
+
const withUnique = (facts, key) => facts.flatMap((f) => [{ at: f.at, key }, ...(f.first ? [{ at: f.at, key: "Unique" }] : [])]);
|
|
27
|
+
export const opensCounts = (facts) => withUnique(ofKind(facts, "open"), "Opens");
|
|
28
|
+
export const clicksCounts = (facts) => withUnique(ofKind(facts, "click"), "Clicks");
|
|
29
|
+
// Truncated, not rounded: 64 / 615 is 10.4065 and the doc shows 10.406.
|
|
30
|
+
const rate = (part, whole) => whole === 0 ? 0 : Math.floor((part * 100_000) / whole) / 1000;
|
|
31
|
+
/**
|
|
32
|
+
* `/stats/outbound` (refs/api_stats-api.md:43-83). The example's arithmetic gives the rules:
|
|
33
|
+
* `Bounced` is hard + soft + transient without SMTP API errors (64 = 12 + 36 + 16, :209-232), and
|
|
34
|
+
* rates are percentages of `Sent` cut to 3 decimals (10.406 = 64 / 615). `WithReadTimeRecorded` comes
|
|
35
|
+
* from the SDK models (sdk/postmark.js/src/client/models/stats/Stats.ts:18). The `With*Recorded`
|
|
36
|
+
* keys count unique opens (INFERRED).
|
|
37
|
+
*/
|
|
38
|
+
export function overview(facts) {
|
|
39
|
+
const sent = ofKind(facts, "sent");
|
|
40
|
+
const bounces = ofKind(facts, "bounce");
|
|
41
|
+
const Sent = sent.length;
|
|
42
|
+
const Bounced = bounces.filter((b) => b.type !== "SMTPApiError").length;
|
|
43
|
+
const SpamComplaints = ofKind(facts, "spamComplaint").length;
|
|
44
|
+
const opens = uniqueOpens(facts);
|
|
45
|
+
const clicks = clicksCounts(facts);
|
|
46
|
+
return {
|
|
47
|
+
Sent,
|
|
48
|
+
Bounced,
|
|
49
|
+
SMTPApiErrors: bounces.length - Bounced,
|
|
50
|
+
BounceRate: rate(Bounced, Sent),
|
|
51
|
+
SpamComplaints,
|
|
52
|
+
SpamComplaintsRate: rate(SpamComplaints, Sent),
|
|
53
|
+
Opens: ofKind(facts, "open").length,
|
|
54
|
+
UniqueOpens: opens.length,
|
|
55
|
+
Tracked: sent.filter((s) => s.openTracking || s.linkTracking).length,
|
|
56
|
+
WithLinkTracking: sent.filter((s) => s.linkTracking).length,
|
|
57
|
+
WithOpenTracking: sent.filter((s) => s.openTracking).length,
|
|
58
|
+
TotalTrackedLinksSent: sent.reduce((sum, s) => sum + s.trackedLinks, 0),
|
|
59
|
+
UniqueLinksClicked: clicks.filter((c) => c.key === "Unique").length,
|
|
60
|
+
TotalClicks: clicks.filter((c) => c.key === "Clicks").length,
|
|
61
|
+
WithClientRecorded: opens.filter((o) => o.client !== null).length,
|
|
62
|
+
WithPlatformRecorded: opens.filter((o) => o.platform !== null && o.platform !== "Unknown")
|
|
63
|
+
.length,
|
|
64
|
+
WithReadTimeRecorded: opens.filter((o) => o.readSeconds > 0).length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { apiError } from "../../errors.js";
|
|
2
|
+
import { parseQueryDate } from "../../http/normalize.js";
|
|
3
|
+
import { defineRoute } from "../../http/routes.js";
|
|
4
|
+
import { findStream } from "../../state/servers.js";
|
|
5
|
+
import { formatEasternDate } from "../../time.js";
|
|
6
|
+
import { clicksCounts, ofKind, opensCounts, overview, series, uniqueOpens, } from "./aggregate.js";
|
|
7
|
+
/**
|
|
8
|
+
* The server's facts that pass `tag`, `messagestream` (default: every stream) and the inclusive
|
|
9
|
+
* Eastern `fromdate`/`todate` days (refs/api_stats-api.md:34-37). Errors: refs/api_overview.md:197-201.
|
|
10
|
+
*/
|
|
11
|
+
function facts({ store, clock, auth, query }) {
|
|
12
|
+
const day = (name) => {
|
|
13
|
+
const raw = query.get(name);
|
|
14
|
+
if (raw === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const parsed = parseQueryDate(raw);
|
|
17
|
+
if (parsed === undefined)
|
|
18
|
+
throw apiError(900, { family: "stats" });
|
|
19
|
+
return formatEasternDate(parsed.instant);
|
|
20
|
+
};
|
|
21
|
+
const from = day("fromdate");
|
|
22
|
+
const to = day("todate");
|
|
23
|
+
const yearAgo = clock.now();
|
|
24
|
+
yearAgo.setUTCFullYear(yearAgo.getUTCFullYear() - 1);
|
|
25
|
+
if (from !== undefined && from < formatEasternDate(yearAgo)) {
|
|
26
|
+
throw apiError(1500, { family: "stats" });
|
|
27
|
+
}
|
|
28
|
+
if (from !== undefined && to !== undefined && from > to) {
|
|
29
|
+
throw apiError(1502, { family: "stats" });
|
|
30
|
+
}
|
|
31
|
+
const stream = query.get("messagestream");
|
|
32
|
+
if (stream !== undefined && findStream(store.state, auth, stream) === undefined) {
|
|
33
|
+
throw apiError(1226, { family: "stats" });
|
|
34
|
+
}
|
|
35
|
+
const tag = query.get("tag");
|
|
36
|
+
return store.state.stats.filter((f) => {
|
|
37
|
+
const date = formatEasternDate(f.at);
|
|
38
|
+
return (f.ServerID === auth.server.ID &&
|
|
39
|
+
(stream === undefined || f.MessageStream === stream) &&
|
|
40
|
+
(tag === undefined || f.Tag === tag) &&
|
|
41
|
+
(from === undefined || date >= from) &&
|
|
42
|
+
(to === undefined || date <= to));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const stat = (path, body) => defineRoute({
|
|
46
|
+
method: "GET",
|
|
47
|
+
path: `/stats/outbound${path}`,
|
|
48
|
+
auth: "server",
|
|
49
|
+
handler: (ctx) => body(facts(ctx)),
|
|
50
|
+
});
|
|
51
|
+
const each = (facts, key) => facts.flatMap((f) => {
|
|
52
|
+
const k = key(f);
|
|
53
|
+
return k === null ? [] : [{ at: f.at, key: k }];
|
|
54
|
+
});
|
|
55
|
+
stat("", overview);
|
|
56
|
+
stat("/sends", (f) => series(each(ofKind(f, "sent"), () => "Sent"), ["Sent"]));
|
|
57
|
+
stat("/bounces", (f) => series(each(ofKind(f, "bounce"), (b) => b.type), ["HardBounce", "SMTPApiError", "SoftBounce", "Transient"]));
|
|
58
|
+
stat("/spam", (f) => series(each(ofKind(f, "spamComplaint"), () => "SpamComplaint"), ["SpamComplaint"]));
|
|
59
|
+
stat("/tracked", (f) => series(each(ofKind(f, "sent"), (s) => (s.openTracking || s.linkTracking ? "Tracked" : null)), ["Tracked"]));
|
|
60
|
+
stat("/opens", (f) => series(opensCounts(f), ["Opens", "Unique"]));
|
|
61
|
+
// Platforms and email clients count unique opens; an open without a platform is `Unknown`
|
|
62
|
+
// (INFERRED from "users that opened", refs/api_stats-api.md:500-503).
|
|
63
|
+
stat("/opens/platforms", (f) => series(each(uniqueOpens(f), (o) => o.platform ?? "Unknown"), ["Desktop", "Mobile", "Unknown", "WebMail"]));
|
|
64
|
+
stat("/opens/emailclients", (f) => series(each(uniqueOpens(f), (o) => o.client), []));
|
|
65
|
+
// No doc gives this body. Its shape comes from the SDK clients (docs/06 §2.2). No source names the
|
|
66
|
+
// buckets: postmock keys each unique open with a read time by its whole seconds, "5" (INFERRED).
|
|
67
|
+
stat("/opens/readtimes", (f) => series(each(uniqueOpens(f), (o) => (o.readSeconds > 0 ? String(o.readSeconds) : null)), []));
|
|
68
|
+
stat("/clicks", (f) => series(clicksCounts(f), ["Clicks", "Unique"]));
|
|
69
|
+
// Browser, platform and location keys count every click (refs/api_stats-api.md:729, :800-802).
|
|
70
|
+
stat("/clicks/browserfamilies", (f) => series(each(ofKind(f, "click"), (c) => c.browser), []));
|
|
71
|
+
stat("/clicks/platforms", (f) => series(each(ofKind(f, "click"), (c) => c.platform ?? "Unknown"), ["Desktop", "Mobile", "Unknown"]));
|
|
72
|
+
stat("/clicks/location", (f) => series(each(ofKind(f, "click"), (c) => c.location), ["HTML", "Text"]));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiError } from "../../errors.js";
|
|
3
|
+
import { inclusiveUpperBound, parseBody, queryDate } from "../../http/normalize.js";
|
|
4
|
+
import { Unsupported } from "../../http/respond.js";
|
|
5
|
+
import { defineRoute } from "../../http/routes.js";
|
|
6
|
+
import { deleteSuppression, suppressByCustomer, } from "../../recipients/transitions.js";
|
|
7
|
+
import { formatTimestamp } from "../../time.js";
|
|
8
|
+
import { liveStream } from "../message-streams/streams.js";
|
|
9
|
+
/** The path stream, or 1226. Archived and inbound streams are not captured (Q15). */
|
|
10
|
+
function streamOf(ctx) {
|
|
11
|
+
const stream = liveStream(ctx.store.state, ctx.auth.server.ID, ctx.params.stream);
|
|
12
|
+
if (stream.ArchivedAt !== null) {
|
|
13
|
+
throw new Unsupported("suppressions of an archived stream: not captured (docs/04 Q15)");
|
|
14
|
+
}
|
|
15
|
+
if (stream.MessageStreamType === "Inbound") {
|
|
16
|
+
throw new Unsupported("suppressions of an inbound stream: not captured");
|
|
17
|
+
}
|
|
18
|
+
return stream;
|
|
19
|
+
}
|
|
20
|
+
const REASONS = ["HardBounce", "SpamComplaint", "ManualSuppression"];
|
|
21
|
+
const ORIGINS = ["Recipient", "Customer", "Admin"];
|
|
22
|
+
const dumpQuery = z.object({
|
|
23
|
+
SuppressionReason: z.string().optional(),
|
|
24
|
+
Origin: z.string().optional(),
|
|
25
|
+
fromdate: queryDate.optional(),
|
|
26
|
+
todate: queryDate.optional(),
|
|
27
|
+
EmailAddress: z.string().optional(),
|
|
28
|
+
});
|
|
29
|
+
defineRoute({
|
|
30
|
+
method: "GET",
|
|
31
|
+
path: "/message-streams/:stream/suppressions/dump",
|
|
32
|
+
auth: "server",
|
|
33
|
+
handler: (ctx) => {
|
|
34
|
+
const stream = streamOf(ctx);
|
|
35
|
+
const parsed = ctx.query.pick(dumpQuery);
|
|
36
|
+
if (!parsed.success)
|
|
37
|
+
throw new Unsupported("invalid suppression dump date: error not captured");
|
|
38
|
+
const q = parsed.data;
|
|
39
|
+
// Enum values match with case kept (INFERRED); count and offset are ignored (docs/04 §2.2).
|
|
40
|
+
if (q.SuppressionReason !== undefined &&
|
|
41
|
+
!REASONS.includes(q.SuppressionReason)) {
|
|
42
|
+
throw apiError(1404);
|
|
43
|
+
}
|
|
44
|
+
if (q.Origin !== undefined && !ORIGINS.includes(q.Origin)) {
|
|
45
|
+
throw apiError(1405);
|
|
46
|
+
}
|
|
47
|
+
const from = q.fromdate?.instant.getTime();
|
|
48
|
+
const to = q.todate && inclusiveUpperBound(q.todate).getTime();
|
|
49
|
+
const rows = [...ctx.store.state.suppressions.values()].filter((s) => s.ServerID === stream.ServerID &&
|
|
50
|
+
s.MessageStream === stream.ID &&
|
|
51
|
+
(q.SuppressionReason === undefined || s.SuppressionReason === q.SuppressionReason) &&
|
|
52
|
+
(q.Origin === undefined || s.Origin === q.Origin) &&
|
|
53
|
+
(from === undefined || s.CreatedAt.getTime() >= from) &&
|
|
54
|
+
(to === undefined || s.CreatedAt.getTime() < to) &&
|
|
55
|
+
// Exact match without case (INFERRED, Q5).
|
|
56
|
+
(q.EmailAddress === undefined ||
|
|
57
|
+
s.EmailAddress.toLowerCase() === q.EmailAddress.toLowerCase()));
|
|
58
|
+
return {
|
|
59
|
+
Suppressions: rows.map((s) => ({
|
|
60
|
+
EmailAddress: s.EmailAddress,
|
|
61
|
+
SuppressionReason: s.SuppressionReason,
|
|
62
|
+
Origin: s.Origin,
|
|
63
|
+
CreatedAt: formatTimestamp(s.CreatedAt),
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const MAX_ITEMS = 50; // refs/api_suppressions-api.md:114
|
|
69
|
+
const changeBody = z.object({
|
|
70
|
+
Suppressions: z.array(z.object({ EmailAddress: z.string() })),
|
|
71
|
+
});
|
|
72
|
+
/** Applies one change per item, in request order (docs/04 §2.4). */
|
|
73
|
+
async function changeAll(ctx, change) {
|
|
74
|
+
const stream = streamOf(ctx);
|
|
75
|
+
const parsed = parseBody(changeBody, ctx.body);
|
|
76
|
+
if (!parsed.success)
|
|
77
|
+
throw apiError(1409);
|
|
78
|
+
const items = parsed.data.Suppressions;
|
|
79
|
+
if (items.length > MAX_ITEMS)
|
|
80
|
+
throw apiError(1410);
|
|
81
|
+
const Suppressions = [];
|
|
82
|
+
for (const { EmailAddress } of items) {
|
|
83
|
+
Suppressions.push(await change(ctx, stream.ServerID, stream.ID, EmailAddress));
|
|
84
|
+
}
|
|
85
|
+
return { Suppressions };
|
|
86
|
+
}
|
|
87
|
+
defineRoute({
|
|
88
|
+
method: "POST",
|
|
89
|
+
path: "/message-streams/:stream/suppressions",
|
|
90
|
+
auth: "server",
|
|
91
|
+
handler: (ctx) => changeAll(ctx, suppressByCustomer),
|
|
92
|
+
});
|
|
93
|
+
defineRoute({
|
|
94
|
+
method: "POST",
|
|
95
|
+
path: "/message-streams/:stream/suppressions/delete",
|
|
96
|
+
auth: "server",
|
|
97
|
+
handler: (ctx) => changeAll(ctx, deleteSuppression),
|
|
98
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type TemplateError } from "../../render/mustachio.ts";
|
|
2
|
+
import type { Template } from "../../state/types.ts";
|
|
3
|
+
import { type Content } from "./templates.ts";
|
|
4
|
+
export type RenderOutcome = {
|
|
5
|
+
ok: true;
|
|
6
|
+
content: Content;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
errors: TemplateError[];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Renders message content with a model, inside a layout when given: each layout body gets the
|
|
13
|
+
* rendered template body at `{{{ @content }}}` (sdk/postmark-dotnet/src/Postmark.Tests/ClientTemplateTests.cs:219-221).
|
|
14
|
+
* A part the template lacks stays null; the layout alone renders nothing (INFERRED).
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderContent(content: Content, layout: Template | null, model: Record<string, unknown>, inlineCss: boolean): RenderOutcome;
|
|
17
|
+
/**
|
|
18
|
+
* `InlineCss` (default true) moves `<style>` rules into `style` attributes (refs/api_templates-api.md:47).
|
|
19
|
+
* The inliner's output is not captured, so HTML with a style block fails loudly.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkInlineCss(html: string | null, inlineCss: boolean): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Unsupported } from "../../http/respond.js";
|
|
2
|
+
import { parseTemplate, renderTemplate } from "../../render/mustachio.js";
|
|
3
|
+
import { CONTENT_PARTS } from "./templates.js";
|
|
4
|
+
/**
|
|
5
|
+
* Renders message content with a model, inside a layout when given: each layout body gets the
|
|
6
|
+
* rendered template body at `{{{ @content }}}` (sdk/postmark-dotnet/src/Postmark.Tests/ClientTemplateTests.cs:219-221).
|
|
7
|
+
* A part the template lacks stays null; the layout alone renders nothing (INFERRED).
|
|
8
|
+
*/
|
|
9
|
+
export function renderContent(content, layout, model, inlineCss) {
|
|
10
|
+
const out = { Subject: null, HtmlBody: null, TextBody: null };
|
|
11
|
+
for (const part of CONTENT_PARTS) {
|
|
12
|
+
const source = content[part];
|
|
13
|
+
if (source === null)
|
|
14
|
+
continue;
|
|
15
|
+
const parsed = parseTemplate(source);
|
|
16
|
+
if (!parsed.ok)
|
|
17
|
+
return parsed;
|
|
18
|
+
let rendered = renderTemplate(parsed.template, model);
|
|
19
|
+
const frame = part === "Subject" ? null : (layout?.[part] ?? null);
|
|
20
|
+
if (frame !== null) {
|
|
21
|
+
const parsedFrame = parseTemplate(frame);
|
|
22
|
+
if (!parsedFrame.ok)
|
|
23
|
+
return parsedFrame;
|
|
24
|
+
rendered = renderTemplate(parsedFrame.template, model, rendered);
|
|
25
|
+
}
|
|
26
|
+
out[part] = rendered;
|
|
27
|
+
}
|
|
28
|
+
checkInlineCss(out.HtmlBody, inlineCss);
|
|
29
|
+
return { ok: true, content: out };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `InlineCss` (default true) moves `<style>` rules into `style` attributes (refs/api_templates-api.md:47).
|
|
33
|
+
* The inliner's output is not captured, so HTML with a style block fails loudly.
|
|
34
|
+
*/
|
|
35
|
+
export function checkInlineCss(html, inlineCss) {
|
|
36
|
+
if (inlineCss && html !== null && /<style[\s>]/i.test(html)) {
|
|
37
|
+
throw new Unsupported("CSS inlining of <style> blocks is not built; send InlineCss: false");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|