@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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/seeds/conformance/00-core.d.ts +4 -0
- package/dist/seeds/conformance/00-core.js +12 -0
- package/dist/seeds/conformance/20-bounces.d.ts +3 -0
- package/dist/seeds/conformance/20-bounces.js +108 -0
- package/dist/seeds/conformance/40-messages.d.ts +3 -0
- package/dist/seeds/conformance/40-messages.js +144 -0
- package/dist/seeds/conformance/50-server-color.d.ts +8 -0
- package/dist/seeds/conformance/50-server-color.js +13 -0
- package/dist/seeds/conformance/70-account.d.ts +9 -0
- package/dist/seeds/conformance/70-account.js +29 -0
- package/dist/seeds/conformance/80-templates.d.ts +4 -0
- package/dist/seeds/conformance/80-templates.js +23 -0
- package/dist/seeds/conformance.d.ts +2 -0
- package/dist/seeds/conformance.js +3 -0
- package/dist/seeds/empty.d.ts +4 -0
- package/dist/seeds/empty.js +3 -0
- package/dist/seeds/lib/conformance.d.ts +16 -0
- package/dist/seeds/lib/conformance.js +18 -0
- package/dist/seeds/lib/history.d.ts +27 -0
- package/dist/seeds/lib/history.js +129 -0
- package/dist/seeds/lib/read-server.d.ts +10 -0
- package/dist/seeds/lib/read-server.js +10 -0
- package/dist/seeds/lib/template-server.d.ts +13 -0
- package/dist/seeds/lib/template-server.js +13 -0
- package/dist/src/api/account/authentication.d.ts +39 -0
- package/dist/src/api/account/authentication.js +97 -0
- package/dist/src/api/account/domains.d.ts +59 -0
- package/dist/src/api/account/domains.js +78 -0
- package/dist/src/api/account/paging.d.ts +12 -0
- package/dist/src/api/account/paging.js +35 -0
- package/dist/src/api/account/push.d.ts +14 -0
- package/dist/src/api/account/push.js +84 -0
- package/dist/src/api/account/routes.d.ts +1 -0
- package/dist/src/api/account/routes.js +288 -0
- package/dist/src/api/account/senders.d.ts +54 -0
- package/dist/src/api/account/senders.js +87 -0
- package/dist/src/api/account/servers.d.ts +40 -0
- package/dist/src/api/account/servers.js +148 -0
- package/dist/src/api/bounces/json.d.ts +25 -0
- package/dist/src/api/bounces/json.js +27 -0
- package/dist/src/api/bounces/routes.d.ts +1 -0
- package/dist/src/api/bounces/routes.js +127 -0
- package/dist/src/api/bulk/bulk.d.ts +43 -0
- package/dist/src/api/bulk/bulk.js +81 -0
- package/dist/src/api/bulk/routes.d.ts +1 -0
- package/dist/src/api/bulk/routes.js +252 -0
- package/dist/src/api/data-removals/routes.d.ts +1 -0
- package/dist/src/api/data-removals/routes.js +59 -0
- package/dist/src/api/email/json.d.ts +11 -0
- package/dist/src/api/email/json.js +33 -0
- package/dist/src/api/email/routes.d.ts +1 -0
- package/dist/src/api/email/routes.js +64 -0
- package/dist/src/api/inbound/routes.d.ts +1 -0
- package/dist/src/api/inbound/routes.js +40 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +3 -0
- package/dist/src/api/message-streams/routes.d.ts +1 -0
- package/dist/src/api/message-streams/routes.js +211 -0
- package/dist/src/api/message-streams/streams.d.ts +28 -0
- package/dist/src/api/message-streams/streams.js +50 -0
- package/dist/src/api/messages/json.d.ts +149 -0
- package/dist/src/api/messages/json.js +114 -0
- package/dist/src/api/messages/query.d.ts +19 -0
- package/dist/src/api/messages/query.js +64 -0
- package/dist/src/api/messages/routes.d.ts +1 -0
- package/dist/src/api/messages/routes.js +203 -0
- package/dist/src/api/server/json.d.ts +26 -0
- package/dist/src/api/server/json.js +25 -0
- package/dist/src/api/server/routes.d.ts +1 -0
- package/dist/src/api/server/routes.js +17 -0
- package/dist/src/api/stats/aggregate.d.ts +58 -0
- package/dist/src/api/stats/aggregate.js +66 -0
- package/dist/src/api/stats/routes.d.ts +1 -0
- package/dist/src/api/stats/routes.js +72 -0
- package/dist/src/api/suppressions/routes.d.ts +1 -0
- package/dist/src/api/suppressions/routes.js +98 -0
- package/dist/src/api/templates/content.d.ts +21 -0
- package/dist/src/api/templates/content.js +39 -0
- package/dist/src/api/templates/routes.d.ts +1 -0
- package/dist/src/api/templates/routes.js +164 -0
- package/dist/src/api/templates/send.d.ts +13 -0
- package/dist/src/api/templates/send.js +105 -0
- package/dist/src/api/templates/templates.d.ts +100 -0
- package/dist/src/api/templates/templates.js +218 -0
- package/dist/src/api/templates/validate.d.ts +15 -0
- package/dist/src/api/templates/validate.js +77 -0
- package/dist/src/api/triggers/routes.d.ts +1 -0
- package/dist/src/api/triggers/routes.js +62 -0
- package/dist/src/api/webhooks/routes.d.ts +14 -0
- package/dist/src/api/webhooks/routes.js +192 -0
- package/dist/src/config.d.ts +17 -0
- package/dist/src/config.js +95 -0
- package/dist/src/control/app.d.ts +5 -0
- package/dist/src/control/app.js +42 -0
- package/dist/src/control/endpoints/account.d.ts +1 -0
- package/dist/src/control/endpoints/account.js +68 -0
- package/dist/src/control/endpoints/bulk.d.ts +1 -0
- package/dist/src/control/endpoints/bulk.js +29 -0
- package/dist/src/control/endpoints/core.d.ts +1 -0
- package/dist/src/control/endpoints/core.js +104 -0
- package/dist/src/control/endpoints/inbound.d.ts +1 -0
- package/dist/src/control/endpoints/inbound.js +61 -0
- package/dist/src/control/endpoints/recipients.d.ts +1 -0
- package/dist/src/control/endpoints/recipients.js +136 -0
- package/dist/src/control/endpoints/smtp.d.ts +1 -0
- package/dist/src/control/endpoints/smtp.js +74 -0
- package/dist/src/control/endpoints/tracking.d.ts +1 -0
- package/dist/src/control/endpoints/tracking.js +95 -0
- package/dist/src/control/endpoints/webhooks.d.ts +1 -0
- package/dist/src/control/endpoints/webhooks.js +36 -0
- package/dist/src/control/index.d.ts +1 -0
- package/dist/src/control/index.js +3 -0
- package/dist/src/control/registry.d.ts +25 -0
- package/dist/src/control/registry.js +19 -0
- package/dist/src/control/seed.d.ts +11 -0
- package/dist/src/control/seed.js +32 -0
- package/dist/src/control/seeding.d.ts +10 -0
- package/dist/src/control/seeding.js +27 -0
- package/dist/src/discover.d.ts +16 -0
- package/dist/src/discover.js +49 -0
- package/dist/src/errors.d.ts +49 -0
- package/dist/src/errors.js +701 -0
- package/dist/src/events.d.ts +49 -0
- package/dist/src/events.js +19 -0
- package/dist/src/http/app.d.ts +8 -0
- package/dist/src/http/app.js +51 -0
- package/dist/src/http/auth.d.ts +7 -0
- package/dist/src/http/auth.js +32 -0
- package/dist/src/http/faults.d.ts +8 -0
- package/dist/src/http/faults.js +33 -0
- package/dist/src/http/normalize.d.ts +57 -0
- package/dist/src/http/normalize.js +225 -0
- package/dist/src/http/respond.d.ts +23 -0
- package/dist/src/http/respond.js +37 -0
- package/dist/src/http/routes.d.ts +60 -0
- package/dist/src/http/routes.js +64 -0
- package/dist/src/inbound/deliver.d.ts +9 -0
- package/dist/src/inbound/deliver.js +34 -0
- package/dist/src/inbound/parse.d.ts +21 -0
- package/dist/src/inbound/parse.js +71 -0
- package/dist/src/inbound/receive.d.ts +17 -0
- package/dist/src/inbound/receive.js +89 -0
- package/dist/src/main.d.ts +2 -0
- package/dist/src/main.js +14 -0
- package/dist/src/mime/compose.d.ts +33 -0
- package/dist/src/mime/compose.js +58 -0
- package/dist/src/pipeline/addresses.d.ts +6 -0
- package/dist/src/pipeline/addresses.js +38 -0
- package/dist/src/pipeline/inactive.d.ts +8 -0
- package/dist/src/pipeline/inactive.js +20 -0
- package/dist/src/pipeline/submit.d.ts +138 -0
- package/dist/src/pipeline/submit.js +415 -0
- package/dist/src/plugins/message-events.d.ts +7 -0
- package/dist/src/plugins/message-events.js +76 -0
- package/dist/src/plugins/smtp.d.ts +15 -0
- package/dist/src/plugins/smtp.js +42 -0
- package/dist/src/plugins/stats.d.ts +7 -0
- package/dist/src/plugins/stats.js +78 -0
- package/dist/src/plugins/webhooks.d.ts +4 -0
- package/dist/src/plugins/webhooks.js +80 -0
- package/dist/src/plugins.d.ts +20 -0
- package/dist/src/plugins.js +9 -0
- package/dist/src/recipients/test-bounces.d.ts +15 -0
- package/dist/src/recipients/test-bounces.js +31 -0
- package/dist/src/recipients/transitions.d.ts +57 -0
- package/dist/src/recipients/transitions.js +279 -0
- package/dist/src/render/mustachio.d.ts +60 -0
- package/dist/src/render/mustachio.js +265 -0
- package/dist/src/runtime.d.ts +12 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/server.d.ts +25 -0
- package/dist/src/server.js +49 -0
- package/dist/src/smtp/auth.d.ts +35 -0
- package/dist/src/smtp/auth.js +66 -0
- package/dist/src/smtp/listener.d.ts +16 -0
- package/dist/src/smtp/listener.js +121 -0
- package/dist/src/smtp/mime.d.ts +36 -0
- package/dist/src/smtp/mime.js +181 -0
- package/dist/src/smtp/receive.d.ts +21 -0
- package/dist/src/smtp/receive.js +89 -0
- package/dist/src/smtp/reply.d.ts +14 -0
- package/dist/src/smtp/reply.js +24 -0
- package/dist/src/state/clock.d.ts +35 -0
- package/dist/src/state/clock.js +107 -0
- package/dist/src/state/ids.d.ts +6 -0
- package/dist/src/state/ids.js +7 -0
- package/dist/src/state/servers.d.ts +27 -0
- package/dist/src/state/servers.js +101 -0
- package/dist/src/state/store.d.ts +57 -0
- package/dist/src/state/store.js +98 -0
- package/dist/src/state/suppressions.d.ts +9 -0
- package/dist/src/state/suppressions.js +8 -0
- package/dist/src/state/types.d.ts +530 -0
- package/dist/src/state/types.js +41 -0
- package/dist/src/time.d.ts +11 -0
- package/dist/src/time.js +48 -0
- package/dist/src/tracking.d.ts +53 -0
- package/dist/src/tracking.js +150 -0
- package/dist/src/webhooks/deliver.d.ts +60 -0
- package/dist/src/webhooks/deliver.js +172 -0
- package/dist/src/webhooks/egress.d.ts +19 -0
- package/dist/src/webhooks/egress.js +40 -0
- package/dist/src/webhooks/outbound.d.ts +26 -0
- package/dist/src/webhooks/outbound.js +47 -0
- package/dist/src/webhooks/payloads.d.ts +111 -0
- package/dist/src/webhooks/payloads.js +128 -0
- package/package.json +55 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Entities for the whole surface (docs/03–07).
|
|
2
|
+
// PascalCase fields carry a wire field of the same name; its value is formatted at the edge
|
|
3
|
+
// (a `Date` becomes the docs/02 §7.1 shape). camelCase fields are postmock-internal.
|
|
4
|
+
// `null` means "not set"; each API group decides whether the wire omits the key or sends null.
|
|
5
|
+
// Lowercase on the wire: the SDK live tests send `red` and read back `red`
|
|
6
|
+
// (sdk/postmark.js/test/integration/Servers.test.ts:57-70; refs/api_servers-api.md:67 example).
|
|
7
|
+
export const SERVER_COLORS = [
|
|
8
|
+
"purple",
|
|
9
|
+
"blue",
|
|
10
|
+
"turquoise",
|
|
11
|
+
"green",
|
|
12
|
+
"red",
|
|
13
|
+
"yellow",
|
|
14
|
+
"grey",
|
|
15
|
+
"orange",
|
|
16
|
+
];
|
|
17
|
+
// docs/04 §1.4.
|
|
18
|
+
export const BOUNCE_TYPES = {
|
|
19
|
+
HardBounce: { TypeCode: 1, Name: "Hard bounce" },
|
|
20
|
+
Transient: { TypeCode: 2, Name: "Message delayed/Undeliverable" },
|
|
21
|
+
Unsubscribe: { TypeCode: 16, Name: "Unsubscribe request" },
|
|
22
|
+
Subscribe: { TypeCode: 32, Name: "Subscribe request" },
|
|
23
|
+
AutoResponder: { TypeCode: 64, Name: "Auto responder" },
|
|
24
|
+
AddressChange: { TypeCode: 128, Name: "Address change" },
|
|
25
|
+
DnsError: { TypeCode: 256, Name: "DNS error" },
|
|
26
|
+
SpamNotification: { TypeCode: 512, Name: "Spam notification" },
|
|
27
|
+
OpenRelayTest: { TypeCode: 1024, Name: "Open relay test" },
|
|
28
|
+
Unknown: { TypeCode: 2048, Name: "Unknown" },
|
|
29
|
+
SoftBounce: { TypeCode: 4096, Name: "Soft bounce" },
|
|
30
|
+
VirusNotification: { TypeCode: 8192, Name: "Virus notification" },
|
|
31
|
+
ChallengeVerification: { TypeCode: 16384, Name: "Spam challenge verification" },
|
|
32
|
+
BadEmailAddress: { TypeCode: 100000, Name: "Invalid email address" },
|
|
33
|
+
SpamComplaint: { TypeCode: 100001, Name: "Spam complaint" },
|
|
34
|
+
ManuallyDeactivated: { TypeCode: 100002, Name: "Manually deactivated" },
|
|
35
|
+
Unconfirmed: { TypeCode: 100003, Name: "Registration not confirmed" },
|
|
36
|
+
Blocked: { TypeCode: 100006, Name: "ISP block" },
|
|
37
|
+
SMTPApiError: { TypeCode: 100007, Name: "SMTP API error" },
|
|
38
|
+
InboundError: { TypeCode: 100008, Name: "Processing failed" },
|
|
39
|
+
DMARCPolicy: { TypeCode: 100009, Name: "DMARC Policy" },
|
|
40
|
+
TemplateRenderingFailed: { TypeCode: 100010, Name: "Template rendering failed" },
|
|
41
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Minutes east of UTC for US Eastern at `instant` (-300 or -240). */
|
|
2
|
+
export declare function easternOffsetMinutes(instant: Date): number;
|
|
3
|
+
/** The instant of an Eastern wall-clock time. */
|
|
4
|
+
export declare function easternWallTime(year: number, month: number, day: number, hour?: number, minute?: number, second?: number, ms?: number): Date;
|
|
5
|
+
/**
|
|
6
|
+
* ISO 8601 with 7 fraction digits, e.g. `2014-02-17T07:25:01.4178645-05:00` (docs/02 §7.1).
|
|
7
|
+
* `zone: "utc"` gives the `Z` form some surfaces use (bounce list, webhooks, bulk).
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatTimestamp(instant: Date, zone?: "eastern" | "utc"): string;
|
|
10
|
+
/** `YYYY-MM-DD` in Eastern time, the stats `Date` shape (docs/08 E8). */
|
|
11
|
+
export declare function formatEasternDate(instant: Date): string;
|
package/dist/src/time.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Postmark reads zoneless query dates as US Eastern time and emits Eastern offsets
|
|
2
|
+
// (docs/02 §7.1; refs/api_messages-api.md:40-41).
|
|
3
|
+
const EASTERN = "America/New_York";
|
|
4
|
+
const partsFormat = new Intl.DateTimeFormat("en-US", {
|
|
5
|
+
timeZone: EASTERN,
|
|
6
|
+
hourCycle: "h23",
|
|
7
|
+
year: "numeric",
|
|
8
|
+
month: "2-digit",
|
|
9
|
+
day: "2-digit",
|
|
10
|
+
hour: "2-digit",
|
|
11
|
+
minute: "2-digit",
|
|
12
|
+
second: "2-digit",
|
|
13
|
+
});
|
|
14
|
+
/** Minutes east of UTC for US Eastern at `instant` (-300 or -240). */
|
|
15
|
+
export function easternOffsetMinutes(instant) {
|
|
16
|
+
const parts = partsFormat.formatToParts(instant);
|
|
17
|
+
const get = (type) => Number(parts.find((x) => x.type === type)?.value);
|
|
18
|
+
const wall = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
|
|
19
|
+
return Math.round((wall - Math.floor(instant.getTime() / 1000) * 1000) / 60000);
|
|
20
|
+
}
|
|
21
|
+
/** The instant of an Eastern wall-clock time. */
|
|
22
|
+
export function easternWallTime(year, month, day, hour = 0, minute = 0, second = 0, ms = 0) {
|
|
23
|
+
const wall = Date.UTC(year, month - 1, day, hour, minute, second, ms);
|
|
24
|
+
let offset = easternOffsetMinutes(new Date(wall));
|
|
25
|
+
offset = easternOffsetMinutes(new Date(wall - offset * 60000));
|
|
26
|
+
return new Date(wall - offset * 60000);
|
|
27
|
+
}
|
|
28
|
+
const pad = (n, width = 2) => String(Math.abs(n)).padStart(width, "0");
|
|
29
|
+
/**
|
|
30
|
+
* ISO 8601 with 7 fraction digits, e.g. `2014-02-17T07:25:01.4178645-05:00` (docs/02 §7.1).
|
|
31
|
+
* `zone: "utc"` gives the `Z` form some surfaces use (bounce list, webhooks, bulk).
|
|
32
|
+
*/
|
|
33
|
+
export function formatTimestamp(instant, zone = "eastern") {
|
|
34
|
+
const offset = zone === "utc" ? 0 : easternOffsetMinutes(instant);
|
|
35
|
+
const w = new Date(instant.getTime() + offset * 60000);
|
|
36
|
+
const date = `${w.getUTCFullYear()}-${pad(w.getUTCMonth() + 1)}-${pad(w.getUTCDate())}`;
|
|
37
|
+
const time = `${pad(w.getUTCHours())}:${pad(w.getUTCMinutes())}:${pad(w.getUTCSeconds())}`;
|
|
38
|
+
const fraction = `${pad(w.getUTCMilliseconds(), 3)}0000`;
|
|
39
|
+
const suffix = zone === "utc"
|
|
40
|
+
? "Z"
|
|
41
|
+
: `${offset < 0 ? "-" : "+"}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`;
|
|
42
|
+
return `${date}T${time}.${fraction}${suffix}`;
|
|
43
|
+
}
|
|
44
|
+
/** `YYYY-MM-DD` in Eastern time, the stats `Date` shape (docs/08 E8). */
|
|
45
|
+
export function formatEasternDate(instant) {
|
|
46
|
+
const w = new Date(instant.getTime() + easternOffsetMinutes(instant) * 60000);
|
|
47
|
+
return `${w.getUTCFullYear()}-${pad(w.getUTCMonth() + 1)}-${pad(w.getUTCDate())}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Runtime } from "./runtime.ts";
|
|
2
|
+
import type { ClickEvent, ClientInfo, Geo, OpenEvent, OutboundMessage } from "./state/types.ts";
|
|
3
|
+
/** A recipient action that real Postmark could not record. The control API answers 400. */
|
|
4
|
+
export declare class TrackingRefused extends Error {
|
|
5
|
+
}
|
|
6
|
+
export type Platform = NonNullable<OpenEvent["Platform"]>;
|
|
7
|
+
/** What the recipient's client tells Postmark. Postmark derives Client, OS, Platform from it. */
|
|
8
|
+
export interface RecipientAgent {
|
|
9
|
+
UserAgent: string;
|
|
10
|
+
Client: ClientInfo | null;
|
|
11
|
+
OS: ClientInfo | null;
|
|
12
|
+
Platform: Platform | null;
|
|
13
|
+
Geo: Geo | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The unique http(s) links Postmark rewrites for the message's `TrackLinks` mode. The same URL in
|
|
17
|
+
* the HTML and the text body is one link (refs/api_stats-api.md:648). The URL pattern is INFERRED.
|
|
18
|
+
*/
|
|
19
|
+
export declare function trackedLinks(message: OutboundMessage, location?: ClickEvent["ClickLocation"]): string[];
|
|
20
|
+
/**
|
|
21
|
+
* The recipient's mail server accepts the message (docs/05 §2.3). The `Delivered` message event is
|
|
22
|
+
* the delivery record that opens and clicks check, so it is written here, not by a listener.
|
|
23
|
+
*/
|
|
24
|
+
export declare function recordDelivery(runtime: Runtime, input: {
|
|
25
|
+
messageId: string;
|
|
26
|
+
recipient: string;
|
|
27
|
+
details: string;
|
|
28
|
+
}, at?: Date): Promise<{
|
|
29
|
+
MessageID: string;
|
|
30
|
+
Recipient: string;
|
|
31
|
+
ReceivedAt: Date;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* The recipient opens the message. Every open fires `opened` (webhook, stats). The Messages API
|
|
35
|
+
* keeps only the first open per recipient (refs/api_messages-api.md:753-754).
|
|
36
|
+
*/
|
|
37
|
+
export declare function recordOpen(runtime: Runtime, input: {
|
|
38
|
+
messageId: string;
|
|
39
|
+
recipient: string;
|
|
40
|
+
agent: RecipientAgent;
|
|
41
|
+
readSeconds: number;
|
|
42
|
+
}, at?: Date): Promise<OpenEvent>;
|
|
43
|
+
/**
|
|
44
|
+
* The recipient clicks a tracked link. Every click fires `clicked`. The Messages API keeps one
|
|
45
|
+
* click per recipient and unique link (refs/api_messages-api.md:864).
|
|
46
|
+
*/
|
|
47
|
+
export declare function recordClick(runtime: Runtime, input: {
|
|
48
|
+
messageId: string;
|
|
49
|
+
recipient: string;
|
|
50
|
+
link: string;
|
|
51
|
+
location: ClickEvent["ClickLocation"];
|
|
52
|
+
agent: RecipientAgent;
|
|
53
|
+
}, at?: Date): Promise<ClickEvent>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { recipientsOf } from "./recipients/transitions.js";
|
|
2
|
+
// What a real recipient can do with a delivered message: its server accepts it, the recipient
|
|
3
|
+
// opens it, the recipient clicks a tracked link (CONTROL-API.md principle). Control endpoints and
|
|
4
|
+
// seeds record these here; the Messages API and the stats plugin read the result.
|
|
5
|
+
/** A recipient action that real Postmark could not record. The control API answers 400. */
|
|
6
|
+
export class TrackingRefused extends Error {
|
|
7
|
+
}
|
|
8
|
+
const LINK = /https?:\/\/[^\s"'<>]+/g;
|
|
9
|
+
/**
|
|
10
|
+
* The unique http(s) links Postmark rewrites for the message's `TrackLinks` mode. The same URL in
|
|
11
|
+
* the HTML and the text body is one link (refs/api_stats-api.md:648). The URL pattern is INFERRED.
|
|
12
|
+
*/
|
|
13
|
+
export function trackedLinks(message, location) {
|
|
14
|
+
const mode = message.TrackLinks;
|
|
15
|
+
const html = mode === "HtmlAndText" || mode === "HtmlOnly";
|
|
16
|
+
const text = mode === "HtmlAndText" || mode === "TextOnly";
|
|
17
|
+
const parts = [
|
|
18
|
+
html && location !== "Text" ? (message.HtmlBody ?? "") : "",
|
|
19
|
+
text && location !== "HTML" ? (message.TextBody ?? "") : "",
|
|
20
|
+
];
|
|
21
|
+
return [...new Set(parts.flatMap((part) => part.match(LINK) ?? []))];
|
|
22
|
+
}
|
|
23
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
24
|
+
// A bounce of these types means the message never reached the recipient (INFERRED from the type
|
|
25
|
+
// names, refs/api_bounce-api.md). An auto responder or a spam complaint means it did.
|
|
26
|
+
const UNDELIVERED = new Set([
|
|
27
|
+
"HardBounce",
|
|
28
|
+
"SoftBounce",
|
|
29
|
+
"BadEmailAddress",
|
|
30
|
+
"Blocked",
|
|
31
|
+
"DnsError",
|
|
32
|
+
"ManuallyDeactivated",
|
|
33
|
+
"Unconfirmed",
|
|
34
|
+
"DMARCPolicy",
|
|
35
|
+
"SMTPApiError",
|
|
36
|
+
"TemplateRenderingFailed",
|
|
37
|
+
]);
|
|
38
|
+
/** A recipient of a stored, sent message, spelled as sent. */
|
|
39
|
+
function messageRecipient(runtime, messageId, recipient) {
|
|
40
|
+
const message = runtime.store.state.outbound.get(messageId);
|
|
41
|
+
if (message === undefined)
|
|
42
|
+
throw new TrackingRefused(`no outbound message ${messageId}`);
|
|
43
|
+
// A sandbox server delivers nothing (docs/07).
|
|
44
|
+
if (message.Sandboxed)
|
|
45
|
+
throw new TrackingRefused(`message ${messageId} is sandboxed`);
|
|
46
|
+
if (message.Status === "Queued")
|
|
47
|
+
throw new TrackingRefused(`message ${messageId} is queued`);
|
|
48
|
+
const address = recipientsOf(message).find((r) => same(r, recipient));
|
|
49
|
+
if (address === undefined) {
|
|
50
|
+
throw new TrackingRefused(`message ${messageId} was not sent to ${recipient}`);
|
|
51
|
+
}
|
|
52
|
+
// A send skips an address suppressed at send time (docs/04 §3.3).
|
|
53
|
+
if (message.suppressedRecipients.some((r) => same(r, address))) {
|
|
54
|
+
throw new TrackingRefused(`${address} was suppressed when ${messageId} was sent`);
|
|
55
|
+
}
|
|
56
|
+
const bounced = [...runtime.store.state.bounces.values()].some((b) => b.MessageID === messageId && same(b.Email, address) && UNDELIVERED.has(b.Type));
|
|
57
|
+
if (bounced)
|
|
58
|
+
throw new TrackingRefused(`message ${messageId} bounced for ${address}`);
|
|
59
|
+
return { message, address };
|
|
60
|
+
}
|
|
61
|
+
/** A recipient whose server accepted the message by `at`: only they can open or click. */
|
|
62
|
+
function deliveredRecipient(runtime, messageId, recipient, at) {
|
|
63
|
+
const found = messageRecipient(runtime, messageId, recipient);
|
|
64
|
+
const delivered = found.message.MessageEvents.some((e) => e.Type === "Delivered" &&
|
|
65
|
+
same(e.Recipient, found.address) &&
|
|
66
|
+
e.ReceivedAt.getTime() <= at.getTime());
|
|
67
|
+
if (!delivered) {
|
|
68
|
+
throw new TrackingRefused(`message ${messageId} was not delivered to ${found.address}`);
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The recipient's mail server accepts the message (docs/05 §2.3). The `Delivered` message event is
|
|
74
|
+
* the delivery record that opens and clicks check, so it is written here, not by a listener.
|
|
75
|
+
*/
|
|
76
|
+
export async function recordDelivery(runtime, input, at = runtime.clock.now()) {
|
|
77
|
+
const { message, address } = messageRecipient(runtime, input.messageId, input.recipient);
|
|
78
|
+
const delivered = message.MessageEvents.some((e) => e.Type === "Delivered" && same(e.Recipient, address));
|
|
79
|
+
if (delivered)
|
|
80
|
+
throw new TrackingRefused(`message ${message.MessageID} already reached ${address}`);
|
|
81
|
+
// DestinationServer and DestinationIP are left out: postmock delivers to no real server.
|
|
82
|
+
message.MessageEvents.push({
|
|
83
|
+
Recipient: address,
|
|
84
|
+
Type: "Delivered",
|
|
85
|
+
ReceivedAt: at,
|
|
86
|
+
Details: { DeliveryMessage: input.details },
|
|
87
|
+
});
|
|
88
|
+
await runtime.events.emit("delivered", {
|
|
89
|
+
message,
|
|
90
|
+
recipient: address,
|
|
91
|
+
deliveredAt: at,
|
|
92
|
+
details: input.details,
|
|
93
|
+
});
|
|
94
|
+
return { MessageID: message.MessageID, Recipient: address, ReceivedAt: at };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The recipient opens the message. Every open fires `opened` (webhook, stats). The Messages API
|
|
98
|
+
* keeps only the first open per recipient (refs/api_messages-api.md:753-754).
|
|
99
|
+
*/
|
|
100
|
+
export async function recordOpen(runtime, input, at = runtime.clock.now()) {
|
|
101
|
+
const { message, address } = deliveredRecipient(runtime, input.messageId, input.recipient, at);
|
|
102
|
+
if (!message.TrackOpens) {
|
|
103
|
+
throw new TrackingRefused(`message ${message.MessageID} has no open tracking`);
|
|
104
|
+
}
|
|
105
|
+
const opens = runtime.store.state.opens;
|
|
106
|
+
const first = !opens.some((o) => o.MessageID === message.MessageID && same(o.Recipient, address));
|
|
107
|
+
const open = {
|
|
108
|
+
...eventBase(message, address, at),
|
|
109
|
+
FirstOpen: first,
|
|
110
|
+
ReadSeconds: input.readSeconds,
|
|
111
|
+
...input.agent,
|
|
112
|
+
};
|
|
113
|
+
if (first)
|
|
114
|
+
opens.push(open);
|
|
115
|
+
await runtime.events.emit("opened", { open });
|
|
116
|
+
return open;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The recipient clicks a tracked link. Every click fires `clicked`. The Messages API keeps one
|
|
120
|
+
* click per recipient and unique link (refs/api_messages-api.md:864).
|
|
121
|
+
*/
|
|
122
|
+
export async function recordClick(runtime, input, at = runtime.clock.now()) {
|
|
123
|
+
const { message, address } = deliveredRecipient(runtime, input.messageId, input.recipient, at);
|
|
124
|
+
if (!trackedLinks(message, input.location).includes(input.link)) {
|
|
125
|
+
throw new TrackingRefused(`message ${message.MessageID} has no tracked link ${input.link} in its ${input.location} body (TrackLinks ${message.TrackLinks})`);
|
|
126
|
+
}
|
|
127
|
+
const clicks = runtime.store.state.clicks;
|
|
128
|
+
const first = !clicks.some((c) => c.MessageID === message.MessageID &&
|
|
129
|
+
same(c.Recipient, address) &&
|
|
130
|
+
c.OriginalLink === input.link);
|
|
131
|
+
const click = {
|
|
132
|
+
...eventBase(message, address, at),
|
|
133
|
+
ClickLocation: input.location,
|
|
134
|
+
OriginalLink: input.link,
|
|
135
|
+
...input.agent,
|
|
136
|
+
};
|
|
137
|
+
if (first)
|
|
138
|
+
clicks.push(click);
|
|
139
|
+
await runtime.events.emit("clicked", { click });
|
|
140
|
+
return click;
|
|
141
|
+
}
|
|
142
|
+
const eventBase = (message, recipient, at) => ({
|
|
143
|
+
ServerID: message.ServerID,
|
|
144
|
+
MessageID: message.MessageID,
|
|
145
|
+
MessageStream: message.MessageStream,
|
|
146
|
+
Recipient: recipient,
|
|
147
|
+
Tag: message.Tag,
|
|
148
|
+
Metadata: message.Metadata,
|
|
149
|
+
ReceivedAt: at,
|
|
150
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.ts";
|
|
2
|
+
import type { Header, WebhookAttempt, WebhookRecordType, WebhookTriggers } from "../state/types.ts";
|
|
3
|
+
export type ServerHookField = "InboundHookUrl" | "BounceHookUrl" | "OpenHookUrl" | "DeliveryHookUrl" | "ClickHookUrl";
|
|
4
|
+
/** Where one event goes: a `/webhooks` row and its trigger, or a server hook URL field. */
|
|
5
|
+
export interface Target {
|
|
6
|
+
serverId: number;
|
|
7
|
+
hook: {
|
|
8
|
+
kind: "webhook";
|
|
9
|
+
webhookId: number;
|
|
10
|
+
trigger: keyof WebhookTriggers;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "server";
|
|
13
|
+
field: ServerHookField;
|
|
14
|
+
};
|
|
15
|
+
url: string;
|
|
16
|
+
httpAuth: {
|
|
17
|
+
Username: string;
|
|
18
|
+
Password: string;
|
|
19
|
+
} | null;
|
|
20
|
+
headers: Header[];
|
|
21
|
+
}
|
|
22
|
+
type Outcome = WebhookAttempt["outcome"];
|
|
23
|
+
type Verdict = "success" | "retry" | "stop";
|
|
24
|
+
/** How a receiver's answer is read, and when a failed event is sent again. */
|
|
25
|
+
export interface RetryPolicy {
|
|
26
|
+
/** Minutes before each retry, in order. */
|
|
27
|
+
delaysMin: readonly number[];
|
|
28
|
+
timeoutMs: number;
|
|
29
|
+
/** Send `X-PM-Retries-Remaining`. */
|
|
30
|
+
retriesHeader: boolean;
|
|
31
|
+
classify(outcome: Outcome): Verdict;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Outbound events (docs/05 §3.4, §3.5): 2xx succeeds; 408, 429, 5xx and a network error retry;
|
|
35
|
+
* other codes drop the event. The outbound timeout is not documented (Q5); postmock uses the
|
|
36
|
+
* documented inbound 2 minutes (INFERRED).
|
|
37
|
+
*/
|
|
38
|
+
export declare const OUTBOUND: RetryPolicy;
|
|
39
|
+
/**
|
|
40
|
+
* Inbound (docs/05 §3.6): only 200 succeeds and only 403 stops. A 204 retries: the doc says
|
|
41
|
+
* "non-200" (conflict R2, Q6). Whether inbound requests carry `X-PM-Retries-Remaining` is Q8;
|
|
42
|
+
* postmock leaves it out.
|
|
43
|
+
*/
|
|
44
|
+
export declare const INBOUND: RetryPolicy;
|
|
45
|
+
export type AttemptResult = WebhookAttempt["result"];
|
|
46
|
+
export interface WebhookEvent {
|
|
47
|
+
target: Target;
|
|
48
|
+
recordType: WebhookRecordType;
|
|
49
|
+
payload: object;
|
|
50
|
+
policy: RetryPolicy;
|
|
51
|
+
/** Runs after each attempt is logged. */
|
|
52
|
+
onResult?: (result: AttemptResult) => void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Sends one event and awaits the first attempt. A retry goes on the clock and runs on a later
|
|
56
|
+
* `clock/advance` or when real time reaches it. Every attempt resends the same body bytes under the
|
|
57
|
+
* same trace ID (docs/05 §6).
|
|
58
|
+
*/
|
|
59
|
+
export declare function deliver(runtime: Runtime, event: WebhookEvent): Promise<void>;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { allows, egressOf } from "./egress.js";
|
|
3
|
+
/**
|
|
4
|
+
* Outbound events (docs/05 §3.4, §3.5): 2xx succeeds; 408, 429, 5xx and a network error retry;
|
|
5
|
+
* other codes drop the event. The outbound timeout is not documented (Q5); postmock uses the
|
|
6
|
+
* documented inbound 2 minutes (INFERRED).
|
|
7
|
+
*/
|
|
8
|
+
export const OUTBOUND = {
|
|
9
|
+
delaysMin: [1, 5, 10, 10, 10, 15],
|
|
10
|
+
timeoutMs: 120_000,
|
|
11
|
+
retriesHeader: true,
|
|
12
|
+
classify: (outcome) => {
|
|
13
|
+
if ("error" in outcome)
|
|
14
|
+
return "retry";
|
|
15
|
+
const { status } = outcome;
|
|
16
|
+
if (status >= 200 && status < 300)
|
|
17
|
+
return "success";
|
|
18
|
+
return status === 408 || status === 429 || status >= 500 ? "retry" : "stop";
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Inbound (docs/05 §3.6): only 200 succeeds and only 403 stops. A 204 retries: the doc says
|
|
23
|
+
* "non-200" (conflict R2, Q6). Whether inbound requests carry `X-PM-Retries-Remaining` is Q8;
|
|
24
|
+
* postmock leaves it out.
|
|
25
|
+
*/
|
|
26
|
+
export const INBOUND = {
|
|
27
|
+
delaysMin: [1, 5, 10, 10, 10, 15, 30, 60, 120, 360],
|
|
28
|
+
timeoutMs: 120_000,
|
|
29
|
+
retriesHeader: false,
|
|
30
|
+
classify: (outcome) => {
|
|
31
|
+
if ("error" in outcome)
|
|
32
|
+
return "retry";
|
|
33
|
+
if (outcome.status === 200)
|
|
34
|
+
return "success";
|
|
35
|
+
return outcome.status === 403 ? "stop" : "retry";
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const MAX_REDIRECTS = 10;
|
|
39
|
+
/**
|
|
40
|
+
* Sends one event and awaits the first attempt. A retry goes on the clock and runs on a later
|
|
41
|
+
* `clock/advance` or when real time reaches it. Every attempt resends the same body bytes under the
|
|
42
|
+
* same trace ID (docs/05 §6).
|
|
43
|
+
*/
|
|
44
|
+
export function deliver(runtime, event) {
|
|
45
|
+
return attempt(runtime, event, JSON.stringify(event.payload), randomUUID(), 1);
|
|
46
|
+
}
|
|
47
|
+
async function attempt(runtime, event, body, traceId, n) {
|
|
48
|
+
const { target, policy } = event;
|
|
49
|
+
if (n > 1 && !stillConfigured(runtime, target))
|
|
50
|
+
return;
|
|
51
|
+
const remaining = policy.delaysMin.length - (n - 1);
|
|
52
|
+
const { url, headers } = request(target, traceId, policy.retriesHeader ? remaining : null);
|
|
53
|
+
const at = runtime.clock.now();
|
|
54
|
+
const sent = await post(egressOf(runtime), url, headers, body, policy.timeoutMs);
|
|
55
|
+
const outcome = "stop" in sent ? { error: sent.stop } : sent;
|
|
56
|
+
// A refused target gets no retry: the configuration, not the receiver, decides it.
|
|
57
|
+
const verdict = "stop" in sent ? "stop" : policy.classify(outcome);
|
|
58
|
+
const result = verdict === "retry" && remaining === 0 ? "exhausted" : verdict;
|
|
59
|
+
const delayMs = result === "retry" ? policy.delaysMin[n - 1] * 60_000 : null;
|
|
60
|
+
runtime.store.state.webhookAttempts.push({
|
|
61
|
+
id: runtime.store.nextId("webhookAttempt"),
|
|
62
|
+
serverId: target.serverId,
|
|
63
|
+
webhookId: target.hook.kind === "webhook" ? target.hook.webhookId : null,
|
|
64
|
+
recordType: event.recordType,
|
|
65
|
+
url: target.url,
|
|
66
|
+
traceId,
|
|
67
|
+
headers: [...headers].map(([Name, Value]) => ({ Name, Value })),
|
|
68
|
+
body,
|
|
69
|
+
attempt: n,
|
|
70
|
+
at,
|
|
71
|
+
outcome,
|
|
72
|
+
result,
|
|
73
|
+
nextAttemptAt: delayMs === null ? null : new Date(at.getTime() + delayMs),
|
|
74
|
+
});
|
|
75
|
+
// Retry delays count from the attempt's start, so the schedule does not drift by request time.
|
|
76
|
+
if (delayMs !== null) {
|
|
77
|
+
const wait = Math.max(0, at.getTime() + delayMs - runtime.clock.now().getTime());
|
|
78
|
+
runtime.clock.schedule(wait, () => attempt(runtime, event, body, traceId, n + 1));
|
|
79
|
+
}
|
|
80
|
+
event.onResult?.(result);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A retry goes out only while the hook still asks for it: the server exists, and the webhook row
|
|
84
|
+
* exists, is verified, keeps its `Url` and its trigger on, or the server hook field keeps its URL
|
|
85
|
+
* (INFERRED, docs/05 D4).
|
|
86
|
+
*/
|
|
87
|
+
function stillConfigured(runtime, target) {
|
|
88
|
+
const { servers, webhooks } = runtime.store.state;
|
|
89
|
+
const server = servers.get(target.serverId);
|
|
90
|
+
if (server === undefined)
|
|
91
|
+
return false;
|
|
92
|
+
const { hook } = target;
|
|
93
|
+
if (hook.kind === "server")
|
|
94
|
+
return server[hook.field] === target.url;
|
|
95
|
+
const webhook = webhooks.get(hook.webhookId);
|
|
96
|
+
return (webhook !== undefined &&
|
|
97
|
+
webhook.ServerID === server.ID &&
|
|
98
|
+
webhook.Status === "verified" &&
|
|
99
|
+
webhook.Url === target.url &&
|
|
100
|
+
webhook.Triggers[hook.trigger].Enabled);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* URL userinfo and `HttpAuth` become `Authorization: Basic`; the userinfo leaves the request URL
|
|
104
|
+
* (docs/05 §3.2, Q18). With both, `HttpAuth` wins (INFERRED, Q3). The query string stays.
|
|
105
|
+
*/
|
|
106
|
+
function request(target, traceId, retriesRemaining) {
|
|
107
|
+
const url = new URL(target.url);
|
|
108
|
+
const userinfo = url.username === "" && url.password === ""
|
|
109
|
+
? null
|
|
110
|
+
: { Username: decodeURIComponent(url.username), Password: decodeURIComponent(url.password) };
|
|
111
|
+
url.username = "";
|
|
112
|
+
url.password = "";
|
|
113
|
+
const auth = target.httpAuth ?? userinfo;
|
|
114
|
+
const headers = new Headers();
|
|
115
|
+
for (const header of target.headers)
|
|
116
|
+
headers.append(header.Name, header.Value);
|
|
117
|
+
headers.set("Content-Type", "application/json");
|
|
118
|
+
headers.set("X-PM-Webhook-Trace-Id", traceId);
|
|
119
|
+
if (retriesRemaining !== null)
|
|
120
|
+
headers.set("X-PM-Retries-Remaining", String(retriesRemaining));
|
|
121
|
+
if (auth !== null) {
|
|
122
|
+
const token = Buffer.from(`${auth.Username}:${auth.Password}`).toString("base64");
|
|
123
|
+
headers.set("Authorization", `Basic ${token}`);
|
|
124
|
+
}
|
|
125
|
+
return { url, headers };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* POSTs and follows up to 10 redirects; the final answer decides (docs/05 §3.3). Every hop must be
|
|
129
|
+
* http or https to an allowed host; a redirect `Location` with userinfo or an unparsable one stops
|
|
130
|
+
* the event. A refused hop opens no socket.
|
|
131
|
+
*/
|
|
132
|
+
async function post(egress, url, headers, body, timeoutMs) {
|
|
133
|
+
let current = url;
|
|
134
|
+
for (let hop = 0;; hop++) {
|
|
135
|
+
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
|
136
|
+
return { stop: `redirect refused: scheme ${current.protocol} is not http or https` };
|
|
137
|
+
}
|
|
138
|
+
if (!allows(egress, current)) {
|
|
139
|
+
return {
|
|
140
|
+
stop: `egress refused: host ${current.hostname} is not loopback and not in POSTMOCK_WEBHOOKS_ALLOW_HOSTS`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await fetch(current, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers,
|
|
148
|
+
body,
|
|
149
|
+
redirect: "manual",
|
|
150
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
151
|
+
});
|
|
152
|
+
await response.body?.cancel();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const cause = error.cause;
|
|
156
|
+
return { error: cause?.message ?? error.message };
|
|
157
|
+
}
|
|
158
|
+
const location = response.headers.get("Location");
|
|
159
|
+
if (response.status < 300 || response.status >= 400 || location === null) {
|
|
160
|
+
return { status: response.status };
|
|
161
|
+
}
|
|
162
|
+
if (hop === MAX_REDIRECTS)
|
|
163
|
+
return { error: `more than ${MAX_REDIRECTS} redirects` };
|
|
164
|
+
const next = URL.parse(location, current.href);
|
|
165
|
+
if (next === null)
|
|
166
|
+
return { stop: `redirect refused: Location '${location}' is not a URL` };
|
|
167
|
+
if (next.username !== "" || next.password !== "") {
|
|
168
|
+
return { stop: "redirect refused: Location carries userinfo" };
|
|
169
|
+
}
|
|
170
|
+
current = next;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Runtime } from "../runtime.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Which hosts the emitter may reach. By default only loopback: SDK suites configure hooks at real
|
|
5
|
+
* hosts (www.example.com, www.postmark.com), and a test run must not POST to the internet or to
|
|
6
|
+
* Postmark (AGENTS.md rule 7). `POSTMOCK_WEBHOOKS_ALLOW_HOSTS` adds hostnames; `*` allows every host.
|
|
7
|
+
*/
|
|
8
|
+
export interface Egress {
|
|
9
|
+
allowHosts: ReadonlySet<string>;
|
|
10
|
+
}
|
|
11
|
+
export declare const LOOPBACK_ONLY: Egress;
|
|
12
|
+
export declare const egressEnv: z.ZodPipe<z.ZodPipe<z.ZodPipe<z.ZodObject<{
|
|
13
|
+
POSTMOCK_WEBHOOKS_ALLOW_HOSTS: z.ZodDefault<z.ZodString>;
|
|
14
|
+
}, z.core.$strip>, z.ZodTransform<string[], {
|
|
15
|
+
POSTMOCK_WEBHOOKS_ALLOW_HOSTS: string;
|
|
16
|
+
}>>, z.ZodArray<z.ZodPipe<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>, z.ZodTransform<string, string>>>>, z.ZodTransform<Egress, string[]>>;
|
|
17
|
+
export declare const setEgress: (runtime: Runtime, egress: Egress) => void;
|
|
18
|
+
export declare const egressOf: (runtime: Runtime) => Egress;
|
|
19
|
+
export declare function allows(egress: Egress, url: URL): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const LOOPBACK_ONLY = { allowHosts: new Set() };
|
|
4
|
+
/** An entry is a hostname, an IPv4 address, or an IPv6 address with or without brackets; no port. */
|
|
5
|
+
const hostEntry = z
|
|
6
|
+
.string()
|
|
7
|
+
.transform((entry) => entry.toLowerCase())
|
|
8
|
+
.transform((entry) => (/^\[.*\]$/.test(entry) ? entry.slice(1, -1) : entry))
|
|
9
|
+
.refine((host) => host === "*" || isIP(host) === 6 || !host.includes(":"), {
|
|
10
|
+
message: "an allowed host takes no port",
|
|
11
|
+
});
|
|
12
|
+
export const egressEnv = z
|
|
13
|
+
.object({ POSTMOCK_WEBHOOKS_ALLOW_HOSTS: z.string().default("") })
|
|
14
|
+
.transform(({ POSTMOCK_WEBHOOKS_ALLOW_HOSTS: hosts }) => hosts
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((h) => h.trim())
|
|
17
|
+
.filter((h) => h !== ""))
|
|
18
|
+
.pipe(z.array(hostEntry))
|
|
19
|
+
.transform((hosts) => ({ allowHosts: new Set(hosts) }));
|
|
20
|
+
const egressByRuntime = new WeakMap();
|
|
21
|
+
export const setEgress = (runtime, egress) => {
|
|
22
|
+
egressByRuntime.set(runtime, egress);
|
|
23
|
+
};
|
|
24
|
+
export const egressOf = (runtime) => egressByRuntime.get(runtime) ?? LOOPBACK_ONLY;
|
|
25
|
+
/**
|
|
26
|
+
* `localhost`, `127.0.0.0/8` and `::1` by their literal form. postmock resolves no name itself, but
|
|
27
|
+
* `fetch` resolves `localhost` through the OS resolver (normally a loopback address).
|
|
28
|
+
*/
|
|
29
|
+
function isLoopback(hostname) {
|
|
30
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
31
|
+
if (host === "localhost")
|
|
32
|
+
return true;
|
|
33
|
+
if (isIP(host) === 4)
|
|
34
|
+
return host.startsWith("127.");
|
|
35
|
+
return isIP(host) === 6 && host === "::1";
|
|
36
|
+
}
|
|
37
|
+
export function allows(egress, url) {
|
|
38
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
39
|
+
return egress.allowHosts.has("*") || egress.allowHosts.has(host) || isLoopback(host);
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.ts";
|
|
2
|
+
import type { Server, Webhook, WebhookRecordType, WebhookTriggers } from "../state/types.ts";
|
|
3
|
+
import { type ServerHookField } from "./deliver.ts";
|
|
4
|
+
/** A hook that receives an event: a `/webhooks` row, or the server's own hook URL. */
|
|
5
|
+
export type Hook = {
|
|
6
|
+
kind: "webhook";
|
|
7
|
+
webhook: Webhook;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "server";
|
|
10
|
+
server: Server;
|
|
11
|
+
};
|
|
12
|
+
export interface OutboundEvent {
|
|
13
|
+
serverId: number;
|
|
14
|
+
messageStream: string;
|
|
15
|
+
recordType: Exclude<WebhookRecordType, "Inbound">;
|
|
16
|
+
trigger: keyof WebhookTriggers;
|
|
17
|
+
/** The server hook URL field for this event; none for SpamComplaint and SubscriptionChange. */
|
|
18
|
+
serverHookField: ServerHookField | null;
|
|
19
|
+
/** The body for one hook, or null when that hook filters the event out. */
|
|
20
|
+
payload: (hook: Hook) => object | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Sends an event to every verified `/webhooks` row of its stream with the trigger on, then to the
|
|
24
|
+
* server hook URL (docs/05 §1.5, §5). Server hook URLs apply to every stream (INFERRED, Q10).
|
|
25
|
+
*/
|
|
26
|
+
export declare function emitOutbound(runtime: Runtime, event: OutboundEvent): Promise<void>;
|