@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,104 @@
1
+ import { z } from "zod";
2
+ import { apiError, ERROR_FAMILIES, isSummaryRow } from "../../errors.js";
3
+ import { formatTimestamp } from "../../time.js";
4
+ import { ControlError, controlInput, defineControl } from "../registry.js";
5
+ import { seedAtomically } from "../seeding.js";
6
+ // docs/09 §5: reset, seed, clock, faults, messages.
7
+ defineControl({
8
+ method: "POST",
9
+ path: "/control/reset",
10
+ handler: async (ctx) => {
11
+ const { seed = ctx.startupSeed } = controlInput(z.object({ seed: z.string().optional() }), ctx.body);
12
+ await seedAtomically(ctx, seed, { reset: true });
13
+ return { seed };
14
+ },
15
+ });
16
+ defineControl({
17
+ method: "POST",
18
+ path: "/control/seed",
19
+ handler: async (ctx) => {
20
+ const { name } = controlInput(z.object({ name: z.string() }), ctx.body);
21
+ await seedAtomically(ctx, name, { reset: false });
22
+ return { seed: name };
23
+ },
24
+ });
25
+ defineControl({
26
+ method: "POST",
27
+ path: "/control/clock/advance",
28
+ handler: async (ctx) => {
29
+ const { ms } = controlInput(z.object({ ms: z.int().nonnegative() }), ctx.body);
30
+ await ctx.clock.advance(ms);
31
+ return { now: formatTimestamp(ctx.clock.now(), "utc") };
32
+ },
33
+ });
34
+ const errorReply = z.object({
35
+ errorCode: z.int(),
36
+ status: z.int().optional(),
37
+ family: z.enum(ERROR_FAMILIES).optional(),
38
+ message: z.string().optional(),
39
+ });
40
+ const faultSchema = z.object({
41
+ match: z.object({ method: z.string(), path: z.string().startsWith("/") }),
42
+ times: z.int().positive().default(1),
43
+ reply: z.unknown(),
44
+ });
45
+ defineControl({
46
+ method: "POST",
47
+ path: "/control/faults",
48
+ handler: (ctx) => {
49
+ const { match, times, reply } = controlInput(faultSchema, ctx.body);
50
+ ctx.store.state.faults.push({
51
+ method: match.method,
52
+ path: match.path,
53
+ remaining: times,
54
+ reply: reply === "timeout" || reply === "reset"
55
+ ? reply
56
+ : faultError(controlInput(errorReply, reply)),
57
+ });
58
+ return { faults: ctx.store.state.faults.length };
59
+ },
60
+ });
61
+ /** Only a status and ErrorCode pair from docs/02 §4.4 can be faulted (CONTROL-API.md principle). */
62
+ function faultError(reply) {
63
+ try {
64
+ // A message row's text is the wire text; only a summary row takes a caller message.
65
+ if (reply.message !== undefined && !isSummaryRow(reply.errorCode, reply.family)) {
66
+ throw new Error(`ErrorCode ${reply.errorCode}: message only for a summary row`);
67
+ }
68
+ const { status, body } = apiError(reply.errorCode, {
69
+ ...(reply.status !== undefined && { status: reply.status }),
70
+ ...(reply.family !== undefined && { family: reply.family }),
71
+ ...(reply.message !== undefined && { message: reply.message }),
72
+ });
73
+ return { status, body };
74
+ }
75
+ catch (error) {
76
+ throw new ControlError(error.message);
77
+ }
78
+ }
79
+ defineControl({
80
+ method: "GET",
81
+ path: "/control/messages",
82
+ handler: ({ store, query }) => {
83
+ const to = query.get("to")?.toLowerCase();
84
+ const tag = query.get("tag");
85
+ const channel = query.get("channel");
86
+ if (channel !== null && channel !== "rest" && channel !== "smtp") {
87
+ throw new ControlError(`channel must be rest or smtp, got '${channel}'`);
88
+ }
89
+ const messages = [...store.state.outbound.values()].filter((m) => (to === undefined ||
90
+ [...m.To, ...m.Cc, ...m.Bcc].some((a) => a.Email.toLowerCase() === to)) &&
91
+ (tag === null || m.Tag === tag) &&
92
+ (channel === null || m.channel === channel));
93
+ return {
94
+ Messages: messages.map((m) => ({
95
+ MessageID: m.MessageID,
96
+ ServerID: m.ServerID,
97
+ MessageStream: m.MessageStream,
98
+ Channel: m.channel,
99
+ SubmittedAt: formatTimestamp(m.ReceivedAt),
100
+ Request: m.request,
101
+ })),
102
+ };
103
+ },
104
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { receiveInbound } from "../../inbound/receive.js";
3
+ import { composeMime } from "../../mime/compose.js";
4
+ import { ControlError, controlInput, defineControl } from "../registry.js";
5
+ const address = z.union([z.string(), z.object({ email: z.string(), name: z.string().optional() })]);
6
+ const emailOf = (a) => (typeof a === "string" ? a : a.email);
7
+ const spam = {
8
+ spamScore: z.number().default(0),
9
+ spamTests: z.array(z.string()).default([]),
10
+ };
11
+ const inboundSchema = z.union([
12
+ z.strictObject({ mime: z.string(), rcptTo: z.array(z.string()).min(1), ...spam }),
13
+ z.strictObject({
14
+ from: address,
15
+ to: z.array(address).default([]),
16
+ cc: z.array(address).default([]),
17
+ bcc: z.array(address).default([]),
18
+ replyTo: address.optional(),
19
+ subject: z.string().default(""),
20
+ text: z.string().optional(),
21
+ html: z.string().optional(),
22
+ headers: z.array(z.object({ name: z.string(), value: z.string() })).default([]),
23
+ attachments: z
24
+ .array(z.object({
25
+ name: z.string(),
26
+ content: z.base64(),
27
+ contentType: z.string(),
28
+ contentId: z.string().optional(),
29
+ }))
30
+ .default([]),
31
+ ...spam,
32
+ }),
33
+ ]);
34
+ // Mail arrives at an inbound address (CONTROL-API.md). The body is raw MIME with its SMTP envelope,
35
+ // or fields that postmock writes as MIME; Bcc addresses go only into the envelope.
36
+ defineControl({
37
+ method: "POST",
38
+ path: "/control/inbound",
39
+ handler: async (ctx) => {
40
+ const input = controlInput(inboundSchema, ctx.body);
41
+ const mail = "mime" in input
42
+ ? input
43
+ : {
44
+ mime: composeMime(input, ctx.clock.now()),
45
+ rcptTo: [...input.to, ...input.cc, ...input.bcc].map(emailOf),
46
+ spamScore: input.spamScore,
47
+ spamTests: input.spamTests,
48
+ };
49
+ const messages = await receiveInbound(ctx, mail);
50
+ if (messages.length === 0) {
51
+ throw new ControlError(`no server has an inbound address among ${mail.rcptTo.join(", ")}`);
52
+ }
53
+ return {
54
+ Messages: messages.map((m) => ({
55
+ MessageID: m.MessageID,
56
+ ServerID: m.ServerID,
57
+ Status: m.Status,
58
+ })),
59
+ };
60
+ },
61
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
1
+ import { z } from "zod";
2
+ import { Unsupported } from "../../http/respond.js";
3
+ import { recipientsOf, recordBounce, recordUnsubscribe } from "../../recipients/transitions.js";
4
+ import { streamKey } from "../../state/store.js";
5
+ import { ControlError, controlInput, defineControl } from "../registry.js";
6
+ // What a recipient or its mail server does to a delivered message (docs/04 §3.2 T1–T5).
7
+ /** Bounce types a receiving mail server reports, with a known effect on the address (docs/04 §1.4). */
8
+ const SERVER_BOUNCE_TYPES = [
9
+ "HardBounce",
10
+ "Transient",
11
+ "Subscribe",
12
+ "AutoResponder",
13
+ "AddressChange",
14
+ "DnsError",
15
+ "SpamNotification",
16
+ "OpenRelayTest",
17
+ "Unknown",
18
+ "SoftBounce",
19
+ "VirusNotification",
20
+ "ChallengeVerification",
21
+ ];
22
+ const target = z.object({ messageId: z.string(), recipient: z.string() });
23
+ /** A delivered message and one of its recipients, with its case as sent. */
24
+ function delivered(ctx, input) {
25
+ const message = ctx.store.state.outbound.get(input.messageId);
26
+ if (message === undefined)
27
+ throw new ControlError(`no outbound message ${input.messageId}`);
28
+ // A sandbox server delivers nothing, so nothing can bounce (docs/07 §2).
29
+ if (message.Sandboxed)
30
+ throw new ControlError(`message ${message.MessageID} was not delivered (sandbox)`);
31
+ const email = recipientsOf(message).find((r) => r.toLowerCase() === input.recipient.toLowerCase());
32
+ if (email === undefined) {
33
+ throw new ControlError(`${input.recipient} is not a recipient of ${message.MessageID}`);
34
+ }
35
+ const stream = ctx.store.state.streams.get(streamKey(message.ServerID, message.MessageStream));
36
+ // A purged stream, or a new stream with the same ID, never carried this message.
37
+ if (stream === undefined || stream.CreatedAt.getTime() > message.ReceivedAt.getTime()) {
38
+ throw new ControlError(`stream ${message.MessageStream} of ${message.MessageID} is gone`);
39
+ }
40
+ if (message.Status === "Queued") {
41
+ throw new ControlError(`message ${message.MessageID} is queued, not delivered`);
42
+ }
43
+ // A send skips an address suppressed at send time (docs/04 §3.3).
44
+ if (message.suppressedRecipients.some((r) => r.toLowerCase() === email.toLowerCase())) {
45
+ throw new ControlError(`${email} was suppressed when ${message.MessageID} was sent`);
46
+ }
47
+ return { message, email, stream };
48
+ }
49
+ /**
50
+ * One message ends once per recipient: after a bounce or complaint other than a `Transient` delay,
51
+ * no further bounce or complaint comes (INFERRED).
52
+ */
53
+ function refuseAfterFinal(ctx, message, email) {
54
+ for (const bounce of ctx.store.state.bounces.values()) {
55
+ if (bounce.MessageID === message.MessageID &&
56
+ bounce.Email === email &&
57
+ bounce.Type !== "Transient") {
58
+ throw new ControlError(`${message.MessageID} already has a ${bounce.Type} for ${email}`);
59
+ }
60
+ }
61
+ }
62
+ /** Behavior postmock does not know is a bad control request, not a crash. */
63
+ async function known(run) {
64
+ try {
65
+ return await run();
66
+ }
67
+ catch (error) {
68
+ if (error instanceof Unsupported)
69
+ throw new ControlError(error.message);
70
+ throw error;
71
+ }
72
+ }
73
+ /** A minimal DSN as the bounce dump (INFERRED shape). */
74
+ const dsn = (message, email, details) => [
75
+ "Return-Path: <>",
76
+ "From: MAILER-DAEMON@postmock.invalid",
77
+ `To: ${message.From}`,
78
+ "Subject: Undelivered Mail Returned to Sender",
79
+ "Content-Type: text/plain; charset=utf-8",
80
+ "",
81
+ `Delivery to ${email} failed.`,
82
+ details,
83
+ "",
84
+ ].join("\r\n");
85
+ defineControl({
86
+ method: "POST",
87
+ path: "/control/bounces",
88
+ handler: async (ctx) => {
89
+ const input = controlInput(target.extend({
90
+ type: z.enum(SERVER_BOUNCE_TYPES),
91
+ details: z.string().default("smtp;550 5.1.1 The email account does not exist."),
92
+ dump: z.string().optional(),
93
+ }), ctx.body);
94
+ const { message, email } = delivered(ctx, input);
95
+ refuseAfterFinal(ctx, message, email);
96
+ const bounce = await known(() => recordBounce(ctx, {
97
+ message,
98
+ email,
99
+ type: input.type,
100
+ details: input.details,
101
+ content: input.dump ?? dsn(message, email, input.details),
102
+ }));
103
+ return { ID: bounce.ID };
104
+ },
105
+ });
106
+ defineControl({
107
+ method: "POST",
108
+ path: "/control/events/spam-complaint",
109
+ handler: async (ctx) => {
110
+ const input = controlInput(target.extend({ dump: z.string().optional() }), ctx.body);
111
+ const { message, email } = delivered(ctx, input);
112
+ refuseAfterFinal(ctx, message, email);
113
+ const bounce = await known(() => recordBounce(ctx, {
114
+ message,
115
+ email,
116
+ type: "SpamComplaint",
117
+ details: "",
118
+ content: input.dump ??
119
+ `Content-Type: message/feedback-report\r\n\r\nFeedback-Type: abuse\r\nOriginal-Rcpt-To: ${email}\r\n`,
120
+ }));
121
+ return { ID: bounce.ID };
122
+ },
123
+ });
124
+ defineControl({
125
+ method: "POST",
126
+ path: "/control/events/unsubscribe",
127
+ handler: async (ctx) => {
128
+ const { message, email, stream } = delivered(ctx, controlInput(target, ctx.body));
129
+ // Postmark adds its unsubscribe link only on such a stream (docs/04 §3.2 T5).
130
+ if (stream.MessageStreamType !== "Broadcasts" ||
131
+ stream.SubscriptionManagementConfiguration.UnsubscribeHandlingType !== "Postmark") {
132
+ throw new ControlError(`stream ${message.MessageStream} has no Postmark unsubscribe handling`);
133
+ }
134
+ return { suppressed: await known(() => recordUnsubscribe(ctx, message, email)) };
135
+ },
136
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ import { newHex } from "../../state/ids.js";
3
+ import { streamKey } from "../../state/store.js";
4
+ import { ControlError, controlInput, defineControl } from "../registry.js";
5
+ // docs/07 Mock must: tests create and revoke SMTP tokens, and fault the SMTP session.
6
+ defineControl({
7
+ method: "POST",
8
+ path: "/control/smtp/tokens",
9
+ handler: ({ store, body }) => {
10
+ const input = controlInput(z.object({ serverId: z.int(), messageStream: z.string() }), body);
11
+ const server = store.state.servers.get(input.serverId);
12
+ if (server === undefined)
13
+ throw new ControlError(`no server ${input.serverId}`);
14
+ const stream = store.state.streams.get(streamKey(server.ID, input.messageStream));
15
+ if (stream === undefined) {
16
+ throw new ControlError(`server ${server.ID} has no stream '${input.messageStream}'`);
17
+ }
18
+ // The UI refuses these like ErrorCodes 1457, 1459 and 1460 (refs/api_overview.md:191-194).
19
+ if (stream.MessageStreamType === "Inbound") {
20
+ throw new ControlError("Tokens cannot be used with inbound streams.");
21
+ }
22
+ if (stream.ArchivedAt !== null) {
23
+ throw new ControlError("A token cannot be issued for an archived stream scheduled for deletion.");
24
+ }
25
+ if (!server.SmtpApiActivated) {
26
+ throw new ControlError("SMTP is currently disabled for the specified server.");
27
+ }
28
+ // Key formats are not documented (docs/07 Q16): INFERRED.
29
+ const token = {
30
+ accessKey: newHex(16),
31
+ secretKey: newHex(32),
32
+ serverId: server.ID,
33
+ messageStream: stream.ID,
34
+ };
35
+ store.state.smtpTokens.set(token.accessKey, token);
36
+ return {
37
+ AccessKey: token.accessKey,
38
+ SecretKey: token.secretKey,
39
+ ServerID: token.serverId,
40
+ MessageStream: token.messageStream,
41
+ };
42
+ },
43
+ });
44
+ defineControl({
45
+ method: "DELETE",
46
+ path: "/control/smtp/tokens/:accessKey",
47
+ handler: ({ store, params }) => {
48
+ const accessKey = params.accessKey;
49
+ if (!store.state.smtpTokens.delete(accessKey)) {
50
+ throw new ControlError(`no SMTP token '${accessKey}'`);
51
+ }
52
+ return { AccessKey: accessKey };
53
+ },
54
+ });
55
+ const faultSchema = z.object({
56
+ stage: z.enum(["connect", "mail", "rcpt", "data"]),
57
+ times: z.int().positive().default(1),
58
+ // A 4xx or 5xx reply; 421 also closes the connection (an outage or an idle close).
59
+ reply: z.object({ code: z.int().min(400).max(599), message: z.string().min(1) }),
60
+ });
61
+ defineControl({
62
+ method: "POST",
63
+ path: "/control/smtp/faults",
64
+ handler: ({ store, body }) => {
65
+ const { stage, times, reply } = controlInput(faultSchema, body);
66
+ store.state.smtpFaults.push({
67
+ stage,
68
+ remaining: times,
69
+ code: reply.code,
70
+ message: reply.message,
71
+ });
72
+ return { faults: store.state.smtpFaults.length };
73
+ },
74
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ import { formatTimestamp } from "../../time.js";
3
+ import { recordClick, recordDelivery, recordOpen, TrackingRefused, } from "../../tracking.js";
4
+ import { ControlError, controlInput, defineControl } from "../registry.js";
5
+ // Recipient actions on a sent message (CONTROL-API.md): delivery, open, click. Each fires the same
6
+ // event a real recipient action fires, so webhooks, message events and stats follow.
7
+ const clientInfo = z.object({ name: z.string(), company: z.string(), family: z.string() });
8
+ const agentSchema = {
9
+ // Postmark reads the client from the user agent. The mock takes the parsed parts from the test.
10
+ userAgent: z.string().min(1).default("Mozilla/5.0 (postmock)"),
11
+ client: clientInfo.optional(),
12
+ os: clientInfo.optional(),
13
+ platform: z.enum(["WebMail", "Desktop", "Mobile", "Unknown"]).optional(),
14
+ geo: z
15
+ .object({
16
+ countryISOCode: z.string(),
17
+ country: z.string(),
18
+ regionISOCode: z.string(),
19
+ region: z.string(),
20
+ city: z.string(),
21
+ zip: z.string(),
22
+ coords: z.string(),
23
+ ip: z.string(),
24
+ })
25
+ .partial()
26
+ .refine((geo) => Object.keys(geo).length > 0, "geo needs at least one field")
27
+ .optional(),
28
+ };
29
+ const target = { messageId: z.string(), recipient: z.string() };
30
+ const info = (c) => c === undefined ? null : { Name: c.name, Company: c.company, Family: c.family };
31
+ const agent = (input) => ({
32
+ UserAgent: input.userAgent,
33
+ Client: info(input.client),
34
+ OS: info(input.os),
35
+ Platform: input.platform ?? null,
36
+ Geo: input.geo === undefined
37
+ ? null
38
+ : Object.fromEntries(Object.entries({
39
+ CountryISOCode: input.geo.countryISOCode,
40
+ Country: input.geo.country,
41
+ RegionISOCode: input.geo.regionISOCode,
42
+ Region: input.geo.region,
43
+ City: input.geo.city,
44
+ Zip: input.geo.zip,
45
+ Coords: input.geo.coords,
46
+ IP: input.geo.ip,
47
+ }).filter(([, v]) => v !== undefined)),
48
+ });
49
+ const refusedAs400 = async (record) => {
50
+ try {
51
+ return await record();
52
+ }
53
+ catch (error) {
54
+ if (error instanceof TrackingRefused)
55
+ throw new ControlError(error.message);
56
+ throw error;
57
+ }
58
+ };
59
+ const eventReply = (e) => ({
60
+ MessageID: e.MessageID,
61
+ Recipient: e.Recipient,
62
+ ReceivedAt: formatTimestamp(e.ReceivedAt),
63
+ });
64
+ defineControl({
65
+ method: "POST",
66
+ path: "/control/events/delivery",
67
+ handler: async (ctx) => {
68
+ const input = controlInput(z.object({ ...target, details: z.string().default("smtp;250 2.0.0 OK") }), ctx.body);
69
+ const delivery = await refusedAs400(() => recordDelivery(ctx, input));
70
+ return eventReply(delivery);
71
+ },
72
+ });
73
+ defineControl({
74
+ method: "POST",
75
+ path: "/control/events/open",
76
+ handler: async (ctx) => {
77
+ const input = controlInput(z.object({ ...target, ...agentSchema, readSeconds: z.int().nonnegative().default(0) }), ctx.body);
78
+ const open = await refusedAs400(() => recordOpen(ctx, { ...input, agent: agent(input) }));
79
+ return { ...eventReply(open), FirstOpen: open.FirstOpen };
80
+ },
81
+ });
82
+ defineControl({
83
+ method: "POST",
84
+ path: "/control/events/click",
85
+ handler: async (ctx) => {
86
+ const input = controlInput(z.object({
87
+ ...target,
88
+ ...agentSchema,
89
+ link: z.string(),
90
+ clickLocation: z.enum(["HTML", "Text"]),
91
+ }), ctx.body);
92
+ const click = await refusedAs400(() => recordClick(ctx, { ...input, location: input.clickLocation, agent: agent(input) }));
93
+ return eventReply(click);
94
+ },
95
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { formatTimestamp } from "../../time.js";
2
+ import { ControlError, defineControl } from "../registry.js";
3
+ // The webhook delivery log (docs/05 §6): one row per POST attempt, oldest first.
4
+ defineControl({
5
+ method: "GET",
6
+ path: "/control/webhooks/attempts",
7
+ handler: ({ store, query }) => {
8
+ const int = (name) => {
9
+ const value = query.get(name);
10
+ if (value !== null && !/^\d+$/.test(value))
11
+ throw new ControlError(`${name} must be an integer`);
12
+ return value === null ? null : Number(value);
13
+ };
14
+ const [serverId, webhookId] = [int("serverId"), int("webhookId")];
15
+ const recordType = query.get("recordType");
16
+ const rows = store.state.webhookAttempts.filter((a) => (serverId === null || a.serverId === serverId) &&
17
+ (webhookId === null || a.webhookId === webhookId) &&
18
+ (recordType === null || a.recordType === recordType));
19
+ return {
20
+ Attempts: rows.map((a) => ({
21
+ ServerID: a.serverId,
22
+ WebhookID: a.webhookId,
23
+ RecordType: a.recordType,
24
+ Url: a.url,
25
+ TraceID: a.traceId,
26
+ Attempt: a.attempt,
27
+ At: formatTimestamp(a.at, "utc"),
28
+ Headers: a.headers,
29
+ Body: JSON.parse(a.body),
30
+ ...("status" in a.outcome ? { HttpStatus: a.outcome.status } : { Error: a.outcome.error }),
31
+ Result: a.result,
32
+ NextAttemptAt: a.nextAttemptAt === null ? null : formatTimestamp(a.nextAttemptAt, "utc"),
33
+ })),
34
+ };
35
+ },
36
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { importAll } from "../discover.js";
2
+ // Loads every `src/control/endpoints/*.ts`; each registers its endpoints with `defineControl`.
3
+ await importAll(new URL("./endpoints/", import.meta.url));
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import { type Method, RouteTable } from "../http/routes.ts";
3
+ import type { Runtime } from "../runtime.ts";
4
+ /** A control request the test got wrong. Answers 400 `{"error": "..."}` (CONTROL-API.md). */
5
+ export declare class ControlError extends Error {
6
+ }
7
+ export interface ControlContext extends Runtime {
8
+ params: Record<string, string>;
9
+ query: URLSearchParams;
10
+ /** Parsed JSON body; `undefined` when empty. */
11
+ body: unknown;
12
+ /** The seed postmock started with; `POST /control/reset` applies it again. */
13
+ startupSeed: string;
14
+ }
15
+ export interface ControlRoute {
16
+ method: Method;
17
+ path: string;
18
+ /** Returns the JSON body of a 200 response. */
19
+ handler: (ctx: ControlContext) => unknown;
20
+ }
21
+ export declare const controlRoutes: RouteTable<ControlRoute>;
22
+ /** Registers a control endpoint under `/control`. Each `src/control/<topic>.ts` calls it at import. */
23
+ export declare function defineControl(route: ControlRoute): void;
24
+ /** Parses a control body with zod; a failure is a `ControlError` naming the fields. */
25
+ export declare function controlInput<S extends z.ZodType>(schema: S, value: unknown): z.output<S>;
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ import { RouteTable } from "../http/routes.js";
3
+ /** A control request the test got wrong. Answers 400 `{"error": "..."}` (CONTROL-API.md). */
4
+ export class ControlError extends Error {
5
+ }
6
+ export const controlRoutes = new RouteTable();
7
+ /** Registers a control endpoint under `/control`. Each `src/control/<topic>.ts` calls it at import. */
8
+ export function defineControl(route) {
9
+ if (!route.path.startsWith("/control/"))
10
+ throw new Error(`control path ${route.path}`);
11
+ controlRoutes.add(route);
12
+ }
13
+ /** Parses a control body with zod; a failure is a `ControlError` naming the fields. */
14
+ export function controlInput(schema, value) {
15
+ const result = schema.safeParse(value ?? {});
16
+ if (!result.success)
17
+ throw new ControlError(z.prettifyError(result.error));
18
+ return result.data;
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { Runtime } from "../runtime.ts";
2
+ /** A seed builds state that real Postmark could hold, directly in the store (CONTROL-API.md). */
3
+ export type Seed = (runtime: Runtime) => void | Promise<void>;
4
+ /**
5
+ * A seed made of every part file in `dir`, applied in filename order. Each track adds its own part
6
+ * file (docs/11 §5). A part uses fixed IDs (`store.useId`), so its IDs do not depend on other parts.
7
+ */
8
+ export declare const seedFromDirectory: (dir: URL) => Seed;
9
+ export declare const seedNames: () => string[];
10
+ /** Loads `seeds/<name>.ts` (`.js` in the package) and applies its default export. */
11
+ export declare function applySeed(runtime: Runtime, name: string): Promise<void>;
@@ -0,0 +1,32 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { defaultFunction, importAll, MODULE_EXTENSION } from "../discover.js";
11
+ const SEEDS_DIR = new URL("../../seeds/", import.meta.url);
12
+ /**
13
+ * A seed made of every part file in `dir`, applied in filename order. Each track adds its own part
14
+ * file (docs/11 §5). A part uses fixed IDs (`store.useId`), so its IDs do not depend on other parts.
15
+ */
16
+ export const seedFromDirectory = (dir) => async (runtime) => {
17
+ for (const part of await importAll(dir))
18
+ await defaultFunction(part)(runtime);
19
+ };
20
+ export const seedNames = () => readdirSync(SEEDS_DIR)
21
+ .filter((f) => f.endsWith(MODULE_EXTENSION) && !f.endsWith(`.test${MODULE_EXTENSION}`))
22
+ .map((f) => f.slice(0, -MODULE_EXTENSION.length))
23
+ .sort();
24
+ /** Loads `seeds/<name>.ts` (`.js` in the package) and applies its default export. */
25
+ export async function applySeed(runtime, name) {
26
+ const file = new URL(`${name}${MODULE_EXTENSION}`, SEEDS_DIR);
27
+ if (!/^[a-z0-9-]+$/.test(name) || !existsSync(file)) {
28
+ throw new Error(`unknown seed '${name}'; seeds: ${seedNames().join(", ")}`);
29
+ }
30
+ const seed = defaultFunction({ url: file, module: await import(__rewriteRelativeImportExtension(file.href)) });
31
+ await runtime.store.seeding(async () => seed(runtime));
32
+ }
@@ -0,0 +1,10 @@
1
+ import type { Runtime } from "../runtime.ts";
2
+ import { applySeed } from "./seed.ts";
3
+ /**
4
+ * Applies a seed, after a full reset when `reset` is set, or changes nothing: a failing seed
5
+ * restores the state, the clock offset and the pending clock tasks, and answers 400.
6
+ * The clock resets before the seed, so seeded dates and seed-scheduled tasks use the fresh clock.
7
+ */
8
+ export declare function seedAtomically(runtime: Runtime, name: string, { reset }: {
9
+ reset: boolean;
10
+ }, apply?: typeof applySeed): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { ControlError } from "./registry.js";
2
+ import { applySeed, seedNames } from "./seed.js";
3
+ /**
4
+ * Applies a seed, after a full reset when `reset` is set, or changes nothing: a failing seed
5
+ * restores the state, the clock offset and the pending clock tasks, and answers 400.
6
+ * The clock resets before the seed, so seeded dates and seed-scheduled tasks use the fresh clock.
7
+ */
8
+ export async function seedAtomically(runtime, name, { reset }, apply = applySeed) {
9
+ if (!seedNames().includes(name)) {
10
+ throw new ControlError(`unknown seed '${name}'; seeds: ${seedNames().join(", ")}`);
11
+ }
12
+ await runtime.clock.idle();
13
+ const state = structuredClone(runtime.store.state);
14
+ const restoreClock = runtime.clock.checkpoint();
15
+ try {
16
+ if (reset) {
17
+ runtime.store.reset();
18
+ runtime.clock.reset();
19
+ }
20
+ await apply(runtime, name);
21
+ }
22
+ catch (error) {
23
+ runtime.store.state = state;
24
+ restoreClock();
25
+ throw new ControlError(`seed '${name}' failed: ${error.message}`);
26
+ }
27
+ }