@benclmnt/postmock 0.0.4 → 0.0.5

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.
@@ -39,7 +39,24 @@ defineControl({
39
39
  function clockState(clock) {
40
40
  return { now: formatTimestamp(clock.now(), "utc"), pending: clock.pending };
41
41
  }
42
- const requestMatch = z.object({ method: z.string(), path: z.string().startsWith("/") });
42
+ const requestMatch = z.object({
43
+ method: z.string(),
44
+ path: z.string().startsWith("/"),
45
+ // The domain part of a parsed address: DNS labels with a letter, so no IP, literal or trailing dot.
46
+ recipientDomain: z
47
+ .string()
48
+ .regex(/^(?=.*[a-z])[a-z0-9-]+(\.[a-z0-9-]+)*$/i)
49
+ .transform((d) => d.toLowerCase())
50
+ .optional(),
51
+ });
52
+ function requestRule(match, times) {
53
+ return {
54
+ method: match.method,
55
+ path: match.path,
56
+ recipientDomain: match.recipientDomain,
57
+ remaining: times,
58
+ };
59
+ }
43
60
  defineControl({
44
61
  method: "POST",
45
62
  path: "/control/latency",
@@ -50,9 +67,7 @@ defineControl({
50
67
  ms: z.int().positive(),
51
68
  }), ctx.body);
52
69
  ctx.store.state.latencies.push({
53
- method: match.method,
54
- path: match.path,
55
- remaining: times,
70
+ ...requestRule(match, times),
56
71
  ms,
57
72
  });
58
73
  return { latencies: ctx.store.state.latencies.length };
@@ -75,9 +90,7 @@ defineControl({
75
90
  handler: (ctx) => {
76
91
  const { match, times, reply } = controlInput(faultSchema, ctx.body);
77
92
  ctx.store.state.faults.push({
78
- method: match.method,
79
- path: match.path,
80
- remaining: times,
93
+ ...requestRule(match, times),
81
94
  reply: reply === "timeout" || reply === "reset"
82
95
  ? reply
83
96
  : faultError(controlInput(errorReply, reply)),
@@ -12,7 +12,8 @@ export function createApiApp(runtime) {
12
12
  app.all("*", async (c) => {
13
13
  const request = c.req.raw;
14
14
  const url = new URL(request.url);
15
- const fault = await applyFault(runtime, request.method, url.pathname, c.env);
15
+ const bytes = await request.arrayBuffer();
16
+ const fault = await applyFault(runtime, { method: request.method, pathname: url.pathname, body: bytes }, c.env);
16
17
  if (fault)
17
18
  return fault;
18
19
  const matched = apiRoutes.match(request.method, url.pathname);
@@ -22,7 +23,7 @@ export function createApiApp(runtime) {
22
23
  const { route, params } = matched;
23
24
  try {
24
25
  const auth = authenticate(route.auth, request.headers, runtime.store.state, `${route.method} ${route.path}`, runtime.clock.now());
25
- const body = decodeJsonBody(await request.arrayBuffer());
26
+ const body = decodeJsonBody(bytes);
26
27
  const result = await route.handler({
27
28
  ...runtime,
28
29
  method: route.method,
@@ -1,8 +1,14 @@
1
1
  import type { HttpBindings } from "@hono/node-server";
2
2
  import type { Runtime } from "../runtime.ts";
3
+ /** What a rule can match on: the route, and the send body for a recipient domain. */
4
+ export interface RuleRequest {
5
+ method: string;
6
+ pathname: string;
7
+ body: ArrayBuffer;
8
+ }
3
9
  /**
4
10
  * Holds the request for the first matching latency rule, then applies the first matching fault
5
11
  * (docs/09 §5 `POST /control/latency`, `POST /control/faults`). Each match uses one of the rule's
6
12
  * `times`. `timeout` holds the request open until the client gives up; `reset` destroys the socket.
7
13
  */
8
- export declare function applyFault({ store, clock }: Runtime, method: string, pathname: string, env: HttpBindings): Promise<Response | undefined>;
14
+ export declare function applyFault({ store, clock }: Runtime, request: RuleRequest, env: HttpBindings): Promise<Response | undefined>;
@@ -1,4 +1,5 @@
1
1
  import { ApiError } from "../errors.js";
2
+ import { parseAddressList } from "../pipeline/addresses.js";
2
3
  import { errorResponse } from "./respond.js";
3
4
  import { RouteTable } from "./routes.js";
4
5
  /**
@@ -6,25 +7,66 @@ import { RouteTable } from "./routes.js";
6
7
  * (docs/09 §5 `POST /control/latency`, `POST /control/faults`). Each match uses one of the rule's
7
8
  * `times`. `timeout` holds the request open until the client gives up; `reset` destroys the socket.
8
9
  */
9
- export async function applyFault({ store, clock }, method, pathname, env) {
10
- const latency = take(store.state.latencies, method, pathname);
10
+ export async function applyFault({ store, clock }, request, env) {
11
+ let domains;
12
+ const mailsDomain = (domain) => {
13
+ domains ??= recipientDomains(request.body);
14
+ return domains.has(domain);
15
+ };
16
+ const latency = take(store.state.latencies, request, mailsDomain);
11
17
  if (latency)
12
18
  await clock.hold(latency.ms);
13
- const fault = take(store.state.faults, method, pathname);
19
+ const fault = take(store.state.faults, request, mailsDomain);
14
20
  return fault && reply(fault, env);
15
21
  }
16
- function take(rules, method, pathname) {
22
+ function take(rules, request, mailsDomain) {
17
23
  const rule = rules.find((r) => {
18
24
  if (r.remaining <= 0)
19
25
  return false;
20
26
  const table = new RouteTable();
21
27
  table.add({ method: r.method.toUpperCase(), path: r.path });
22
- return table.match(method, pathname) !== undefined;
28
+ if (table.match(request.method, request.pathname) === undefined)
29
+ return false;
30
+ return r.recipientDomain === undefined || mailsDomain(r.recipientDomain);
23
31
  });
24
32
  if (rule)
25
33
  rule.remaining -= 1;
26
34
  return rule;
27
35
  }
36
+ /**
37
+ * The lower-case domains of every To, Cc and Bcc address in a send body: one message, a batch
38
+ * array, or a `Messages` list. Keys match without case (docs/08 R8). A body that is not JSON, or a
39
+ * field with a malformed entry, gives none; its route answers the Postmark error.
40
+ */
41
+ function recipientDomains(bytes) {
42
+ let body;
43
+ try {
44
+ body = JSON.parse(new TextDecoder("utf-8").decode(bytes));
45
+ }
46
+ catch {
47
+ return new Set();
48
+ }
49
+ const list = field(body, "Messages");
50
+ const messages = Array.isArray(body) ? body : Array.isArray(list) ? list : [body];
51
+ const domains = new Set();
52
+ for (const message of messages) {
53
+ for (const name of ["To", "Cc", "Bcc"]) {
54
+ const raw = field(message, name);
55
+ if (typeof raw !== "string")
56
+ continue;
57
+ for (const { Email } of parseAddressList(raw) ?? []) {
58
+ domains.add(Email.slice(Email.lastIndexOf("@") + 1).toLowerCase());
59
+ }
60
+ }
61
+ }
62
+ return domains;
63
+ }
64
+ function field(value, name) {
65
+ if (typeof value !== "object" || value === null || Array.isArray(value))
66
+ return undefined;
67
+ const key = Object.keys(value).find((k) => k.toLowerCase() === name.toLowerCase());
68
+ return key === undefined ? undefined : value[key];
69
+ }
28
70
  async function reply(fault, env) {
29
71
  const { reply } = fault;
30
72
  if (reply === "reset") {
@@ -526,6 +526,8 @@ export interface RequestRule {
526
526
  method: string;
527
527
  /** `/email`, `/templates/:id` */
528
528
  path: string;
529
+ /** Lower case. Only a request with a To, Cc or Bcc address at this domain matches. */
530
+ recipientDomain: string | undefined;
529
531
  remaining: number;
530
532
  }
531
533
  export interface Fault extends RequestRule {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benclmnt/postmock",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A mock Postmark server for testing code that sends email through Postmark",
5
5
  "license": "MIT",
6
6
  "repository": {