@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.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/dist/seeds/conformance/00-core.d.ts +4 -0
  4. package/dist/seeds/conformance/00-core.js +12 -0
  5. package/dist/seeds/conformance/20-bounces.d.ts +3 -0
  6. package/dist/seeds/conformance/20-bounces.js +108 -0
  7. package/dist/seeds/conformance/40-messages.d.ts +3 -0
  8. package/dist/seeds/conformance/40-messages.js +144 -0
  9. package/dist/seeds/conformance/50-server-color.d.ts +8 -0
  10. package/dist/seeds/conformance/50-server-color.js +13 -0
  11. package/dist/seeds/conformance/70-account.d.ts +9 -0
  12. package/dist/seeds/conformance/70-account.js +29 -0
  13. package/dist/seeds/conformance/80-templates.d.ts +4 -0
  14. package/dist/seeds/conformance/80-templates.js +23 -0
  15. package/dist/seeds/conformance.d.ts +2 -0
  16. package/dist/seeds/conformance.js +3 -0
  17. package/dist/seeds/empty.d.ts +4 -0
  18. package/dist/seeds/empty.js +3 -0
  19. package/dist/seeds/lib/conformance.d.ts +16 -0
  20. package/dist/seeds/lib/conformance.js +18 -0
  21. package/dist/seeds/lib/history.d.ts +27 -0
  22. package/dist/seeds/lib/history.js +129 -0
  23. package/dist/seeds/lib/read-server.d.ts +10 -0
  24. package/dist/seeds/lib/read-server.js +10 -0
  25. package/dist/seeds/lib/template-server.d.ts +13 -0
  26. package/dist/seeds/lib/template-server.js +13 -0
  27. package/dist/src/api/account/authentication.d.ts +39 -0
  28. package/dist/src/api/account/authentication.js +97 -0
  29. package/dist/src/api/account/domains.d.ts +59 -0
  30. package/dist/src/api/account/domains.js +78 -0
  31. package/dist/src/api/account/paging.d.ts +12 -0
  32. package/dist/src/api/account/paging.js +35 -0
  33. package/dist/src/api/account/push.d.ts +14 -0
  34. package/dist/src/api/account/push.js +84 -0
  35. package/dist/src/api/account/routes.d.ts +1 -0
  36. package/dist/src/api/account/routes.js +288 -0
  37. package/dist/src/api/account/senders.d.ts +54 -0
  38. package/dist/src/api/account/senders.js +87 -0
  39. package/dist/src/api/account/servers.d.ts +40 -0
  40. package/dist/src/api/account/servers.js +148 -0
  41. package/dist/src/api/bounces/json.d.ts +25 -0
  42. package/dist/src/api/bounces/json.js +27 -0
  43. package/dist/src/api/bounces/routes.d.ts +1 -0
  44. package/dist/src/api/bounces/routes.js +127 -0
  45. package/dist/src/api/bulk/bulk.d.ts +43 -0
  46. package/dist/src/api/bulk/bulk.js +81 -0
  47. package/dist/src/api/bulk/routes.d.ts +1 -0
  48. package/dist/src/api/bulk/routes.js +252 -0
  49. package/dist/src/api/data-removals/routes.d.ts +1 -0
  50. package/dist/src/api/data-removals/routes.js +59 -0
  51. package/dist/src/api/email/json.d.ts +11 -0
  52. package/dist/src/api/email/json.js +33 -0
  53. package/dist/src/api/email/routes.d.ts +1 -0
  54. package/dist/src/api/email/routes.js +64 -0
  55. package/dist/src/api/inbound/routes.d.ts +1 -0
  56. package/dist/src/api/inbound/routes.js +40 -0
  57. package/dist/src/api/index.d.ts +1 -0
  58. package/dist/src/api/index.js +3 -0
  59. package/dist/src/api/message-streams/routes.d.ts +1 -0
  60. package/dist/src/api/message-streams/routes.js +211 -0
  61. package/dist/src/api/message-streams/streams.d.ts +28 -0
  62. package/dist/src/api/message-streams/streams.js +50 -0
  63. package/dist/src/api/messages/json.d.ts +149 -0
  64. package/dist/src/api/messages/json.js +114 -0
  65. package/dist/src/api/messages/query.d.ts +19 -0
  66. package/dist/src/api/messages/query.js +64 -0
  67. package/dist/src/api/messages/routes.d.ts +1 -0
  68. package/dist/src/api/messages/routes.js +203 -0
  69. package/dist/src/api/server/json.d.ts +26 -0
  70. package/dist/src/api/server/json.js +25 -0
  71. package/dist/src/api/server/routes.d.ts +1 -0
  72. package/dist/src/api/server/routes.js +17 -0
  73. package/dist/src/api/stats/aggregate.d.ts +58 -0
  74. package/dist/src/api/stats/aggregate.js +66 -0
  75. package/dist/src/api/stats/routes.d.ts +1 -0
  76. package/dist/src/api/stats/routes.js +72 -0
  77. package/dist/src/api/suppressions/routes.d.ts +1 -0
  78. package/dist/src/api/suppressions/routes.js +98 -0
  79. package/dist/src/api/templates/content.d.ts +21 -0
  80. package/dist/src/api/templates/content.js +39 -0
  81. package/dist/src/api/templates/routes.d.ts +1 -0
  82. package/dist/src/api/templates/routes.js +164 -0
  83. package/dist/src/api/templates/send.d.ts +13 -0
  84. package/dist/src/api/templates/send.js +105 -0
  85. package/dist/src/api/templates/templates.d.ts +100 -0
  86. package/dist/src/api/templates/templates.js +218 -0
  87. package/dist/src/api/templates/validate.d.ts +15 -0
  88. package/dist/src/api/templates/validate.js +77 -0
  89. package/dist/src/api/triggers/routes.d.ts +1 -0
  90. package/dist/src/api/triggers/routes.js +62 -0
  91. package/dist/src/api/webhooks/routes.d.ts +14 -0
  92. package/dist/src/api/webhooks/routes.js +192 -0
  93. package/dist/src/config.d.ts +17 -0
  94. package/dist/src/config.js +95 -0
  95. package/dist/src/control/app.d.ts +5 -0
  96. package/dist/src/control/app.js +42 -0
  97. package/dist/src/control/endpoints/account.d.ts +1 -0
  98. package/dist/src/control/endpoints/account.js +68 -0
  99. package/dist/src/control/endpoints/bulk.d.ts +1 -0
  100. package/dist/src/control/endpoints/bulk.js +29 -0
  101. package/dist/src/control/endpoints/core.d.ts +1 -0
  102. package/dist/src/control/endpoints/core.js +104 -0
  103. package/dist/src/control/endpoints/inbound.d.ts +1 -0
  104. package/dist/src/control/endpoints/inbound.js +61 -0
  105. package/dist/src/control/endpoints/recipients.d.ts +1 -0
  106. package/dist/src/control/endpoints/recipients.js +136 -0
  107. package/dist/src/control/endpoints/smtp.d.ts +1 -0
  108. package/dist/src/control/endpoints/smtp.js +74 -0
  109. package/dist/src/control/endpoints/tracking.d.ts +1 -0
  110. package/dist/src/control/endpoints/tracking.js +95 -0
  111. package/dist/src/control/endpoints/webhooks.d.ts +1 -0
  112. package/dist/src/control/endpoints/webhooks.js +36 -0
  113. package/dist/src/control/index.d.ts +1 -0
  114. package/dist/src/control/index.js +3 -0
  115. package/dist/src/control/registry.d.ts +25 -0
  116. package/dist/src/control/registry.js +19 -0
  117. package/dist/src/control/seed.d.ts +11 -0
  118. package/dist/src/control/seed.js +32 -0
  119. package/dist/src/control/seeding.d.ts +10 -0
  120. package/dist/src/control/seeding.js +27 -0
  121. package/dist/src/discover.d.ts +16 -0
  122. package/dist/src/discover.js +49 -0
  123. package/dist/src/errors.d.ts +49 -0
  124. package/dist/src/errors.js +701 -0
  125. package/dist/src/events.d.ts +49 -0
  126. package/dist/src/events.js +19 -0
  127. package/dist/src/http/app.d.ts +8 -0
  128. package/dist/src/http/app.js +51 -0
  129. package/dist/src/http/auth.d.ts +7 -0
  130. package/dist/src/http/auth.js +32 -0
  131. package/dist/src/http/faults.d.ts +8 -0
  132. package/dist/src/http/faults.js +33 -0
  133. package/dist/src/http/normalize.d.ts +57 -0
  134. package/dist/src/http/normalize.js +225 -0
  135. package/dist/src/http/respond.d.ts +23 -0
  136. package/dist/src/http/respond.js +37 -0
  137. package/dist/src/http/routes.d.ts +60 -0
  138. package/dist/src/http/routes.js +64 -0
  139. package/dist/src/inbound/deliver.d.ts +9 -0
  140. package/dist/src/inbound/deliver.js +34 -0
  141. package/dist/src/inbound/parse.d.ts +21 -0
  142. package/dist/src/inbound/parse.js +71 -0
  143. package/dist/src/inbound/receive.d.ts +17 -0
  144. package/dist/src/inbound/receive.js +89 -0
  145. package/dist/src/main.d.ts +2 -0
  146. package/dist/src/main.js +14 -0
  147. package/dist/src/mime/compose.d.ts +33 -0
  148. package/dist/src/mime/compose.js +58 -0
  149. package/dist/src/pipeline/addresses.d.ts +6 -0
  150. package/dist/src/pipeline/addresses.js +38 -0
  151. package/dist/src/pipeline/inactive.d.ts +8 -0
  152. package/dist/src/pipeline/inactive.js +20 -0
  153. package/dist/src/pipeline/submit.d.ts +138 -0
  154. package/dist/src/pipeline/submit.js +415 -0
  155. package/dist/src/plugins/message-events.d.ts +7 -0
  156. package/dist/src/plugins/message-events.js +76 -0
  157. package/dist/src/plugins/smtp.d.ts +15 -0
  158. package/dist/src/plugins/smtp.js +42 -0
  159. package/dist/src/plugins/stats.d.ts +7 -0
  160. package/dist/src/plugins/stats.js +78 -0
  161. package/dist/src/plugins/webhooks.d.ts +4 -0
  162. package/dist/src/plugins/webhooks.js +80 -0
  163. package/dist/src/plugins.d.ts +20 -0
  164. package/dist/src/plugins.js +9 -0
  165. package/dist/src/recipients/test-bounces.d.ts +15 -0
  166. package/dist/src/recipients/test-bounces.js +31 -0
  167. package/dist/src/recipients/transitions.d.ts +57 -0
  168. package/dist/src/recipients/transitions.js +279 -0
  169. package/dist/src/render/mustachio.d.ts +60 -0
  170. package/dist/src/render/mustachio.js +265 -0
  171. package/dist/src/runtime.d.ts +12 -0
  172. package/dist/src/runtime.js +11 -0
  173. package/dist/src/server.d.ts +25 -0
  174. package/dist/src/server.js +49 -0
  175. package/dist/src/smtp/auth.d.ts +35 -0
  176. package/dist/src/smtp/auth.js +66 -0
  177. package/dist/src/smtp/listener.d.ts +16 -0
  178. package/dist/src/smtp/listener.js +121 -0
  179. package/dist/src/smtp/mime.d.ts +36 -0
  180. package/dist/src/smtp/mime.js +181 -0
  181. package/dist/src/smtp/receive.d.ts +21 -0
  182. package/dist/src/smtp/receive.js +89 -0
  183. package/dist/src/smtp/reply.d.ts +14 -0
  184. package/dist/src/smtp/reply.js +24 -0
  185. package/dist/src/state/clock.d.ts +35 -0
  186. package/dist/src/state/clock.js +107 -0
  187. package/dist/src/state/ids.d.ts +6 -0
  188. package/dist/src/state/ids.js +7 -0
  189. package/dist/src/state/servers.d.ts +27 -0
  190. package/dist/src/state/servers.js +101 -0
  191. package/dist/src/state/store.d.ts +57 -0
  192. package/dist/src/state/store.js +98 -0
  193. package/dist/src/state/suppressions.d.ts +9 -0
  194. package/dist/src/state/suppressions.js +8 -0
  195. package/dist/src/state/types.d.ts +530 -0
  196. package/dist/src/state/types.js +41 -0
  197. package/dist/src/time.d.ts +11 -0
  198. package/dist/src/time.js +48 -0
  199. package/dist/src/tracking.d.ts +53 -0
  200. package/dist/src/tracking.js +150 -0
  201. package/dist/src/webhooks/deliver.d.ts +60 -0
  202. package/dist/src/webhooks/deliver.js +172 -0
  203. package/dist/src/webhooks/egress.d.ts +19 -0
  204. package/dist/src/webhooks/egress.js +40 -0
  205. package/dist/src/webhooks/outbound.d.ts +26 -0
  206. package/dist/src/webhooks/outbound.js +47 -0
  207. package/dist/src/webhooks/payloads.d.ts +111 -0
  208. package/dist/src/webhooks/payloads.js +128 -0
  209. package/package.json +55 -0
@@ -0,0 +1,415 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { errorBody } from "../errors.js";
4
+ import { absent, base64, canonicalizeKeys, objectOrEmptyArray } from "../http/normalize.js";
5
+ import { Unsupported } from "../http/respond.js";
6
+ import { composeMime } from "../mime/compose.js";
7
+ import { testBounces } from "../recipients/test-bounces.js";
8
+ import { newMessageId } from "../state/ids.js";
9
+ import { findStream } from "../state/servers.js";
10
+ import { suppressedAddresses } from "../state/suppressions.js";
11
+ import { parseAddressList } from "./addresses.js";
12
+ import { inactiveRecipientsError } from "./inactive.js";
13
+ // R9: null and "" are absent for optional values (docs/08; php, dotnet, gem send them).
14
+ const text = absent(z.string());
15
+ const nullable = (schema) => z.preprocess((v) => (v === null ? undefined : v), schema.optional());
16
+ const TRACK_LINKS = z.enum(["None", "HtmlAndText", "HtmlOnly", "TextOnly"]);
17
+ /** Field shape checks, in draft field order. REST sends JSON types; SMTP sends header text (docs/07 §1.3). */
18
+ const draftSchema = (channel) => z.object({
19
+ From: text,
20
+ To: text,
21
+ Cc: text,
22
+ Bcc: text,
23
+ ReplyTo: text,
24
+ Subject: text,
25
+ HtmlBody: text,
26
+ TextBody: text,
27
+ Tag: text,
28
+ MessageStream: text,
29
+ Headers: nullable(z.array(z.object({ Name: z.string(), Value: z.string() }))),
30
+ Attachments: nullable(z.array(z.object({
31
+ Name: z.string(),
32
+ Content: base64,
33
+ ContentType: z.string(),
34
+ // postmark.js sends null, php and gem a name or a `cid:` url (docs/03 §1.7).
35
+ ContentID: z.string().nullable().optional(),
36
+ }))),
37
+ // php sends an empty metadata array as `[]` (sdk/postmark-php/src/Postmark/PostmarkClient.php:97,113).
38
+ Metadata: nullable(objectOrEmptyArray(z.record(z.string(), z.string()))),
39
+ // SMTP: `X-PM-TrackOpens: true`; absent or false means no tracking (docs/07 §1.3).
40
+ TrackOpens: channel === "rest"
41
+ ? absent(z.boolean())
42
+ : absent(z
43
+ .string()
44
+ .regex(/^(true|false)$/i)
45
+ .transform((v) => v.toLowerCase() === "true")),
46
+ TrackLinks: absent(TRACK_LINKS),
47
+ });
48
+ /**
49
+ * The draft of one JSON message object. Keys match without case at every level, `ContentId` too
50
+ * (docs/08 R8); unknown keys are ignored (R12). Every JSON channel builds its draft here.
51
+ */
52
+ export function draftFromJson(body) {
53
+ const canonical = canonicalizeKeys(draftSchema("rest"), body);
54
+ const keys = Object.keys(draftSchema("rest").shape);
55
+ return Object.fromEntries(keys.map((key) => [key, canonical[key]]));
56
+ }
57
+ // Limits: docs/03 §1.2, docs/07 §3. Lengths count UTF-16 code units (docs/03 §1.9).
58
+ const MB = 1024 * 1024;
59
+ const MAX_BODY_BYTES = 5 * MB;
60
+ const MAX_MESSAGE_BYTES = 10 * MB;
61
+ const MAX_RECIPIENTS = 50;
62
+ const MAX_FROM = 255;
63
+ const MAX_SUBJECT = 2000;
64
+ const MAX_TAG = 1000;
65
+ const MAX_METADATA_FIELDS = 10;
66
+ const MAX_METADATA_KEY = 20;
67
+ const MAX_METADATA_VALUE = 80;
68
+ // refs/user-guide_send-email-with-api.md:176 (docs/07 §3).
69
+ const FORBIDDEN_EXTENSIONS = new Set("vbs exe bin bat chm com cpl crt hlp hta inf ins isp jse lnk mdb pcd pif reg scr sct shs vbe vba wsf wsh wsl msc msi msp mst".split(" "));
70
+ const reject = (field, error) => ({
71
+ outcome: "rejected",
72
+ field,
73
+ error,
74
+ });
75
+ // ErrorCode 300 is documented for each case below (refs/api_overview.md:69). Only the address
76
+ // texts are documented (refs/api_bulk-email.md:221-227); the other texts are INFERRED (docs/03 Q4).
77
+ const invalid = (field, message) => reject(field, errorBody(300, { message }));
78
+ const invalidAddress = (field, raw) => invalid(field, `Invalid '${field}' address: '${raw}'.`);
79
+ /**
80
+ * The checks of a send, with no state change: the data checks (field types, size, stream,
81
+ * addresses, recipients, content, limits, attachments) and the sender check. Account approval and
82
+ * suppressions belong to `acceptOutbound`. Throws `Unsupported` for uncaptured cases.
83
+ */
84
+ export function validateOutbound(runtime, submission) {
85
+ const data = checkData(runtime, submission);
86
+ if (data.outcome === "rejected") {
87
+ refuseSenderAfterDataError(runtime, submission.auth, submission.draft.From);
88
+ return data;
89
+ }
90
+ if (submission.auth.kind === "test")
91
+ return data;
92
+ const { from, draft, recipients } = data.outbound;
93
+ if (!senderAuthorized(runtime.store.state, from)) {
94
+ return reject("From", errorBody(400, { params: { from: from.Email } }));
95
+ }
96
+ // A fake bounce type with an uncaptured effect answers 501 before anything is stored. It runs
97
+ // after the sender check, so an unknown sender keeps the captured 400.
98
+ testBounces(draft.Headers ?? [], recipients);
99
+ return data;
100
+ }
101
+ /**
102
+ * Which error Postmark answers for a message that fails a data check and the sender check is not
103
+ * captured (docs/03 §3.4). A caller that answers a data error calls this first. The test token
104
+ * belongs to no account and has no sender check (sdk/postmark-gem/spec/integration/api_client_messages_spec.rb:5-10,
105
+ * :45-48; sdk/postmark-java/src/test/java/integration/MessageTest.java:27-36).
106
+ */
107
+ export function refuseSenderAfterDataError(runtime, auth, rawFrom) {
108
+ if (auth.kind === "test" || typeof rawFrom !== "string")
109
+ return;
110
+ const [from, ...more] = parseAddressList(rawFrom) ?? [];
111
+ if (from === undefined || more.length > 0)
112
+ return;
113
+ if (!senderAuthorized(runtime.store.state, from)) {
114
+ throw new Unsupported(`a message that fails a data check and the sender check for ${from.Email} (docs/03 §3.4)`);
115
+ }
116
+ }
117
+ /** The data checks; check order follows docs/03 §3.3 (INFERRED). */
118
+ function checkData(runtime, submission) {
119
+ const parsed = draftSchema(submission.channel).safeParse(submission.draft);
120
+ // ErrorCode 403 for a field of the wrong type is INFERRED (docs/02 §9 Q13); text shape from
121
+ // refs/api_overview.md:43-50. The first failing field is named.
122
+ if (!parsed.success) {
123
+ const field = String(parsed.error.issues[0]?.path[0]);
124
+ return reject(field, errorBody(403, { message: `Invalid request field(s): '${field}'.` }));
125
+ }
126
+ const draft = parsed.data;
127
+ checkSize(draft);
128
+ const streamId = draft.MessageStream ?? "outbound";
129
+ const stream = findStream(runtime.store.state, submission.auth, streamId);
130
+ if (stream === undefined) {
131
+ return reject("MessageStream", errorBody(1235, { params: { stream: streamId } }));
132
+ }
133
+ if (stream.MessageStreamType === "Inbound")
134
+ return reject("MessageStream", errorBody(1236));
135
+ if (stream.ArchivedAt !== null) {
136
+ throw new Unsupported(`send to archived stream '${streamId}' is not captured (docs/04 Q15)`);
137
+ }
138
+ if (draft.From === undefined)
139
+ return invalidAddress("From", "");
140
+ const [from, ...moreFrom] = parseAddressList(draft.From) ?? [];
141
+ if (from === undefined || moreFrom.length > 0)
142
+ return invalidAddress("From", draft.From);
143
+ const lists = {
144
+ To: [],
145
+ Cc: [],
146
+ Bcc: [],
147
+ ReplyTo: [],
148
+ };
149
+ for (const field of ["To", "Cc", "Bcc", "ReplyTo"]) {
150
+ const addresses = parseAddressList(draft[field] ?? "");
151
+ if (addresses === undefined)
152
+ return invalidAddress(field, draft[field] ?? "");
153
+ lists[field] = addresses;
154
+ }
155
+ // `To` is required (docs/03 §1.2); the empty-`To` text is INFERRED (docs/03 Q4).
156
+ if (draft.To === undefined || lists.To.length === 0)
157
+ return invalidAddress("To", draft.To ?? "");
158
+ const recipients = [...lists.To, ...lists.Cc, ...lists.Bcc];
159
+ if (recipients.length > MAX_RECIPIENTS) {
160
+ return invalid("To", `Exceeded the maximum of ${MAX_RECIPIENTS} recipients per message.`);
161
+ }
162
+ if (draft.HtmlBody === undefined && draft.TextBody === undefined) {
163
+ return invalid("TextBody", "Provide either email TextBody or HtmlBody or both.");
164
+ }
165
+ const limitError = checkLimits(draft);
166
+ if (limitError !== undefined)
167
+ return invalid(limitError.field, limitError.message);
168
+ for (const attachment of draft.Attachments ?? []) {
169
+ const extension = /\.([^.]*)$/.exec(attachment.Name)?.[1]?.toLowerCase();
170
+ if (extension !== undefined && FORBIDDEN_EXTENSIONS.has(extension)) {
171
+ return reject("Attachments", errorBody(411));
172
+ }
173
+ }
174
+ return {
175
+ outcome: "valid",
176
+ outbound: {
177
+ submission,
178
+ draft: { ...draft, From: draft.From, To: draft.To },
179
+ streamId,
180
+ from,
181
+ lists,
182
+ recipients,
183
+ },
184
+ };
185
+ }
186
+ /**
187
+ * Account approval → suppression check → store (docs/11 §2), with no event. The test token stops
188
+ * before them: it validates data only, and account state and suppressions are INFERRED not to
189
+ * apply to it (docs/02 Q18).
190
+ */
191
+ function storeOutbound(runtime, outbound) {
192
+ const { submission, draft, streamId, from, lists, recipients } = outbound;
193
+ const { auth } = submission;
194
+ const now = runtime.clock.now();
195
+ if (auth.kind === "test") {
196
+ return { outcome: "validated", messageId: newMessageId(), submittedAt: now };
197
+ }
198
+ const { account } = runtime.store.state;
199
+ if (account.approval === "unapproved")
200
+ return { outcome: "rejected", error: errorBody(413) };
201
+ const fromDomain = domainOf(from);
202
+ if (account.approval === "pending" && recipients.some((r) => domainOf(r) !== fromDomain)) {
203
+ return { outcome: "rejected", error: errorBody(412) };
204
+ }
205
+ const inactive = inactiveRecipients(runtime, auth.server.ID, streamId, recipients);
206
+ if (inactive.length === recipients.length) {
207
+ return { outcome: "rejected", error: inactiveRecipientsError(uniqueEmails(inactive)) };
208
+ }
209
+ const messageId = newMessageId();
210
+ const message = {
211
+ MessageID: messageId,
212
+ ServerID: auth.server.ID,
213
+ MessageStream: streamId,
214
+ From: draft.From,
215
+ To: lists.To,
216
+ Cc: lists.Cc,
217
+ Bcc: lists.Bcc,
218
+ ReplyTo: draft.ReplyTo ?? null,
219
+ Subject: draft.Subject ?? null,
220
+ HtmlBody: draft.HtmlBody ?? null,
221
+ TextBody: draft.TextBody ?? null,
222
+ Tag: draft.Tag ?? null,
223
+ Headers: draft.Headers ?? [],
224
+ Attachments: (draft.Attachments ?? []).map((a) => ({
225
+ Name: a.Name,
226
+ Content: a.Content.toString("base64"),
227
+ ContentType: a.ContentType,
228
+ // `""` is "not inline", like null and absent (docs/03 §1.7).
229
+ ContentID: a.ContentID || null,
230
+ ContentLength: a.Content.length,
231
+ })),
232
+ Metadata: draft.Metadata ?? {},
233
+ // A server with open tracking on forces it on (docs/03 §5.3); a message without an HTML body
234
+ // is never tracked (docs/07 §3).
235
+ TrackOpens: draft.HtmlBody !== undefined && (auth.server.TrackOpens || (draft.TrackOpens ?? false)),
236
+ // A message value overrides the server value (docs/03 §5.2).
237
+ TrackLinks: draft.TrackLinks ?? auth.server.TrackLinks,
238
+ Status: "Sent",
239
+ Sandboxed: auth.server.DeliveryType === "Sandbox",
240
+ ReceivedAt: now,
241
+ MessageEvents: [],
242
+ channel: submission.channel,
243
+ request: submission.request,
244
+ rawSource: submission.channel === "smtp" ? submission.rawSource : restSource(outbound, messageId, now),
245
+ bulkRequestId: submission.bulkRequestId,
246
+ templateId: submission.templateId,
247
+ suppressedRecipients: uniqueEmails(inactive),
248
+ };
249
+ runtime.store.state.outbound.set(message.MessageID, message);
250
+ return inactive.length === 0
251
+ ? { outcome: "accepted", message }
252
+ : {
253
+ outcome: "partiallySuppressed",
254
+ message,
255
+ error: inactiveRecipientsError(uniqueEmails(inactive)),
256
+ };
257
+ }
258
+ /**
259
+ * The MIME source of a REST send, served by the dump endpoint. The gem live test finds the subject
260
+ * in it (sdk/postmark-gem/spec/integration/api_client_resources_spec.rb:36-40). Postmark adds
261
+ * `X-PM-Tag` and `X-PM-Message-Id` (refs/api_messages-api.md:276). A `Message-ID` from `Headers`
262
+ * is kept; otherwise it is `<uuid@mtasv.net>`, as on SMTP (src/smtp/mime.ts, INFERRED). The layout
263
+ * is INFERRED.
264
+ */
265
+ function restSource({ draft, from, lists }, messageId, now) {
266
+ const address = (a) => ({ email: a.Email, name: a.Name ?? undefined });
267
+ const custom = (draft.Headers ?? []).map((h) => ({ name: h.Name, value: h.Value }));
268
+ const hasMessageId = custom.some((h) => h.name.toLowerCase() === "message-id");
269
+ return composeMime({
270
+ from: address(from),
271
+ to: lists.To.map(address),
272
+ cc: lists.Cc.map(address),
273
+ replyTo: draft.ReplyTo,
274
+ subject: draft.Subject ?? "",
275
+ text: draft.TextBody,
276
+ html: draft.HtmlBody,
277
+ headers: [
278
+ ...custom,
279
+ ...(draft.Tag === undefined ? [] : [{ name: "X-PM-Tag", value: draft.Tag }]),
280
+ { name: "X-PM-Message-Id", value: messageId },
281
+ ...(hasMessageId ? [] : [{ name: "Message-ID", value: `<${randomUUID()}@mtasv.net>` }]),
282
+ ],
283
+ attachments: (draft.Attachments ?? []).map((a) => ({
284
+ name: a.Name,
285
+ content: a.Content.toString("base64"),
286
+ contentType: a.ContentType,
287
+ contentId: a.ContentID || undefined,
288
+ })),
289
+ }, now);
290
+ }
291
+ /**
292
+ * Stores every accepted message, then emits `sent` for each, in order. A `sent` listener that
293
+ * changes state (archives a stream, adds a suppression) cannot affect a later message of the
294
+ * same request.
295
+ */
296
+ export async function acceptOutbounds(runtime, outbounds) {
297
+ const results = outbounds.map((outbound) => storeOutbound(runtime, outbound));
298
+ for (const result of results) {
299
+ if ("message" in result)
300
+ await runtime.events.emit("sent", { message: result.message });
301
+ }
302
+ return results;
303
+ }
304
+ export async function acceptOutbound(runtime, outbound) {
305
+ const [result] = await acceptOutbounds(runtime, [outbound]);
306
+ return result;
307
+ }
308
+ /** `validateOutbound`, then `acceptOutbound` for a valid draft. */
309
+ export async function submitOutbound(runtime, submission) {
310
+ const validation = validateOutbound(runtime, submission);
311
+ return validation.outcome === "valid"
312
+ ? acceptOutbound(runtime, validation.outbound)
313
+ : { outcome: "rejected", error: validation.error };
314
+ }
315
+ /**
316
+ * Body parts over 5 MB or a message over 10 MB, measured after base64, is HTTP 413 (docs/07 §3).
317
+ * The 413 body is not captured (docs/02 Q10), so postmock answers 501.
318
+ */
319
+ function checkSize(draft) {
320
+ const bodies = [draft.HtmlBody, draft.TextBody].map((b) => Buffer.byteLength(b ?? ""));
321
+ const attachments = (draft.Attachments ?? []).reduce((sum, a) => sum + Math.ceil(a.Content.length / 3) * 4, 0);
322
+ const total = bodies.reduce((a, b) => a + b, 0) + attachments;
323
+ if (bodies.some((b) => b > MAX_BODY_BYTES) || total > MAX_MESSAGE_BYTES) {
324
+ throw new Unsupported("HTTP 413 for an oversized message: body not captured (docs/02 Q10)");
325
+ }
326
+ }
327
+ /** The first limit the draft breaks, as a 300 message (texts INFERRED, docs/03 Q4). */
328
+ function checkLimits(draft) {
329
+ if ((draft.From ?? "").length > MAX_FROM) {
330
+ return {
331
+ field: "From",
332
+ message: `The 'From' field exceeds the maximum length of ${MAX_FROM} characters.`,
333
+ };
334
+ }
335
+ if ((draft.Subject ?? "").length > MAX_SUBJECT) {
336
+ return {
337
+ field: "Subject",
338
+ message: `The 'Subject' field exceeds the maximum length of ${MAX_SUBJECT} characters.`,
339
+ };
340
+ }
341
+ if ((draft.Tag ?? "").length > MAX_TAG) {
342
+ return {
343
+ field: "Tag",
344
+ message: `The 'Tag' field exceeds the maximum length of ${MAX_TAG} characters.`,
345
+ };
346
+ }
347
+ const metadata = Object.entries(draft.Metadata ?? {});
348
+ if (metadata.length > MAX_METADATA_FIELDS) {
349
+ return {
350
+ field: "Metadata",
351
+ message: `Metadata may contain at most ${MAX_METADATA_FIELDS} fields.`,
352
+ };
353
+ }
354
+ const seen = new Set();
355
+ for (const [key, value] of metadata) {
356
+ if (key.length > MAX_METADATA_KEY) {
357
+ return {
358
+ field: "Metadata",
359
+ message: `Metadata field name '${key}' exceeds the maximum length of ${MAX_METADATA_KEY} characters.`,
360
+ };
361
+ }
362
+ if (value.length > MAX_METADATA_VALUE) {
363
+ return {
364
+ field: "Metadata",
365
+ message: `Metadata field '${key}' value exceeds the maximum length of ${MAX_METADATA_VALUE} characters.`,
366
+ };
367
+ }
368
+ if (seen.has(key.toLowerCase()))
369
+ return { field: "Metadata", message: `Metadata field '${key}' appears more than once.` };
370
+ seen.add(key.toLowerCase());
371
+ }
372
+ return undefined;
373
+ }
374
+ const domainOf = (address) => address.Email.split("@").at(-1).toLowerCase();
375
+ /**
376
+ * True for a `From` on a DKIM-verified account Domain (any local part, CAPTURED:
377
+ * captures/20260916T231736Z-from-verification/03) or a confirmed Sender Signature's address. False
378
+ * only where the captured 400 applies: no account Domain and no Signature matches the `From`
379
+ * (captures/20260916T231736Z-from-verification/01). Names match without case. Every other state
380
+ * is not captured and throws `Unsupported` (docs/03 §3.4).
381
+ */
382
+ function senderAuthorized(state, from) {
383
+ const domain = domainOf(from);
384
+ const email = from.Email.toLowerCase();
385
+ const signatures = [...state.senders.values()].filter((s) => s.EmailAddress.toLowerCase() === email);
386
+ const domains = [...state.domains.values()].filter((d) => d.Name.toLowerCase() === domain);
387
+ if (signatures.some((s) => s.Confirmed) || domains.some((d) => d.DKIMVerified))
388
+ return true;
389
+ if (signatures.length > 0) {
390
+ throw new Unsupported(`a send from the unconfirmed sender signature ${from.Email} (docs/03 Q3)`);
391
+ }
392
+ if (domains.length > 0) {
393
+ throw new Unsupported(`a send from the domain ${domain} without verified DKIM (docs/03 Q3)`);
394
+ }
395
+ const parent = [...state.domains.values()].find((d) => domain.endsWith(`.${d.Name.toLowerCase()}`));
396
+ if (parent !== undefined) {
397
+ throw new Unsupported(`a send from ${domain}, a subdomain of the domain ${parent.Name} (docs/03 Q3)`);
398
+ }
399
+ return false;
400
+ }
401
+ /** Recipients on the send stream's suppression list; each stream has its own list (docs/04 §3.3). */
402
+ function inactiveRecipients(runtime, serverId, streamId, recipients) {
403
+ const emails = recipients.map((r) => r.Email);
404
+ const suppressed = new Set(suppressedAddresses(runtime.store.state, serverId, streamId, emails));
405
+ return recipients.filter((r) => suppressed.has(r.Email));
406
+ }
407
+ /** Each address once, in first-seen spelling; addresses compare without case. */
408
+ function uniqueEmails(addresses) {
409
+ const byLower = new Map();
410
+ for (const { Email } of addresses) {
411
+ if (!byLower.has(Email.toLowerCase()))
412
+ byLower.set(Email.toLowerCase(), Email);
413
+ }
414
+ return [...byLower.values()];
415
+ }
@@ -0,0 +1,7 @@
1
+ import type { Plugin } from "../plugins.ts";
2
+ /**
3
+ * Appends the `MessageEvents` of outbound message details (docs/06 §1.6). Details values are
4
+ * strings (refs/api_messages-api.md:181-236).
5
+ */
6
+ declare const messageEvents: Plugin;
7
+ export default messageEvents;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Appends the `MessageEvents` of outbound message details (docs/06 §1.6). Details values are
3
+ * strings (refs/api_messages-api.md:181-236).
4
+ */
5
+ const messageEvents = {
6
+ install(runtime) {
7
+ const { events } = runtime;
8
+ const add = (message, event) => {
9
+ message?.MessageEvents.push(event);
10
+ };
11
+ // A bounce or subscription change can name no stored message (a manual suppression).
12
+ const outbound = (id) => id === null ? undefined : runtime.store.state.outbound.get(id);
13
+ events.on("bounced", ({ bounce }) => {
14
+ const message = outbound(bounce.MessageID);
15
+ if (bounce.Type === "Transient") {
16
+ add(message, {
17
+ Recipient: bounce.Email,
18
+ Type: "Transient",
19
+ ReceivedAt: bounce.BouncedAt,
20
+ Details: { DeliveryMessage: bounce.Details },
21
+ });
22
+ }
23
+ else if (bounce.Type !== "SpamComplaint" && bounce.Type !== "SMTPApiError") {
24
+ add(message, {
25
+ Recipient: bounce.Email,
26
+ Type: "Bounced",
27
+ ReceivedAt: bounce.BouncedAt,
28
+ Details: { Summary: bounce.Details, BounceID: String(bounce.ID) },
29
+ });
30
+ }
31
+ });
32
+ // A complaint is a bounce of type SpamComplaint; the doc lists no own event type (INFERRED).
33
+ events.on("spamComplaint", ({ bounce }) => add(outbound(bounce.MessageID), {
34
+ Recipient: bounce.Email,
35
+ Type: "Bounced",
36
+ ReceivedAt: bounce.BouncedAt,
37
+ Details: { Summary: bounce.Description, BounceID: String(bounce.ID) },
38
+ }));
39
+ // Only the stored first open and first click per link appear, like the opens and clicks reads.
40
+ events.on("opened", ({ open }) => {
41
+ if (!runtime.store.state.opens.includes(open))
42
+ return;
43
+ add(outbound(open.MessageID), {
44
+ Recipient: open.Recipient,
45
+ Type: "Opened",
46
+ ReceivedAt: open.ReceivedAt,
47
+ Details: { Summary: `Email opened with ${open.UserAgent}` },
48
+ });
49
+ });
50
+ events.on("clicked", ({ click }) => {
51
+ if (!runtime.store.state.clicks.includes(click))
52
+ return;
53
+ const body = click.ClickLocation === "HTML" ? "HTMLBody" : "TextBody";
54
+ add(outbound(click.MessageID), {
55
+ Recipient: click.Recipient,
56
+ Type: "LinkClicked",
57
+ ReceivedAt: click.ReceivedAt,
58
+ Details: {
59
+ Summary: `Tracked Link '${click.OriginalLink}' was clicked from the ${body}.`,
60
+ Link: click.OriginalLink,
61
+ ClickLocation: click.ClickLocation,
62
+ },
63
+ });
64
+ });
65
+ events.on("subscriptionChange", ({ change }) => add(outbound(change.MessageID), {
66
+ Recipient: change.Recipient,
67
+ Type: "SubscriptionChanged",
68
+ ReceivedAt: change.ChangedAt,
69
+ Details: {
70
+ Origin: change.Origin,
71
+ SuppressSending: change.SuppressSending ? "True" : "False",
72
+ },
73
+ }));
74
+ },
75
+ };
76
+ export default messageEvents;
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+ import type { Plugin } from "../plugins.ts";
3
+ /**
4
+ * `POSTMOCK_SMTP_PORTS`: comma-separated; every port serves the same endpoint. The default `0` picks
5
+ * a free port, so parallel test runs never collide. Postmark listens on 25, 587 and 2525
6
+ * (docs/07 §1.2): set `2525` and map the other two to it.
7
+ * `POSTMOCK_SMTP_TLS_KEY`, `POSTMOCK_SMTP_TLS_CERT`: PEM files; both set offers STARTTLS.
8
+ */
9
+ export declare const smtpEnv: z.ZodObject<{
10
+ POSTMOCK_SMTP_PORTS: z.ZodPipe<z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number[], string>>, z.ZodArray<z.ZodInt>>;
11
+ POSTMOCK_SMTP_TLS_KEY: z.ZodOptional<z.ZodString>;
12
+ POSTMOCK_SMTP_TLS_CERT: z.ZodOptional<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ declare const smtp: Plugin;
15
+ export default smtp;
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { readPem } from "../config.js";
3
+ import { startSmtp } from "../smtp/listener.js";
4
+ /**
5
+ * `POSTMOCK_SMTP_PORTS`: comma-separated; every port serves the same endpoint. The default `0` picks
6
+ * a free port, so parallel test runs never collide. Postmark listens on 25, 587 and 2525
7
+ * (docs/07 §1.2): set `2525` and map the other two to it.
8
+ * `POSTMOCK_SMTP_TLS_KEY`, `POSTMOCK_SMTP_TLS_CERT`: PEM files; both set offers STARTTLS.
9
+ */
10
+ export const smtpEnv = z
11
+ .object({
12
+ POSTMOCK_SMTP_PORTS: z
13
+ .string()
14
+ .regex(/^\d+(,\d+)*$/, "a comma-separated port list")
15
+ .default("0")
16
+ .transform((v) => v.split(",").map(Number))
17
+ .pipe(z.array(z.int().max(65535))),
18
+ POSTMOCK_SMTP_TLS_KEY: z.string().min(1).optional(),
19
+ POSTMOCK_SMTP_TLS_CERT: z.string().min(1).optional(),
20
+ })
21
+ .refine((env) => (env.POSTMOCK_SMTP_TLS_KEY === undefined) === (env.POSTMOCK_SMTP_TLS_CERT === undefined), {
22
+ message: "set both POSTMOCK_SMTP_TLS_KEY and POSTMOCK_SMTP_TLS_CERT, or neither",
23
+ })
24
+ .refine((env) => env.POSTMOCK_SMTP_PORTS.length === 1 || !env.POSTMOCK_SMTP_PORTS.includes(0), {
25
+ message: "POSTMOCK_SMTP_PORTS: port 0 is only allowed alone; its port is in the startup URL",
26
+ });
27
+ const smtp = {
28
+ start: (runtime, host) => {
29
+ const env = smtpEnv.parse(process.env);
30
+ return startSmtp(runtime, {
31
+ host,
32
+ ports: env.POSTMOCK_SMTP_PORTS,
33
+ tls: env.POSTMOCK_SMTP_TLS_KEY === undefined || env.POSTMOCK_SMTP_TLS_CERT === undefined
34
+ ? null
35
+ : {
36
+ key: readPem("POSTMOCK_SMTP_TLS_KEY", env.POSTMOCK_SMTP_TLS_KEY),
37
+ cert: readPem("POSTMOCK_SMTP_TLS_CERT", env.POSTMOCK_SMTP_TLS_CERT),
38
+ },
39
+ });
40
+ },
41
+ };
42
+ export default smtp;
@@ -0,0 +1,7 @@
1
+ import type { Plugin } from "../plugins.ts";
2
+ /**
3
+ * Records a Stats API fact for each countable event (docs/06 §2). Facts outlive messages, opens
4
+ * and clicks, which leave after the retention window (refs/api_stats-api.md:4).
5
+ */
6
+ declare const stats: Plugin;
7
+ export default stats;
@@ -0,0 +1,78 @@
1
+ import { recipientsOf } from "../recipients/transitions.js";
2
+ import { trackedLinks } from "../tracking.js";
3
+ /**
4
+ * Records a Stats API fact for each countable event (docs/06 §2). Facts outlive messages, opens
5
+ * and clicks, which leave after the retention window (refs/api_stats-api.md:4).
6
+ */
7
+ const stats = {
8
+ install(runtime) {
9
+ const { events } = runtime;
10
+ const record = (fact) => {
11
+ runtime.store.state.stats.push(fact);
12
+ };
13
+ const from = (b) => ({
14
+ ServerID: b.ServerID,
15
+ MessageStream: b.MessageStream,
16
+ Tag: b.Tag,
17
+ at: b.BouncedAt,
18
+ });
19
+ events.on("sent", ({ message }) => {
20
+ const linkTracking = message.TrackLinks !== "None";
21
+ // One fact per recipient: stats count a message once per recipient (refs/api_messages-api.md:50).
22
+ for (const _ of recipientsOf(message)) {
23
+ record({
24
+ kind: "sent",
25
+ ServerID: message.ServerID,
26
+ MessageStream: message.MessageStream,
27
+ Tag: message.Tag,
28
+ at: message.ReceivedAt,
29
+ openTracking: message.TrackOpens,
30
+ linkTracking,
31
+ trackedLinks: linkTracking ? trackedLinks(message).length : 0,
32
+ });
33
+ }
34
+ });
35
+ // The bounce stats keys are HardBounce, SoftBounce, Transient, SMTPApiError
36
+ // (refs/api_stats-api.md:197-200). Other bounce types count nowhere (INFERRED).
37
+ events.on("bounced", ({ bounce }) => {
38
+ if (bounce.Type === "HardBounce" ||
39
+ bounce.Type === "SoftBounce" ||
40
+ bounce.Type === "Transient") {
41
+ record({ kind: "bounce", type: bounce.Type, ...from(bounce) });
42
+ }
43
+ });
44
+ events.on("smtpApiError", ({ bounce }) => {
45
+ record({ kind: "bounce", type: "SMTPApiError", ...from(bounce) });
46
+ });
47
+ events.on("spamComplaint", ({ bounce }) => record({ kind: "spamComplaint", ...from(bounce) }));
48
+ events.on("opened", ({ open }) => record({
49
+ kind: "open",
50
+ ServerID: open.ServerID,
51
+ MessageStream: open.MessageStream,
52
+ Tag: open.Tag,
53
+ at: open.ReceivedAt,
54
+ MessageID: open.MessageID,
55
+ Recipient: open.Recipient,
56
+ first: open.FirstOpen,
57
+ platform: open.Platform,
58
+ // Email client and browser keys use Client.Family (INFERRED; doc examples: refs/api_stats-api.md:588, :741).
59
+ client: open.Client?.Family ?? null,
60
+ readSeconds: open.ReadSeconds,
61
+ }));
62
+ events.on("clicked", ({ click }) => record({
63
+ kind: "click",
64
+ ServerID: click.ServerID,
65
+ MessageStream: click.MessageStream,
66
+ Tag: click.Tag,
67
+ at: click.ReceivedAt,
68
+ MessageID: click.MessageID,
69
+ Recipient: click.Recipient,
70
+ first: runtime.store.state.clicks.includes(click),
71
+ link: click.OriginalLink,
72
+ location: click.ClickLocation,
73
+ platform: click.Platform,
74
+ browser: click.Client?.Family ?? null,
75
+ }));
76
+ },
77
+ };
78
+ export default stats;
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from "../plugins.ts";
2
+ /** The webhook emitter: every domain event becomes a POST to the hooks that ask for it (docs/05 §5). */
3
+ declare const webhooks: Plugin;
4
+ export default webhooks;