@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,265 @@
1
+ import { Unsupported } from "../http/respond.js";
2
+ // Postmark renders the unsubscribe placeholder at send time on broadcast streams (docs/03 §5.4);
3
+ // the renderer keeps it as written.
4
+ const UNSUBSCRIBE = /^pm:unsubscribe$/;
5
+ const CONTENT = /^@content$/;
6
+ // Key characters: letters, digits, `_`, `-` (INFERRED; the article shows only word keys).
7
+ const PATH = /^((?:\.\.\/)*)([\p{L}\p{N}_-]+(?:\.[\p{L}\p{N}_-]+)*)$/u;
8
+ // Error texts are INFERRED: only "The syntax for this template is invalid." is documented
9
+ // (refs/api_templates-api.md:862).
10
+ const messages = {
11
+ syntax: "The syntax for this template is invalid.",
12
+ path: (raw) => `The path '${raw}' is not valid. Please see documentation for examples of valid paths.`,
13
+ unclosed: (raw) => `A block for the path '${raw}' was opened but not closed. Close it with '{{/${raw}}}'.`,
14
+ unopened: (raw) => `A closing tag '{{/${raw}}}' was found without a matching opening tag.`,
15
+ };
16
+ function parsePath(raw) {
17
+ if (raw === ".")
18
+ return { raw, up: 0, keys: [] };
19
+ const match = PATH.exec(raw);
20
+ if (!match)
21
+ return undefined;
22
+ return { raw, up: (match[1] ?? "").length / 3, keys: (match[2] ?? "").split(".") };
23
+ }
24
+ function positionOf(source, index) {
25
+ const before = source.slice(0, index);
26
+ const lineStart = before.lastIndexOf("\n") + 1;
27
+ return { line: before.split("\n").length, column: index - lineStart + 1 };
28
+ }
29
+ /** Parses Mustachio source. Every syntax error is collected with its position. */
30
+ export function parseTemplate(source) {
31
+ const errors = [];
32
+ const root = [];
33
+ const stack = [];
34
+ let contentPlaceholders = 0;
35
+ const target = () => stack.at(-1)?.node.children ?? root;
36
+ const fail = (message, at) => errors.push({ Message: message, Line: at.line, CharacterPosition: at.column });
37
+ let index = 0;
38
+ while (index < source.length) {
39
+ const open = source.indexOf("{{", index);
40
+ if (open === -1) {
41
+ target().push({ kind: "text", text: source.slice(index) });
42
+ break;
43
+ }
44
+ if (open > index)
45
+ target().push({ kind: "text", text: source.slice(index, open) });
46
+ const at = positionOf(source, open);
47
+ const triple = source.startsWith("{{{", open);
48
+ const closer = triple ? "}}}" : "}}";
49
+ const end = source.indexOf(closer, open + closer.length);
50
+ // An unterminated tag is an error, not text (INFERRED).
51
+ if (end === -1) {
52
+ fail(messages.syntax, at);
53
+ break;
54
+ }
55
+ const tag = source.slice(open, end + closer.length);
56
+ const inner = source.slice(open + closer.length, end).trim();
57
+ index = end + closer.length;
58
+ if (UNSUBSCRIBE.test(inner)) {
59
+ target().push({ kind: "text", text: tag });
60
+ continue;
61
+ }
62
+ if (triple || inner.startsWith("&")) {
63
+ const raw = triple ? inner : inner.slice(1).trim();
64
+ if (triple && CONTENT.test(raw)) {
65
+ contentPlaceholders += 1;
66
+ target().push({ kind: "content" });
67
+ continue;
68
+ }
69
+ const path = parsePath(raw);
70
+ if (path)
71
+ target().push({ kind: "value", path, escape: false });
72
+ else
73
+ fail(messages.path(raw), at);
74
+ continue;
75
+ }
76
+ const sigil = inner.charAt(0);
77
+ if (sigil === "!" || sigil === ">" || sigil === "=") {
78
+ // Comments, partials and delimiter changes are not in the article.
79
+ throw new Unsupported(`Mustachio tag '${tag}' is not documented (docs/06 §3.5)`);
80
+ }
81
+ if (sigil === "#" || sigil === "^") {
82
+ const body = inner.slice(1).trim();
83
+ const each = sigil === "#" ? /^each\s+(.+)$/.exec(body) : null;
84
+ const raw = each ? (each[1] ?? "").trim() : body;
85
+ // A bad path still opens a block, so its close tag adds no second error.
86
+ const path = parsePath(raw) ?? { raw, up: 0, keys: [] };
87
+ if (path.keys.length === 0 && raw !== ".")
88
+ fail(messages.path(raw), at);
89
+ const node = each
90
+ ? { kind: "each", path, children: [] }
91
+ : { kind: "section", path, inverted: sigil === "^", children: [] };
92
+ target().push(node);
93
+ stack.push({ node, closeName: each ? "each" : raw, at });
94
+ continue;
95
+ }
96
+ if (sigil === "/") {
97
+ const name = inner.slice(1).trim();
98
+ if (stack.at(-1)?.closeName === name)
99
+ stack.pop();
100
+ else
101
+ fail(messages.unopened(name), at);
102
+ continue;
103
+ }
104
+ const path = parsePath(inner);
105
+ if (path)
106
+ target().push({ kind: "value", path, escape: true });
107
+ else
108
+ fail(messages.path(inner), at);
109
+ }
110
+ for (const open of stack) {
111
+ fail(messages.unclosed(open.node.path.raw), open.at);
112
+ }
113
+ return errors.length > 0
114
+ ? { ok: false, errors }
115
+ : { ok: true, template: { nodes: root, contentPlaceholders } };
116
+ }
117
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
118
+ function resolve(path, scopes) {
119
+ // Each `../` leaves one scope; `{{#each}}` opens two: the collection, then the item (article :98-107).
120
+ let value = scopes[scopes.length - 1 - path.up];
121
+ for (const key of path.keys) {
122
+ value = isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined;
123
+ }
124
+ return value;
125
+ }
126
+ // Missing, null, false and empty values skip a section (article :111, :264). Zero skips it too
127
+ // (INFERRED, as in Mustache).
128
+ const truthy = (value) => value !== undefined &&
129
+ value !== null &&
130
+ value !== false &&
131
+ value !== 0 &&
132
+ value !== "" &&
133
+ !(Array.isArray(value) && value.length === 0);
134
+ // `{{x}}` HTML-encodes in every part (article :300). The encoded set is INFERRED.
135
+ const ENTITIES = {
136
+ "&": "&amp;",
137
+ "<": "&lt;",
138
+ ">": "&gt;",
139
+ '"': "&quot;",
140
+ "'": "&#39;",
141
+ };
142
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ENTITIES[c] ?? c);
143
+ function scalarText(value, raw) {
144
+ if (value === undefined || value === null)
145
+ return "";
146
+ if (typeof value === "string")
147
+ return value;
148
+ if (typeof value === "number")
149
+ return String(value);
150
+ // .NET prints booleans as True/False (INFERRED).
151
+ if (typeof value === "boolean")
152
+ return value ? "True" : "False";
153
+ throw new Unsupported(`rendering the object or array at '{{${raw}}}' is not captured`);
154
+ }
155
+ /**
156
+ * Renders a parsed template with a model. `content` fills `{{{ @content }}}` in a layout.
157
+ * A missing value renders as "" (docs/06 §3.5, INFERRED).
158
+ */
159
+ export function renderTemplate(template, model, content = "") {
160
+ const renderNode = (node, scopes) => {
161
+ switch (node.kind) {
162
+ case "text":
163
+ return node.text;
164
+ case "content":
165
+ return content;
166
+ case "value": {
167
+ const text = scalarText(resolve(node.path, scopes), node.path.raw);
168
+ return node.escape ? escapeHtml(text) : text;
169
+ }
170
+ case "section": {
171
+ const value = resolve(node.path, scopes);
172
+ if (node.inverted)
173
+ return truthy(value) ? "" : render(node.children, scopes);
174
+ if (!truthy(value))
175
+ return "";
176
+ // Mustache iterates here; the article iterates only with `#each`.
177
+ if (Array.isArray(value)) {
178
+ throw new Unsupported(`'{{#${node.path.raw}}}' on a list is not captured; use #each`);
179
+ }
180
+ return render(node.children, [...scopes, value]);
181
+ }
182
+ case "each": {
183
+ const value = resolve(node.path, scopes);
184
+ if (!truthy(value))
185
+ return "";
186
+ if (!Array.isArray(value)) {
187
+ throw new Unsupported(`'{{#each ${node.path.raw}}}' on a non-list is not captured`);
188
+ }
189
+ return value.map((item) => render(node.children, [...scopes, value, item])).join("");
190
+ }
191
+ }
192
+ };
193
+ const render = (nodes, scopes) => nodes.map((node) => renderNode(node, scopes)).join("");
194
+ return render(template.nodes, [model]);
195
+ }
196
+ const newInferred = () => ({ usage: "value", children: new Map() });
197
+ /** Collects every key a template reads into `into` (root of the suggested model). */
198
+ function inferInto(template, into) {
199
+ // A scope entry is null where no key can be added: the list level of `#each`.
200
+ const walk = (nodes, scopes) => {
201
+ for (const node of nodes) {
202
+ if (node.kind === "text" || node.kind === "content")
203
+ continue;
204
+ let target = scopes[scopes.length - 1 - node.path.up] ?? null;
205
+ node.path.keys.forEach((key) => {
206
+ if (target === null)
207
+ return;
208
+ if (target.usage === "value")
209
+ target.usage = "object";
210
+ const child = target.children.get(key) ?? newInferred();
211
+ target.children.set(key, child);
212
+ target = child;
213
+ });
214
+ if (node.kind === "value")
215
+ continue;
216
+ if (target === null || node.path.keys.length === 0) {
217
+ const inner = node.kind === "each" ? [null, target] : [target];
218
+ walk(node.children, [...scopes, ...inner]);
219
+ continue;
220
+ }
221
+ if (node.kind === "each") {
222
+ target.usage = "collection";
223
+ walk(node.children, [...scopes, null, target]);
224
+ }
225
+ else {
226
+ if (target.usage === "value")
227
+ target.usage = "object";
228
+ walk(node.children, [...scopes, target]);
229
+ }
230
+ }
231
+ };
232
+ walk(template.nodes, [into]);
233
+ }
234
+ function modelOf(key, node) {
235
+ const inner = () => node.children.size === 0
236
+ ? `${key}_Value`
237
+ : Object.fromEntries([...node.children].map(([k, child]) => [k, modelOf(k, child)]));
238
+ // A list gets three items: sdk/postmark-dotnet/src/Postmark.Tests/ClientTemplateTests.cs:203-205.
239
+ if (node.usage === "collection")
240
+ return [inner(), inner(), inner()];
241
+ return inner();
242
+ }
243
+ /**
244
+ * `SuggestedTemplateModel`: a placeholder `<key>_Value` for every key the templates read
245
+ * (refs/api_templates-api.md:844, :873-886).
246
+ */
247
+ export function suggestModel(templates) {
248
+ const root = newInferred();
249
+ for (const template of templates)
250
+ inferInto(template, root);
251
+ return Object.fromEntries([...root.children].map(([k, child]) => [k, modelOf(k, child)]));
252
+ }
253
+ /** Deep merge; `override` values win and its keys come first (refs/api_templates-api.md:873-886). */
254
+ export function mergeModels(base, override) {
255
+ const out = {};
256
+ for (const [key, value] of Object.entries(override)) {
257
+ const under = base[key];
258
+ out[key] = isObject(value) && isObject(under) ? mergeModels(under, value) : value;
259
+ }
260
+ for (const [key, value] of Object.entries(base)) {
261
+ if (!(key in out))
262
+ out[key] = value;
263
+ }
264
+ return out;
265
+ }
@@ -0,0 +1,12 @@
1
+ import { EventBus } from "./events.ts";
2
+ import { type Plugin } from "./plugins.ts";
3
+ import { Clock } from "./state/clock.ts";
4
+ import { Store } from "./state/store.ts";
5
+ /** Everything a request handler, the pipeline, a seed, a plugin or a listener shares. */
6
+ export interface Runtime {
7
+ store: Store;
8
+ events: EventBus;
9
+ clock: Clock;
10
+ }
11
+ /** A runtime with every plugin installed. Tests pass a `Clock` with a frozen real time. */
12
+ export declare function createRuntime(plugins?: readonly Plugin[], clock?: Clock): Runtime;
@@ -0,0 +1,11 @@
1
+ import { EventBus } from "./events.js";
2
+ import { PLUGINS } from "./plugins.js";
3
+ import { Clock } from "./state/clock.js";
4
+ import { Store } from "./state/store.js";
5
+ /** A runtime with every plugin installed. Tests pass a `Clock` with a frozen real time. */
6
+ export function createRuntime(plugins = PLUGINS, clock = new Clock()) {
7
+ const runtime = { store: new Store(), events: new EventBus(), clock };
8
+ for (const plugin of plugins)
9
+ plugin.install?.(runtime);
10
+ return runtime;
11
+ }
@@ -0,0 +1,25 @@
1
+ import { type Plugin } from "./plugins.ts";
2
+ import { type Runtime } from "./runtime.ts";
3
+ export interface PostmockConfig {
4
+ host: string;
5
+ /** Plain-http REST port; 0 picks a free port. */
6
+ apiPort: number;
7
+ controlPort: number;
8
+ /** REST over TLS for DNS routing (docs/01 §3.3 option B): PEM key and cert for the Postmark host names. */
9
+ https?: {
10
+ port: number;
11
+ key: string;
12
+ cert: string;
13
+ };
14
+ seed: string;
15
+ /** Defaults to every plugin in `src/plugins/`. */
16
+ plugins?: readonly Plugin[];
17
+ }
18
+ export interface RunningPostmock {
19
+ runtime: Runtime;
20
+ /** URL per listener: `api`, `control`, `https` when configured, and one per plugin listener. */
21
+ listeners: Record<string, string>;
22
+ close(): Promise<void>;
23
+ }
24
+ /** Seeds the state, then starts the REST, control and plugin listeners. */
25
+ export declare function startPostmock(config: PostmockConfig): Promise<RunningPostmock>;
@@ -0,0 +1,49 @@
1
+ import { createServer as createHttpsServer } from "node:https";
2
+ import { serve } from "@hono/node-server";
3
+ import { createControlApp } from "./control/app.js";
4
+ import { applySeed } from "./control/seed.js";
5
+ import { createApiApp } from "./http/app.js";
6
+ import { PLUGINS } from "./plugins.js";
7
+ import { createRuntime } from "./runtime.js";
8
+ function listen(name, fetch, host, port, tls) {
9
+ return new Promise((resolve) => {
10
+ const onListen = (info) => resolve({
11
+ name,
12
+ url: `${tls ? "https" : "http"}://${host}:${info.port}`,
13
+ close: () => new Promise((done, fail) => server.close((e) => (e ? fail(e) : done()))),
14
+ });
15
+ const server = tls
16
+ ? serve({ fetch, hostname: host, port, createServer: createHttpsServer, serverOptions: tls }, onListen)
17
+ : serve({ fetch, hostname: host, port }, onListen);
18
+ });
19
+ }
20
+ /** Seeds the state, then starts the REST, control and plugin listeners. */
21
+ export async function startPostmock(config) {
22
+ const runtime = createRuntime(config.plugins ?? PLUGINS);
23
+ await applySeed(runtime, config.seed);
24
+ const api = createApiApp(runtime);
25
+ const started = [await listen("api", api.fetch, config.host, config.apiPort)];
26
+ if (config.https) {
27
+ const { port, key, cert } = config.https;
28
+ started.push(await listen("https", api.fetch, config.host, port, { key, cert }));
29
+ }
30
+ for (const plugin of config.plugins ?? PLUGINS) {
31
+ if (plugin.start)
32
+ started.push(await plugin.start(runtime, config.host));
33
+ }
34
+ // Control binds last: once it answers, every listener is up (the Compose health check).
35
+ started.push(await listen("control", createControlApp(runtime, config.seed).fetch, config.host, config.controlPort));
36
+ const names = started.map((l) => l.name);
37
+ const duplicate = names.find((n, i) => names.indexOf(n) !== i);
38
+ if (duplicate !== undefined)
39
+ throw new Error(`two listeners named ${duplicate}`);
40
+ return {
41
+ runtime,
42
+ listeners: Object.fromEntries(started.map((l) => [l.name, l.url])),
43
+ close: async () => {
44
+ await Promise.all(started.map((l) => l.close()));
45
+ await runtime.clock.idle();
46
+ runtime.clock.reset();
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,35 @@
1
+ import type { State } from "../state/store.ts";
2
+ import type { Server } from "../state/types.ts";
3
+ /**
4
+ * Who an SMTP session authenticated as. Kept as the credential, not the server object, so a
5
+ * revoked token or a `POST /control/reset` takes effect on an open pooled connection.
6
+ */
7
+ export type SmtpIdentity = {
8
+ kind: "serverToken";
9
+ token: string;
10
+ } | {
11
+ kind: "smtpToken";
12
+ accessKey: string;
13
+ };
14
+ /** The server a message goes to, and the stream an SMTP token pins. */
15
+ export interface SmtpSender {
16
+ server: Server;
17
+ /** Set for an SMTP token (one token per stream, docs/07 §1.2); null for a server token. */
18
+ tokenStream: string | null;
19
+ }
20
+ /** What `smtp-server` hands to `onAuth`: PLAIN and LOGIN carry a password, CRAM-MD5 a verifier. */
21
+ export type AuthAttempt = {
22
+ username: string;
23
+ password: string;
24
+ } | {
25
+ username: string;
26
+ validatePassword(password: string): boolean;
27
+ };
28
+ /**
29
+ * docs/07 §1.2: a server API token is both username and password; an SMTP token is Access Key and
30
+ * Secret Key. An account token, an unknown or revoked token, and a server with
31
+ * `SmtpApiActivated: false` fail AUTH. DIGEST-MD5 never reaches here: it is not offered (504).
32
+ */
33
+ export declare function authenticate(state: State, attempt: AuthAttempt): SmtpIdentity;
34
+ /** The current sender for an identity; throws 535 once the token or its SMTP access is gone. */
35
+ export declare function resolveSender(state: State, identity: SmtpIdentity): SmtpSender;
@@ -0,0 +1,66 @@
1
+ import { Unsupported } from "../http/respond.js";
2
+ import { TEST_TOKEN } from "../state/servers.js";
3
+ import { SmtpReply } from "./reply.js";
4
+ // Reply code and text for bad credentials are not captured (docs/07 §1.4, Q3): INFERRED 535.
5
+ const invalid = () => new SmtpReply(535, "Authentication credentials invalid");
6
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
7
+ function passwordMatches(attempt, expected, ignoreCase) {
8
+ if ("validatePassword" in attempt)
9
+ return attempt.validatePassword(expected);
10
+ return ignoreCase ? same(attempt.password, expected) : attempt.password === expected;
11
+ }
12
+ /**
13
+ * docs/07 §1.2: a server API token is both username and password; an SMTP token is Access Key and
14
+ * Secret Key. An account token, an unknown or revoked token, and a server with
15
+ * `SmtpApiActivated: false` fail AUTH. DIGEST-MD5 never reaches here: it is not offered (504).
16
+ */
17
+ export function authenticate(state, attempt) {
18
+ const { username } = attempt;
19
+ // docs/07 §2.2 Q4: whether POSTMARK_API_TEST works over SMTP is not captured.
20
+ if (same(username, TEST_TOKEN)) {
21
+ throw new Unsupported(`${TEST_TOKEN} over SMTP is not captured (docs/07 Q4)`);
22
+ }
23
+ const smtpToken = state.smtpTokens.get(username);
24
+ if (smtpToken !== undefined) {
25
+ if (!passwordMatches(attempt, smtpToken.secretKey, false))
26
+ throw invalid();
27
+ const identity = { kind: "smtpToken", accessKey: username };
28
+ resolveSender(state, identity);
29
+ return identity;
30
+ }
31
+ const held = serverByToken(state, username);
32
+ // Server tokens compare without case, as on REST (docs/02 §3.1). CRAM-MD5 hashes the stored
33
+ // spelling, so a client there must send the token as stored.
34
+ if (held === undefined || !passwordMatches(attempt, held.token, true))
35
+ throw invalid();
36
+ const identity = { kind: "serverToken", token: held.token };
37
+ resolveSender(state, identity);
38
+ return identity;
39
+ }
40
+ function serverByToken(state, token) {
41
+ for (const server of state.servers.values()) {
42
+ const stored = server.ApiTokens.find((t) => same(t, token));
43
+ if (stored !== undefined)
44
+ return { server, token: stored };
45
+ }
46
+ return undefined;
47
+ }
48
+ /** The current sender for an identity; throws 535 once the token or its SMTP access is gone. */
49
+ export function resolveSender(state, identity) {
50
+ let sender;
51
+ if (identity.kind === "serverToken") {
52
+ const held = serverByToken(state, identity.token);
53
+ if (held !== undefined)
54
+ sender = { server: held.server, tokenStream: null };
55
+ }
56
+ else {
57
+ const token = state.smtpTokens.get(identity.accessKey);
58
+ const server = token && state.servers.get(token.serverId);
59
+ if (token !== undefined && server !== undefined) {
60
+ sender = { server, tokenStream: token.messageStream };
61
+ }
62
+ }
63
+ if (sender === undefined || !sender.server.SmtpApiActivated)
64
+ throw invalid();
65
+ return sender;
66
+ }
@@ -0,0 +1,16 @@
1
+ import type { StartedListener } from "../plugins.ts";
2
+ import type { Runtime } from "../runtime.ts";
3
+ /** 10 MB total, measured after base64 (docs/07 §3). Advertised as EHLO `SIZE`; same on SMTP is INFERRED. */
4
+ export declare const MAX_MESSAGE_BYTES: number;
5
+ export interface SmtpOptions {
6
+ host: string;
7
+ /** Every port serves the same listener; `0` picks a free port. */
8
+ ports: number[];
9
+ /** PEM key and cert. With them the listener offers STARTTLS; it never requires it (docs/07 §1.2). */
10
+ tls: {
11
+ key: string;
12
+ cert: string;
13
+ } | null;
14
+ }
15
+ /** Starts the Postmark SMTP endpoint (docs/07 §1). The URL names the first port. */
16
+ export declare function startSmtp(runtime: Runtime, options: SmtpOptions): Promise<StartedListener>;
@@ -0,0 +1,121 @@
1
+ import { SMTPServer } from "smtp-server";
2
+ import { Unsupported } from "../http/respond.js";
3
+ import { authenticate, resolveSender } from "./auth.js";
4
+ import { receive } from "./receive.js";
5
+ import { SmtpReply, takeFault, UNSUPPORTED_CODE } from "./reply.js";
6
+ /** 10 MB total, measured after base64 (docs/07 §3). Advertised as EHLO `SIZE`; same on SMTP is INFERRED. */
7
+ export const MAX_MESSAGE_BYTES = 10 * 1024 * 1024;
8
+ // setTimeout's ceiling. Postmark's idle timeout is not captured (docs/07 Q11), so postmock never
9
+ // closes an idle connection; smtp-server reads 0 as its 60 s default.
10
+ const NEVER_MS = 2 ** 31 - 1;
11
+ /** An `smtp-server` callback error: an `SmtpReply`, a 502 for `Unsupported`, else a crash (554). */
12
+ function toReply(error) {
13
+ if (error instanceof SmtpReply)
14
+ return error;
15
+ if (error instanceof Unsupported)
16
+ return new SmtpReply(UNSUPPORTED_CODE, `postmock: ${error.message}`);
17
+ console.error(error);
18
+ return new SmtpReply(554, `postmock crashed: ${error.message}`);
19
+ }
20
+ const settle = (work, callback) => {
21
+ Promise.resolve()
22
+ .then(work)
23
+ .then((value) => callback(null, value), (error) => callback(toReply(error)));
24
+ };
25
+ function createServer(runtime, options) {
26
+ const identities = new WeakMap();
27
+ const identity = (session) => {
28
+ const found = identities.get(session);
29
+ if (found === undefined)
30
+ throw new Error("smtp-server let an unauthenticated session through");
31
+ return found;
32
+ };
33
+ const server = new SMTPServer({
34
+ // Greeting name and EHLO order are not captured (docs/07 Q2).
35
+ name: "postmock",
36
+ banner: "postmock",
37
+ // docs/07 Mock must: AUTH PLAIN LOGIN CRAM-MD5, before or after STARTTLS; DIGEST-MD5 gets 504.
38
+ authMethods: ["PLAIN", "LOGIN", "CRAM-MD5"],
39
+ allowInsecureAuth: true,
40
+ authOptional: false,
41
+ size: MAX_MESSAGE_BYTES,
42
+ disableReverseLookup: true,
43
+ socketTimeout: NEVER_MS,
44
+ closeTimeout: 1,
45
+ // Without 8BITMIME a client encodes 8-bit bodies, so the stored source keeps every byte as
46
+ // UTF-8 text. Postmark's EHLO list is not captured (docs/07 Q2).
47
+ hide8BITMIME: true,
48
+ ...(options.tls === null ? { hideSTARTTLS: true } : options.tls),
49
+ onConnect: (_session, callback) => settle(() => takeFault(runtime.store, "connect"), callback),
50
+ onAuth: (auth, session, callback) => settle(() => {
51
+ // @types/smtp-server omits CRAM-MD5, which passes `validatePassword` instead of a password.
52
+ const cram = auth;
53
+ const username = auth.username ?? "";
54
+ const attempt = cram.validatePassword === undefined
55
+ ? { username, password: auth.password ?? "" }
56
+ : { username, validatePassword: cram.validatePassword };
57
+ identities.set(session, authenticate(runtime.store.state, attempt));
58
+ return { user: attempt.username };
59
+ }, callback),
60
+ onMailFrom: (_address, session, callback) => settle(() => {
61
+ resolveSender(runtime.store.state, identity(session));
62
+ takeFault(runtime.store, "mail");
63
+ }, callback),
64
+ // docs/07 §1.4: every recipient is accepted; problems become SMTPApiError bounces after DATA.
65
+ onRcptTo: (_address, _session, callback) => settle(() => takeFault(runtime.store, "rcpt"), callback),
66
+ onData: (stream, session, callback) => settle(async () => {
67
+ const raw = await collect(stream);
68
+ // Oversize at DATA: SMTP 552 is INFERRED (docs/07 Q3).
69
+ if (stream.sizeExceeded) {
70
+ throw new SmtpReply(552, `Message exceeds fixed maximum message size ${MAX_MESSAGE_BYTES}`);
71
+ }
72
+ const sender = resolveSender(runtime.store.state, identity(session));
73
+ takeFault(runtime.store, "data");
74
+ const rcptTo = session.envelope.rcptTo.map((r) => r.address);
75
+ const messageId = await receive(runtime, sender, raw, rcptTo);
76
+ // The 250 text is not captured (docs/07 Q1); naming the MessageID is INFERRED.
77
+ return `Ok: queued as ${messageId}`;
78
+ }, callback),
79
+ });
80
+ // A client that drops the socket mid-transaction is normal pool behavior (docs/07 §1.6).
81
+ server.on("error", (error) => {
82
+ if (error.code !== "ECONNRESET" && error.code !== "EPIPE")
83
+ console.error(error);
84
+ });
85
+ return server;
86
+ }
87
+ const collect = async (stream) => {
88
+ const chunks = [];
89
+ for await (const chunk of stream)
90
+ chunks.push(chunk);
91
+ return Buffer.concat(chunks);
92
+ };
93
+ function listen(server, host, port) {
94
+ return new Promise((resolve, reject) => {
95
+ server.server.once("error", reject);
96
+ server.listen(port, host, () => {
97
+ server.server.off("error", reject);
98
+ resolve(server.server.address().port);
99
+ });
100
+ });
101
+ }
102
+ /** Starts the Postmark SMTP endpoint (docs/07 §1). The URL names the first port. */
103
+ export async function startSmtp(runtime, options) {
104
+ const servers = [];
105
+ const ports = [];
106
+ const close = async () => {
107
+ await Promise.all(servers.map((s) => new Promise((done) => s.close(() => done()))));
108
+ };
109
+ try {
110
+ for (const port of options.ports) {
111
+ const server = createServer(runtime, options);
112
+ servers.push(server);
113
+ ports.push(await listen(server, options.host, port));
114
+ }
115
+ }
116
+ catch (error) {
117
+ await close();
118
+ throw error;
119
+ }
120
+ return { name: "smtp", url: `smtp://${options.host}:${ports[0]}`, close };
121
+ }
@@ -0,0 +1,36 @@
1
+ import type { ParsedMail } from "mailparser";
2
+ import type { OutboundDraft } from "../pipeline/submit.ts";
3
+ import type { SmtpSender } from "./auth.ts";
4
+ /** mailparser options: keep the bodies as sent; derive nothing. */
5
+ export declare const PARSE_OPTIONS: {
6
+ readonly skipHtmlToText: true;
7
+ readonly skipTextToHtml: true;
8
+ readonly skipTextLinks: true;
9
+ readonly skipImageLinks: true;
10
+ };
11
+ export interface SmtpDraft {
12
+ draft: OutboundDraft & {
13
+ From: string | undefined;
14
+ Tag: string | undefined;
15
+ Metadata: Record<string, string>;
16
+ };
17
+ /** The stream the message goes to, for an `SMTPApiError` bounce when the draft is rejected. */
18
+ stream: string;
19
+ keepId: boolean;
20
+ }
21
+ /**
22
+ * Maps one parsed SMTP transaction to a raw draft. `submitOutbound` checks every value
23
+ * (docs/11 §2). Recipients come from the envelope (docs/07 Mock must). The `To` and `Cc` headers
24
+ * only sort them and name them; an `RCPT TO` address neither header names is a Bcc, since clients
25
+ * strip the `Bcc` header (docs/07 §1.1). A header address outside the envelope is dropped (INFERRED).
26
+ */
27
+ export declare function draftFromMime(mail: ParsedMail, rcptTo: string[], sender: SmtpSender): SmtpDraft;
28
+ /**
29
+ * The copy Postmark delivers and serves as the raw source: incoming `X-PM-*` headers removed
30
+ * (docs/07 §1.3), `X-PM-Tag` added as in the dump example (refs/api_messages-api.md:276), and
31
+ * every `Message-ID` replaced unless `X-PM-KeepID: true`
32
+ * (refs/user-guide_send-email-with-smtp.md:101). The replacement `<uuid@mtasv.net>` is INFERRED.
33
+ * The dump example's `X-PM-Message-Id` is left out: the pipeline mints the MessageID after this
34
+ * source is built. The body bytes stay unchanged.
35
+ */
36
+ export declare function deliveredSource(raw: string, tag: string | undefined, keepId: boolean): string;