@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,77 @@
1
+ import { z } from "zod";
2
+ import { apiError } from "../../errors.js";
3
+ import { absent } from "../../http/normalize.js";
4
+ import { mergeModels, parseTemplate, renderTemplate, suggestModel, } from "../../render/mustachio.js";
5
+ import { checkInlineCss } from "./content.js";
6
+ import { activeLayout, CONTENT_PARTS, layoutRuleError, parseOrReject, parseTemplateType, templateModelField, } from "./templates.js";
7
+ // `POST /templates/validate` (docs/06 §3.3; refs/api_templates-api.md:779-887).
8
+ const text = absent(z.string());
9
+ const validateBody = z.object({
10
+ Subject: text,
11
+ HtmlBody: text,
12
+ TextBody: text,
13
+ TestRenderModel: templateModelField,
14
+ InlineCssForHtmlTestRender: absent(z.boolean()),
15
+ TemplateType: text,
16
+ LayoutTemplate: text,
17
+ });
18
+ export function validateTemplate(state, serverId, body) {
19
+ const input = parseOrReject(validateBody, body);
20
+ if (CONTENT_PARTS.every((part) => input[part] === undefined)) {
21
+ // refs/api_templates-api.md:813-815: one part is required. Code and text INFERRED.
22
+ throw apiError(1120, { message: "One of 'Subject', 'HtmlBody' or 'TextBody' is required." });
23
+ }
24
+ const type = parseTemplateType(input.TemplateType);
25
+ if (type === "Layout" && input.LayoutTemplate !== undefined) {
26
+ throw apiError(1123, { message: "A layout template cannot use a 'LayoutTemplate'." });
27
+ }
28
+ const layout = input.LayoutTemplate === undefined ? null : activeLayout(state, serverId, input.LayoutTemplate);
29
+ const parsed = new Map();
30
+ const results = new Map();
31
+ for (const part of CONTENT_PARTS) {
32
+ const source = input[part];
33
+ if (source === undefined)
34
+ continue;
35
+ const result = parseTemplate(source);
36
+ const ruleError = result.ok
37
+ ? layoutRuleError(type, part, result.template.contentPlaceholders)
38
+ : undefined;
39
+ if (!result.ok || ruleError !== undefined) {
40
+ // RenderedContent is null for invalid content (INFERRED, docs/06 Q7).
41
+ const errors = result.ok
42
+ ? [{ Message: ruleError ?? "", Line: null, CharacterPosition: null }]
43
+ : result.errors;
44
+ results.set(part, { ContentIsValid: false, ValidationErrors: errors, RenderedContent: null });
45
+ }
46
+ else {
47
+ parsed.set(part, result.template);
48
+ }
49
+ }
50
+ // The layout's own keys join the suggestion (INFERRED).
51
+ const frames = new Map();
52
+ for (const part of ["HtmlBody", "TextBody"]) {
53
+ const source = layout?.[part] ?? null;
54
+ const frame = source === null ? undefined : parseTemplate(source);
55
+ if (frame?.ok)
56
+ frames.set(part, frame.template);
57
+ }
58
+ const SuggestedTemplateModel = mergeModels(suggestModel([...parsed.values(), ...frames.values()]), input.TestRenderModel ?? {});
59
+ for (const [part, template] of parsed) {
60
+ // A layout renders with an empty `{{{ @content }}}` (INFERRED).
61
+ let rendered = renderTemplate(template, SuggestedTemplateModel);
62
+ const frame = frames.get(part);
63
+ if (frame !== undefined)
64
+ rendered = renderTemplate(frame, SuggestedTemplateModel, rendered);
65
+ if (part === "HtmlBody")
66
+ checkInlineCss(rendered, input.InlineCssForHtmlTestRender ?? true);
67
+ results.set(part, { ContentIsValid: true, ValidationErrors: [], RenderedContent: rendered });
68
+ }
69
+ // A part not sent is null (INFERRED from sdk/postmark-python/tests/test_templates.py:374-375).
70
+ return {
71
+ AllContentIsValid: [...results.values()].every((r) => r.ContentIsValid),
72
+ HtmlBody: results.get("HtmlBody") ?? null,
73
+ TextBody: results.get("TextBody") ?? null,
74
+ Subject: results.get("Subject") ?? null,
75
+ SuggestedTemplateModel,
76
+ };
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { z } from "zod";
2
+ import { apiError } from "../../errors.js";
3
+ import { parseBody, queryInt } from "../../http/normalize.js";
4
+ import { paged } from "../../http/respond.js";
5
+ import { defineRoute } from "../../http/routes.js";
6
+ // Inbound rule triggers (docs/05 §1.6). postmark.js and java spell the path `inboundRules`; route
7
+ // literals match without case.
8
+ const MAX_COUNT = 500;
9
+ defineRoute({
10
+ method: "GET",
11
+ path: "/triggers/inboundrules",
12
+ auth: "server",
13
+ handler: ({ store, query, auth }) => {
14
+ const parsed = query.pick(z.object({ count: queryInt, offset: queryInt }));
15
+ // 800 texts INFERRED from the docs/02 §4.4 summary.
16
+ if (!parsed.success || parsed.data.count < 0 || parsed.data.offset < 0) {
17
+ throw apiError(800, {
18
+ message: "The 'count' and 'offset' parameters are required integers.",
19
+ });
20
+ }
21
+ const { count, offset } = parsed.data;
22
+ if (count > MAX_COUNT) {
23
+ throw apiError(800, { message: "You may only request up to 500 triggers per call." });
24
+ }
25
+ const rules = [...store.state.inboundRules.values()]
26
+ .filter((r) => r.ServerID === auth.server.ID)
27
+ .map((r) => ({ ID: r.ID, Rule: r.Rule }));
28
+ return paged("InboundRules", rules, count, offset);
29
+ },
30
+ });
31
+ defineRoute({
32
+ method: "POST",
33
+ path: "/triggers/inboundrules",
34
+ auth: "server",
35
+ handler: ({ store, body, auth }) => {
36
+ const parsed = parseBody(z.object({ Rule: z.string().trim().min(1) }), body);
37
+ // A rule is an address or a domain (refs/api_inbound-rules-triggers-api.md:98); postmock checks
38
+ // no form (INFERRED).
39
+ if (!parsed.success)
40
+ throw apiError(809);
41
+ const rule = parsed.data.Rule;
42
+ const rules = [...store.state.inboundRules.values()].filter((r) => r.ServerID === auth.server.ID);
43
+ if (rules.some((r) => r.Rule.toLowerCase() === rule.toLowerCase()))
44
+ throw apiError(810);
45
+ const created = { ID: store.nextId("inboundRule"), ServerID: auth.server.ID, Rule: rule };
46
+ store.state.inboundRules.set(created.ID, created);
47
+ return { ID: created.ID, Rule: created.Rule };
48
+ },
49
+ });
50
+ defineRoute({
51
+ method: "DELETE",
52
+ path: "/triggers/inboundrules/:id",
53
+ auth: "server",
54
+ handler: ({ store, params, auth }) => {
55
+ const id = /^\d+$/.test(params.id ?? "") ? Number(params.id) : undefined;
56
+ const rule = id === undefined ? undefined : store.state.inboundRules.get(id);
57
+ if (rule === undefined || rule.ServerID !== auth.server.ID)
58
+ throw apiError(812);
59
+ store.state.inboundRules.delete(rule.ID);
60
+ return { ErrorCode: 0, Message: `Rule ${rule.Rule} removed.` };
61
+ },
62
+ });
@@ -0,0 +1,14 @@
1
+ import type { Webhook, WebhookTriggers } from "../../state/types.ts";
2
+ /** The wire shape (refs/api_webhooks-api.md:155-227). No auth or headers: `null` and `[]` (INFERRED). */
3
+ export declare const webhookJson: (w: Webhook) => {
4
+ ID: number;
5
+ Url: string;
6
+ MessageStream: string;
7
+ Status: "unverified" | "verified";
8
+ HttpAuth: {
9
+ Username: string;
10
+ Password: string;
11
+ } | null;
12
+ HttpHeaders: import("../../state/types.ts").Header[];
13
+ Triggers: WebhookTriggers;
14
+ };
@@ -0,0 +1,192 @@
1
+ import { z } from "zod";
2
+ import { apiError } from "../../errors.js";
3
+ import { parseBody } from "../../http/normalize.js";
4
+ import { Unsupported } from "../../http/respond.js";
5
+ import { defineRoute } from "../../http/routes.js";
6
+ import { streamKey } from "../../state/store.js";
7
+ // The webhooks API (docs/05 §1.1–1.4). Errors are ErrorCodes 1350–1364.
8
+ const orNull = (schema) => z.preprocess((v) => (v === null ? undefined : v), schema.optional());
9
+ const flag = orNull(z.boolean());
10
+ const trigger = z.object({ Enabled: flag });
11
+ const triggersSchema = z.object({
12
+ Open: orNull(z.object({ Enabled: flag, PostFirstOpenOnly: flag })),
13
+ Click: orNull(trigger),
14
+ Delivery: orNull(trigger),
15
+ Bounce: orNull(z.object({ Enabled: flag, IncludeContent: flag })),
16
+ SpamComplaint: orNull(z.object({ Enabled: flag, IncludeContent: flag })),
17
+ SubscriptionChange: orNull(trigger),
18
+ });
19
+ const bodySchema = z.object({
20
+ ID: orNull(z.unknown()),
21
+ Status: orNull(z.unknown()),
22
+ Url: orNull(z.string()),
23
+ MessageStream: orNull(z.string()),
24
+ HttpAuth: orNull(z.object({ Username: z.string(), Password: z.string() })),
25
+ HttpHeaders: orNull(z.array(z.object({ Name: z.string(), Value: z.string() }))),
26
+ Triggers: orNull(triggersSchema),
27
+ Verify: orNull(z.boolean()),
28
+ });
29
+ function parse(body) {
30
+ if (body === undefined)
31
+ throw apiError(1355);
32
+ const parsed = parseBody(bodySchema, body);
33
+ if (!parsed.success)
34
+ throw apiError(1361);
35
+ const data = parsed.data;
36
+ if (data.Status !== undefined)
37
+ throw apiError(1363);
38
+ // `Verify` absent or true saves with no probe (INFERRED until docs/05 Q13 captures the probe):
39
+ // SDK live tests create webhooks at hosts that answer no probe (docs/05 §1.3 conflict V1).
40
+ const url = data.Url;
41
+ if (url !== undefined && !isWebhookUrl(url))
42
+ throw apiError(1354);
43
+ // An HTTP header name is an RFC 9110 token.
44
+ if (data.HttpHeaders?.some((h) => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(h.Name))) {
45
+ throw apiError(1358);
46
+ }
47
+ return data;
48
+ }
49
+ function isWebhookUrl(value) {
50
+ try {
51
+ const url = new URL(value);
52
+ return url.protocol === "http:" || url.protocol === "https:";
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ const FALSE_TRIGGERS = {
59
+ Open: { Enabled: false, PostFirstOpenOnly: false },
60
+ Click: { Enabled: false },
61
+ Delivery: { Enabled: false },
62
+ Bounce: { Enabled: false, IncludeContent: false },
63
+ SpamComplaint: { Enabled: false, IncludeContent: false },
64
+ SubscriptionChange: { Enabled: false },
65
+ };
66
+ /** A partial `Triggers` object changes only the given triggers and sub-fields (docs/05 §1.2). */
67
+ function mergeTriggers(current, patch) {
68
+ const merged = structuredClone(current);
69
+ for (const [name, fields] of Object.entries(patch ?? {})) {
70
+ if (fields === undefined)
71
+ continue;
72
+ const target = merged[name];
73
+ for (const [field, value] of Object.entries(fields)) {
74
+ if (typeof value === "boolean")
75
+ target[field] = value;
76
+ }
77
+ }
78
+ return merged;
79
+ }
80
+ /** The wire shape (refs/api_webhooks-api.md:155-227). No auth or headers: `null` and `[]` (INFERRED). */
81
+ export const webhookJson = (w) => ({
82
+ ID: w.ID,
83
+ Url: w.Url,
84
+ MessageStream: w.MessageStream,
85
+ Status: w.Status,
86
+ HttpAuth: w.HttpAuth,
87
+ HttpHeaders: w.HttpHeaders,
88
+ Triggers: w.Triggers,
89
+ });
90
+ function findWebhook({ store, params, auth }) {
91
+ const id = /^\d+$/.test(params.id ?? "") ? Number(params.id) : undefined;
92
+ const webhook = id === undefined ? undefined : store.state.webhooks.get(id);
93
+ if (webhook === undefined || webhook.ServerID !== auth.server.ID)
94
+ throw apiError(1352);
95
+ return webhook;
96
+ }
97
+ defineRoute({
98
+ method: "GET",
99
+ path: "/webhooks",
100
+ auth: "server",
101
+ handler: ({ store, query, auth }) => {
102
+ // Doc spells the filter `MessageStream`, postmark.js `messageStream`; the query matches both.
103
+ const stream = query.get("MessageStream");
104
+ const webhooks = [...store.state.webhooks.values()].filter((w) => w.ServerID === auth.server.ID && (stream === undefined || w.MessageStream === stream));
105
+ return { Webhooks: webhooks.map(webhookJson) };
106
+ },
107
+ });
108
+ defineRoute({
109
+ method: "GET",
110
+ path: "/webhooks/:id",
111
+ auth: "server",
112
+ handler: (ctx) => webhookJson(findWebhook(ctx)),
113
+ });
114
+ defineRoute({
115
+ method: "POST",
116
+ path: "/webhooks",
117
+ auth: "server",
118
+ handler: ({ store, body, auth }) => {
119
+ const data = parse(body);
120
+ if (data.ID !== undefined)
121
+ throw apiError(1356);
122
+ if (data.Url === undefined)
123
+ throw apiError(1354);
124
+ const streamId = data.MessageStream ?? "outbound";
125
+ const stream = store.state.streams.get(streamKey(auth.server.ID, streamId));
126
+ // No webhooks ErrorCode names an unknown stream; 1226 is the streams API text (INFERRED).
127
+ if (stream === undefined)
128
+ throw apiError(1226, { family: "streams" });
129
+ if (stream.ArchivedAt !== null)
130
+ throw apiError(1350);
131
+ if (stream.MessageStreamType === "Inbound")
132
+ throw apiError(1351);
133
+ const webhook = {
134
+ ID: store.nextId("webhook"),
135
+ ServerID: auth.server.ID,
136
+ Url: data.Url,
137
+ MessageStream: stream.ID,
138
+ Status: data.Verify === false ? "unverified" : "verified",
139
+ HttpAuth: data.HttpAuth ?? null,
140
+ HttpHeaders: data.HttpHeaders ?? [],
141
+ Triggers: mergeTriggers(FALSE_TRIGGERS, data.Triggers),
142
+ };
143
+ store.state.webhooks.set(webhook.ID, webhook);
144
+ return webhookJson(webhook);
145
+ },
146
+ });
147
+ defineRoute({
148
+ method: "PUT",
149
+ path: "/webhooks/:id",
150
+ auth: "server",
151
+ handler: (ctx) => {
152
+ const webhook = findWebhook(ctx);
153
+ const data = parse(ctx.body);
154
+ if ((data.ID !== undefined && data.ID !== webhook.ID) ||
155
+ (data.MessageStream !== undefined && data.MessageStream !== webhook.MessageStream)) {
156
+ throw apiError(1357);
157
+ }
158
+ Object.assign(webhook, {
159
+ ...(data.Url !== undefined && { Url: data.Url }),
160
+ ...(data.HttpAuth !== undefined && { HttpAuth: data.HttpAuth }),
161
+ ...(data.HttpHeaders !== undefined && { HttpHeaders: data.HttpHeaders }),
162
+ ...(data.Verify === false && { Status: "unverified" }),
163
+ Triggers: mergeTriggers(webhook.Triggers, data.Triggers),
164
+ });
165
+ return webhookJson(webhook);
166
+ },
167
+ });
168
+ defineRoute({
169
+ method: "DELETE",
170
+ path: "/webhooks/:id",
171
+ auth: "server",
172
+ handler: (ctx) => {
173
+ const webhook = findWebhook(ctx);
174
+ ctx.store.state.webhooks.delete(webhook.ID);
175
+ return { ErrorCode: 0, Message: `Webhook ${webhook.ID} removed.` };
176
+ },
177
+ });
178
+ // No SDK calls these; their probe body (Q13) and slow thresholds (Q19) are not captured.
179
+ for (const [method, path, question] of [
180
+ ["POST", "/webhooks/:id/verify", "Q13"],
181
+ ["GET", "/webhooks/:id/statistics", "Q19"],
182
+ ]) {
183
+ defineRoute({
184
+ method,
185
+ path,
186
+ auth: "server",
187
+ handler: (ctx) => {
188
+ findWebhook(ctx);
189
+ throw new Unsupported(`${method} ${path} not captured (docs/05 ${question})`);
190
+ },
191
+ });
192
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ import type { PostmockConfig } from "./server.ts";
3
+ /** Every setting is one of these env keys. Plugins read their own keys when they start. */
4
+ export declare const ENV_KEYS: readonly ["POSTMOCK_HOST", "POSTMOCK_API_PORT", "POSTMOCK_CONTROL_PORT", "POSTMOCK_SEED", "POSTMOCK_HTTPS_PORT", "POSTMOCK_HTTPS_TLS_KEY", "POSTMOCK_HTTPS_TLS_CERT", "POSTMOCK_SMTP_PORTS", "POSTMOCK_SMTP_TLS_KEY", "POSTMOCK_SMTP_TLS_CERT", "POSTMOCK_WEBHOOKS_ALLOW_HOSTS"];
5
+ export declare const flagOf: (key: string) => string;
6
+ export declare const USAGE: string;
7
+ /**
8
+ * Writes each flag in `argv` into `env` under its key, so plugins that read `process.env` see it.
9
+ * Returns `help` for `--help`. An unknown flag, a positional argument, or an empty value throws.
10
+ */
11
+ export declare function applyFlags(argv: string[], env: NodeJS.ProcessEnv): "help" | "run";
12
+ /** A decimal TCP port; `0` picks a free port. */
13
+ export declare const portSchema: z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<number, string>>, z.ZodInt>;
14
+ /** The content of the PEM file that env key `key` names. */
15
+ export declare function readPem(key: string, path: string): string;
16
+ /** The core listeners' config from env. A bad value throws. */
17
+ export declare function configFromEnv(env: NodeJS.ProcessEnv): PostmockConfig;
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { parseArgs } from "node:util";
3
+ import { z } from "zod";
4
+ /** Every setting is one of these env keys. Plugins read their own keys when they start. */
5
+ export const ENV_KEYS = [
6
+ "POSTMOCK_HOST",
7
+ "POSTMOCK_API_PORT",
8
+ "POSTMOCK_CONTROL_PORT",
9
+ "POSTMOCK_SEED",
10
+ "POSTMOCK_HTTPS_PORT",
11
+ "POSTMOCK_HTTPS_TLS_KEY",
12
+ "POSTMOCK_HTTPS_TLS_CERT",
13
+ "POSTMOCK_SMTP_PORTS",
14
+ "POSTMOCK_SMTP_TLS_KEY",
15
+ "POSTMOCK_SMTP_TLS_CERT",
16
+ "POSTMOCK_WEBHOOKS_ALLOW_HOSTS",
17
+ ];
18
+ export const flagOf = (key) => key.slice("POSTMOCK_".length).toLowerCase().replaceAll("_", "-");
19
+ export const USAGE = [
20
+ "usage: postmock [--<flag> <value>]...",
21
+ "Each flag sets an env key; a flag wins over the env:",
22
+ ...ENV_KEYS.map((key) => ` --${flagOf(key).padEnd(22)} ${key}`),
23
+ ].join("\n");
24
+ /**
25
+ * Writes each flag in `argv` into `env` under its key, so plugins that read `process.env` see it.
26
+ * Returns `help` for `--help`. An unknown flag, a positional argument, or an empty value throws.
27
+ */
28
+ export function applyFlags(argv, env) {
29
+ const { values } = parseArgs({
30
+ args: argv,
31
+ options: {
32
+ help: { type: "boolean" },
33
+ ...Object.fromEntries(ENV_KEYS.map((key) => [flagOf(key), { type: "string" }])),
34
+ },
35
+ });
36
+ if (values.help)
37
+ return "help";
38
+ for (const key of ENV_KEYS) {
39
+ const value = values[flagOf(key)];
40
+ if (typeof value === "string")
41
+ env[key] = value;
42
+ }
43
+ for (const key of ENV_KEYS) {
44
+ if (env[key] === "")
45
+ throw new Error(`${key} is empty; unset it or give a value`);
46
+ }
47
+ return "run";
48
+ }
49
+ /** A decimal TCP port; `0` picks a free port. */
50
+ export const portSchema = z
51
+ .string()
52
+ .regex(/^\d+$/, "a decimal port")
53
+ .transform(Number)
54
+ .pipe(z.int().max(65535));
55
+ /** The content of the PEM file that env key `key` names. */
56
+ export function readPem(key, path) {
57
+ if (!existsSync(path))
58
+ throw new Error(`${key}: no file at ${path}`);
59
+ return readFileSync(path, "utf8");
60
+ }
61
+ const envSchema = z.object({
62
+ POSTMOCK_HOST: z.string().min(1).default("127.0.0.1"),
63
+ POSTMOCK_API_PORT: portSchema.default(8080),
64
+ POSTMOCK_CONTROL_PORT: portSchema.default(8025),
65
+ POSTMOCK_SEED: z.string().min(1).default("empty"),
66
+ POSTMOCK_HTTPS_PORT: portSchema.optional(),
67
+ POSTMOCK_HTTPS_TLS_KEY: z.string().min(1).optional(),
68
+ POSTMOCK_HTTPS_TLS_CERT: z.string().min(1).optional(),
69
+ });
70
+ /** The core listeners' config from env. A bad value throws. */
71
+ export function configFromEnv(env) {
72
+ const e = envSchema.parse(env);
73
+ const config = {
74
+ host: e.POSTMOCK_HOST,
75
+ apiPort: e.POSTMOCK_API_PORT,
76
+ controlPort: e.POSTMOCK_CONTROL_PORT,
77
+ seed: e.POSTMOCK_SEED,
78
+ };
79
+ const https = [e.POSTMOCK_HTTPS_PORT, e.POSTMOCK_HTTPS_TLS_KEY, e.POSTMOCK_HTTPS_TLS_CERT];
80
+ if (https.every((v) => v === undefined))
81
+ return config;
82
+ if (e.POSTMOCK_HTTPS_PORT === undefined ||
83
+ e.POSTMOCK_HTTPS_TLS_KEY === undefined ||
84
+ e.POSTMOCK_HTTPS_TLS_CERT === undefined) {
85
+ throw new Error("set POSTMOCK_HTTPS_PORT, POSTMOCK_HTTPS_TLS_KEY and POSTMOCK_HTTPS_TLS_CERT, or none");
86
+ }
87
+ return {
88
+ ...config,
89
+ https: {
90
+ port: e.POSTMOCK_HTTPS_PORT,
91
+ key: readPem("POSTMOCK_HTTPS_TLS_KEY", e.POSTMOCK_HTTPS_TLS_KEY),
92
+ cert: readPem("POSTMOCK_HTTPS_TLS_CERT", e.POSTMOCK_HTTPS_TLS_CERT),
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,5 @@
1
+ import { Hono } from "hono";
2
+ import "./index.ts";
3
+ import type { Runtime } from "../runtime.ts";
4
+ /** The test-facing control API on its own port (docs/09 §3, §5). */
5
+ export declare function createControlApp(runtime: Runtime, startupSeed: string): Hono;
@@ -0,0 +1,42 @@
1
+ import { Hono } from "hono";
2
+ import "./index.js";
3
+ import { textResponse, toJson } from "../http/respond.js";
4
+ import { ControlError, controlRoutes } from "./registry.js";
5
+ const json = (status, body) => new Response(toJson(body), { status, headers: { "Content-Type": "application/json" } });
6
+ /** The test-facing control API on its own port (docs/09 §3, §5). */
7
+ export function createControlApp(runtime, startupSeed) {
8
+ const app = new Hono();
9
+ app.all("*", async (c) => {
10
+ const url = new URL(c.req.url);
11
+ const matched = controlRoutes.match(c.req.method, url.pathname);
12
+ if (!matched)
13
+ return json(404, { error: `no control route ${c.req.method} ${url.pathname}` });
14
+ let body;
15
+ try {
16
+ body = JSON.parse(new TextDecoder().decode(await c.req.arrayBuffer()) || "null") ?? undefined;
17
+ }
18
+ catch {
19
+ return json(400, { error: "body is not JSON" });
20
+ }
21
+ try {
22
+ const result = await matched.route.handler({
23
+ ...runtime,
24
+ params: matched.params,
25
+ query: url.searchParams,
26
+ body,
27
+ startupSeed,
28
+ });
29
+ return json(200, result);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ControlError)
33
+ return json(400, { error: error.message });
34
+ throw error;
35
+ }
36
+ });
37
+ app.onError((error) => {
38
+ console.error(error);
39
+ return textResponse(500, `postmock crashed: ${error.stack ?? error.message}`);
40
+ });
41
+ return app;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { confirmDkim } from "../../api/account/authentication.js";
3
+ import { domainJson } from "../../api/account/domains.js";
4
+ import { pathId } from "../../api/account/paging.js";
5
+ import { senderJson } from "../../api/account/senders.js";
6
+ import { ControlError, controlInput, defineControl } from "../registry.js";
7
+ // Account events postmock cannot observe: a support decision, DNS records, a click on a confirmation link.
8
+ defineControl({
9
+ method: "POST",
10
+ path: "/control/account/server-deletion",
11
+ handler: ({ store, body }) => {
12
+ const { enabled } = controlInput(z.object({ enabled: z.boolean() }), body);
13
+ store.state.account.serverDeletionEnabled = enabled;
14
+ return { serverDeletionEnabled: enabled };
15
+ },
16
+ });
17
+ const verifyInput = z
18
+ .object({ dkim: z.literal(true).optional(), returnPath: z.literal(true).optional() })
19
+ .refine((v) => v.dkim || v.returnPath, "set dkim or returnPath to true");
20
+ /**
21
+ * Postmark finds the DNS records: the pending DKIM key becomes active, the Return-Path CNAME
22
+ * points to `pm.mtasv.net`. It never finds a record for a key or Return-Path that does not exist.
23
+ */
24
+ function verify(auth, body) {
25
+ const { dkim, returnPath } = controlInput(verifyInput, body);
26
+ if (dkim && auth.DKIMUpdateStatus !== "Pending")
27
+ throw new ControlError("no pending DKIM key");
28
+ if (returnPath && auth.ReturnPathDomain === "")
29
+ throw new ControlError("no ReturnPathDomain set");
30
+ if (dkim)
31
+ confirmDkim(auth);
32
+ if (returnPath)
33
+ auth.ReturnPathDomainVerified = true;
34
+ }
35
+ const found = (item, what) => {
36
+ if (item === undefined)
37
+ throw new ControlError(`${what} not found`);
38
+ return item;
39
+ };
40
+ defineControl({
41
+ method: "POST",
42
+ path: "/control/domains/:id/verify",
43
+ handler: ({ store, params, body }) => {
44
+ const domain = found(store.state.domains.get(pathId(params.id) ?? -1), "domain");
45
+ verify(domain, body);
46
+ return domainJson(domain);
47
+ },
48
+ });
49
+ defineControl({
50
+ method: "POST",
51
+ path: "/control/senders/:id/verify",
52
+ handler: ({ store, params, body }) => {
53
+ const sender = found(store.state.senders.get(pathId(params.id) ?? -1), "sender");
54
+ verify(sender, body);
55
+ return senderJson(sender);
56
+ },
57
+ });
58
+ defineControl({
59
+ method: "POST",
60
+ path: "/control/senders/:id/confirm",
61
+ handler: ({ store, params }) => {
62
+ const sender = found(store.state.senders.get(pathId(params.id) ?? -1), "sender");
63
+ if (sender.Confirmed)
64
+ throw new ControlError("sender already confirmed");
65
+ sender.Confirmed = true;
66
+ return senderJson(sender);
67
+ },
68
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { bulkStatusJson } from "../../api/bulk/bulk.js";
2
+ import { ControlError, defineControl } from "../registry.js";
3
+ // Postmark cancels a bulk request that has not finished (refs/api_bulk-email.md:190 `Cancelled`).
4
+ // The messages not yet released stay unsent; the counters keep their values.
5
+ defineControl({
6
+ method: "POST",
7
+ path: "/control/bulk/:id/cancel",
8
+ handler: ({ store, params }) => {
9
+ const bulk = store.state.bulkRequests.get(params.id ?? "");
10
+ if (bulk === undefined)
11
+ throw new ControlError(`no bulk request '${params.id}'`);
12
+ if (bulk.Status !== "Accepted" && bulk.Status !== "Processing") {
13
+ throw new ControlError(`bulk request '${bulk.Id}' is ${bulk.Status}`);
14
+ }
15
+ bulk.Status = "Cancelled";
16
+ return bulkStatusJson(bulk);
17
+ },
18
+ });
19
+ // The bulk request as the API shows it, plus why processing stopped on uncaptured behavior.
20
+ defineControl({
21
+ method: "GET",
22
+ path: "/control/bulk/:id",
23
+ handler: ({ store, params }) => {
24
+ const bulk = store.state.bulkRequests.get(params.id ?? "");
25
+ if (bulk === undefined)
26
+ throw new ControlError(`no bulk request '${params.id}'`);
27
+ return { ...bulkStatusJson(bulk), Unsupported: bulk.unsupported };
28
+ },
29
+ });
@@ -0,0 +1 @@
1
+ export {};