@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,80 @@
|
|
|
1
|
+
import { deliverInbound } from "../inbound/deliver.js";
|
|
2
|
+
import { egressEnv, setEgress } from "../webhooks/egress.js";
|
|
3
|
+
import { emitOutbound } from "../webhooks/outbound.js";
|
|
4
|
+
import { bouncePayload, clickPayload, deliveryPayload, openPayload, subscriptionChangePayload, } from "../webhooks/payloads.js";
|
|
5
|
+
const bounceContent = (hook, trigger) => hook.kind === "webhook"
|
|
6
|
+
? hook.webhook.Triggers[trigger].IncludeContent
|
|
7
|
+
: hook.server.IncludeBounceContentInHook;
|
|
8
|
+
/** The webhook emitter: every domain event becomes a POST to the hooks that ask for it (docs/05 §5). */
|
|
9
|
+
const webhooks = {
|
|
10
|
+
install(runtime) {
|
|
11
|
+
setEgress(runtime, egressEnv.parse(process.env));
|
|
12
|
+
const { events } = runtime;
|
|
13
|
+
events.on("delivered", (e) => emitOutbound(runtime, {
|
|
14
|
+
serverId: e.message.ServerID,
|
|
15
|
+
messageStream: e.message.MessageStream,
|
|
16
|
+
recordType: "Delivery",
|
|
17
|
+
trigger: "Delivery",
|
|
18
|
+
serverHookField: "DeliveryHookUrl",
|
|
19
|
+
payload: () => deliveryPayload(e),
|
|
20
|
+
}));
|
|
21
|
+
const toBounceHooks = (bounce) => emitOutbound(runtime, {
|
|
22
|
+
serverId: bounce.ServerID,
|
|
23
|
+
messageStream: bounce.MessageStream,
|
|
24
|
+
recordType: "Bounce",
|
|
25
|
+
trigger: "Bounce",
|
|
26
|
+
serverHookField: "BounceHookUrl",
|
|
27
|
+
payload: (hook) => bouncePayload("Bounce", bounce, bounceContent(hook, "Bounce")),
|
|
28
|
+
});
|
|
29
|
+
events.on("bounced", ({ bounce }) => toBounceHooks(bounce));
|
|
30
|
+
// SMTP API errors reach the bounce hooks only with the server switch (docs/05 §2.8).
|
|
31
|
+
events.on("smtpApiError", async ({ bounce }) => {
|
|
32
|
+
if (runtime.store.state.servers.get(bounce.ServerID)?.EnableSmtpApiErrorHooks === true) {
|
|
33
|
+
await toBounceHooks(bounce);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
events.on("spamComplaint", ({ bounce }) => emitOutbound(runtime, {
|
|
37
|
+
serverId: bounce.ServerID,
|
|
38
|
+
messageStream: bounce.MessageStream,
|
|
39
|
+
recordType: "SpamComplaint",
|
|
40
|
+
trigger: "SpamComplaint",
|
|
41
|
+
serverHookField: null,
|
|
42
|
+
payload: (hook) => bouncePayload("SpamComplaint", bounce, bounceContent(hook, "SpamComplaint")),
|
|
43
|
+
}));
|
|
44
|
+
events.on("opened", ({ open }) => emitOutbound(runtime, {
|
|
45
|
+
serverId: open.ServerID,
|
|
46
|
+
messageStream: open.MessageStream,
|
|
47
|
+
recordType: "Open",
|
|
48
|
+
trigger: "Open",
|
|
49
|
+
serverHookField: "OpenHookUrl",
|
|
50
|
+
payload: (hook) => {
|
|
51
|
+
const firstOnly = hook.kind === "webhook"
|
|
52
|
+
? hook.webhook.Triggers.Open.PostFirstOpenOnly
|
|
53
|
+
: hook.server.PostFirstOpenOnly;
|
|
54
|
+
return firstOnly && !open.FirstOpen ? null : openPayload(open);
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
events.on("clicked", ({ click }) => emitOutbound(runtime, {
|
|
58
|
+
serverId: click.ServerID,
|
|
59
|
+
messageStream: click.MessageStream,
|
|
60
|
+
recordType: "Click",
|
|
61
|
+
trigger: "Click",
|
|
62
|
+
serverHookField: "ClickHookUrl",
|
|
63
|
+
payload: () => clickPayload(click),
|
|
64
|
+
}));
|
|
65
|
+
events.on("subscriptionChange", ({ change }) => emitOutbound(runtime, {
|
|
66
|
+
serverId: change.ServerID,
|
|
67
|
+
messageStream: change.MessageStream,
|
|
68
|
+
recordType: "SubscriptionChange",
|
|
69
|
+
trigger: "SubscriptionChange",
|
|
70
|
+
serverHookField: null,
|
|
71
|
+
payload: () => subscriptionChangePayload(change),
|
|
72
|
+
}));
|
|
73
|
+
// A blocked message waits for a bypass (docs/05 §4.1).
|
|
74
|
+
events.on("inboundReceived", async ({ message }) => {
|
|
75
|
+
if (message.Status !== "Blocked")
|
|
76
|
+
await deliverInbound(runtime, message);
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export default webhooks;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Runtime } from "./runtime.ts";
|
|
2
|
+
export interface StartedListener {
|
|
3
|
+
/** Key in `RunningPostmock.listeners` and the startup line, e.g. `smtp`. */
|
|
4
|
+
name: string;
|
|
5
|
+
url: string;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A feature that needs more than routes: event listeners (webhooks, stats) or its own listener
|
|
10
|
+
* (SMTP, TLS). One file per plugin in `src/plugins/`, default export. A plugin reads its own env
|
|
11
|
+
* keys in `start`, parsed there with zod (docs/11 §5).
|
|
12
|
+
*/
|
|
13
|
+
export interface Plugin {
|
|
14
|
+
/** Runs once per runtime, before any seed. */
|
|
15
|
+
install?(runtime: Runtime): void;
|
|
16
|
+
/** Starts a listener after the seed is applied. */
|
|
17
|
+
start?(runtime: Runtime, host: string): Promise<StartedListener>;
|
|
18
|
+
}
|
|
19
|
+
/** Every plugin in `src/plugins/`, in filename order. */
|
|
20
|
+
export declare const PLUGINS: Plugin[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { importAll } from "./discover.js";
|
|
2
|
+
/** Every plugin in `src/plugins/`, in filename order. */
|
|
3
|
+
export const PLUGINS = (await importAll(new URL("./plugins/", import.meta.url))).map((found) => {
|
|
4
|
+
const plugin = found.module.default;
|
|
5
|
+
if (typeof plugin !== "object" || plugin === null) {
|
|
6
|
+
throw new Error(`${found.url.pathname} has no default export plugin object`);
|
|
7
|
+
}
|
|
8
|
+
return plugin;
|
|
9
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Address, type BounceType, type Header } from "../state/types.ts";
|
|
2
|
+
/** The black-hole domain whose recipients bounce at once (docs/07 §2.3). */
|
|
3
|
+
export declare const BOUNCE_TESTING_DOMAIN = "bounce-testing.postmarkapp.com";
|
|
4
|
+
export interface TestBounce {
|
|
5
|
+
email: string;
|
|
6
|
+
type: BounceType;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The fake bounce of each recipient on `bounce-testing.postmarkapp.com` (docs/07 §2.3). The type
|
|
10
|
+
* comes from `X-PM-Bounce-Type`, else the local part; case and `_` do not matter. An unknown local
|
|
11
|
+
* part and `SpamComplaint` give HardBounce (DOC). The header winning over the local part is
|
|
12
|
+
* INFERRED. Throws `Unsupported` for an unknown header value and for a type whose effect on the
|
|
13
|
+
* address is not captured, so a send can refuse before it stores anything.
|
|
14
|
+
*/
|
|
15
|
+
export declare function testBounces(headers: readonly Header[], recipients: readonly Address[]): TestBounce[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Unsupported } from "../http/respond.js";
|
|
2
|
+
import { BOUNCE_TYPES } from "../state/types.js";
|
|
3
|
+
import { bounceEffectCaptured } from "./transitions.js";
|
|
4
|
+
/** The black-hole domain whose recipients bounce at once (docs/07 §2.3). */
|
|
5
|
+
export const BOUNCE_TESTING_DOMAIN = "bounce-testing.postmarkapp.com";
|
|
6
|
+
const TYPES = new Map(Object.keys(BOUNCE_TYPES).map((type) => [type.toLowerCase(), type]));
|
|
7
|
+
const known = (value) => TYPES.get(value.replaceAll("_", "").toLowerCase());
|
|
8
|
+
/**
|
|
9
|
+
* The fake bounce of each recipient on `bounce-testing.postmarkapp.com` (docs/07 §2.3). The type
|
|
10
|
+
* comes from `X-PM-Bounce-Type`, else the local part; case and `_` do not matter. An unknown local
|
|
11
|
+
* part and `SpamComplaint` give HardBounce (DOC). The header winning over the local part is
|
|
12
|
+
* INFERRED. Throws `Unsupported` for an unknown header value and for a type whose effect on the
|
|
13
|
+
* address is not captured, so a send can refuse before it stores anything.
|
|
14
|
+
*/
|
|
15
|
+
export function testBounces(headers, recipients) {
|
|
16
|
+
const header = headers.find((h) => h.Name.toLowerCase() === "x-pm-bounce-type")?.Value;
|
|
17
|
+
return recipients.flatMap(({ Email }) => {
|
|
18
|
+
const at = Email.lastIndexOf("@");
|
|
19
|
+
if (Email.slice(at + 1).toLowerCase() !== BOUNCE_TESTING_DOMAIN)
|
|
20
|
+
return [];
|
|
21
|
+
const named = known(header ?? Email.slice(0, at));
|
|
22
|
+
if (named === undefined && header !== undefined) {
|
|
23
|
+
throw new Unsupported(`X-PM-Bounce-Type '${header}': not a bounce type (docs/07 §2.3)`);
|
|
24
|
+
}
|
|
25
|
+
const type = named === undefined || named === "SpamComplaint" ? "HardBounce" : named;
|
|
26
|
+
if (!bounceEffectCaptured(type)) {
|
|
27
|
+
throw new Unsupported(`a fake ${type} bounce: its effect on the address is not captured (docs/04 Q13)`);
|
|
28
|
+
}
|
|
29
|
+
return [{ email: Email, type }];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.ts";
|
|
2
|
+
import type { Bounce, BounceType, OutboundMessage, Server } from "../state/types.ts";
|
|
3
|
+
/** Bounces are kept 45 days (refs/api_bounce-api.md:4); their suppression rows stay (T14, INFERRED). */
|
|
4
|
+
export declare const BOUNCE_RETENTION_MS: number;
|
|
5
|
+
/** No dump after 30 days (refs/api_bounce-api.md:141). */
|
|
6
|
+
export declare const DUMP_RETENTION_MS: number;
|
|
7
|
+
export declare const BOUNCE_DESCRIPTIONS: Record<BounceType, string>;
|
|
8
|
+
/** False for a bounce type whose effect on the address is not captured: recording one throws `Unsupported`. */
|
|
9
|
+
export declare const bounceEffectCaptured: (type: BounceType) => boolean;
|
|
10
|
+
export declare const recipientsOf: (message: OutboundMessage) => string[];
|
|
11
|
+
/** A bounce is readable until retention ends (T14). */
|
|
12
|
+
export declare const bounceVisible: (bounce: Bounce, now: Date) => boolean;
|
|
13
|
+
export declare const dumpAvailable: (bounce: Bounce, now: Date) => boolean;
|
|
14
|
+
/** A bounce of this server within retention, or 422 / 1001. */
|
|
15
|
+
export declare function findBounce(runtime: Runtime, server: Server, id: number): Bounce;
|
|
16
|
+
export interface BounceReport {
|
|
17
|
+
message: OutboundMessage;
|
|
18
|
+
/** One recipient of the message. */
|
|
19
|
+
email: string;
|
|
20
|
+
type: BounceType;
|
|
21
|
+
details: string;
|
|
22
|
+
/** Raw dump; "" when none. */
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The recipient server (or the recipient, for SpamComplaint) reports a bounce: T1, T3, T4.
|
|
27
|
+
* A deactivating type marks the bounce inactive and adds a Recipient row on the message stream
|
|
28
|
+
* unless one exists. Emits the bounce event, then `subscriptionChange` for a new row (Q12 order).
|
|
29
|
+
*/
|
|
30
|
+
export declare const recordBounce: (runtime: Runtime, report: BounceReport) => Promise<Bounce>;
|
|
31
|
+
/** `recordBounce` with a bounce ID the caller claimed and a time: a seed records past bounces (docs/11 §5). */
|
|
32
|
+
export declare function recordBounceAt(runtime: Runtime, report: BounceReport, { id, at }: {
|
|
33
|
+
id: number;
|
|
34
|
+
at: Date;
|
|
35
|
+
}): Promise<Bounce>;
|
|
36
|
+
/**
|
|
37
|
+
* The recipient unsubscribes through Postmark's link on a Broadcasts stream with
|
|
38
|
+
* `UnsubscribeHandlingType: Postmark` (T5). Returns false when the address is already unsubscribed;
|
|
39
|
+
* an unsubscribe over another row is not captured.
|
|
40
|
+
*/
|
|
41
|
+
export declare function recordUnsubscribe(runtime: Runtime, message: OutboundMessage, email: string): Promise<boolean>;
|
|
42
|
+
/** `PUT /bounces/{id}/activate` (T11, T12). */
|
|
43
|
+
export declare function activateBounce(runtime: Runtime, bounce: Bounce): Promise<void>;
|
|
44
|
+
export interface SuppressionStatus {
|
|
45
|
+
EmailAddress: string;
|
|
46
|
+
Status: "Suppressed" | "Deleted" | "Failed";
|
|
47
|
+
Message: string | null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Address syntax for the Suppressions API: one `@`, no spaces, a dot in the domain. The exact rule
|
|
51
|
+
* is INFERRED; `not-a-correct-email-address` fails (sdk/postmark-dotnet/src/Postmark.Tests/ClientSuppressionTests.cs:40).
|
|
52
|
+
*/
|
|
53
|
+
export declare const isEmailAddress: (value: string) => boolean;
|
|
54
|
+
/** `POST .../suppressions`, one item (T6). */
|
|
55
|
+
export declare function suppressByCustomer(runtime: Runtime, serverId: number, stream: string, email: string): Promise<SuppressionStatus>;
|
|
56
|
+
/** `POST .../suppressions/delete`, one item (T7–T10). */
|
|
57
|
+
export declare function deleteSuppression(runtime: Runtime, serverId: number, stream: string, email: string): Promise<SuppressionStatus>;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { apiError } from "../errors.js";
|
|
2
|
+
import { Unsupported } from "../http/respond.js";
|
|
3
|
+
import { suppressionKey } from "../state/store.js";
|
|
4
|
+
import { findSuppression } from "../state/suppressions.js";
|
|
5
|
+
// The recipient state machine of docs/04 §3.2: bounces, suppressions and the events between them.
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
/** Bounces are kept 45 days (refs/api_bounce-api.md:4); their suppression rows stay (T14, INFERRED). */
|
|
8
|
+
export const BOUNCE_RETENTION_MS = 45 * DAY_MS;
|
|
9
|
+
/** No dump after 30 days (refs/api_bounce-api.md:141). */
|
|
10
|
+
export const DUMP_RETENTION_MS = 30 * DAY_MS;
|
|
11
|
+
// refs/api_bounce-api.md:397-418.
|
|
12
|
+
export const BOUNCE_DESCRIPTIONS = {
|
|
13
|
+
HardBounce: "The server was unable to deliver your message (ex: unknown user, mailbox not found).",
|
|
14
|
+
Transient: "The server could not temporarily deliver your message (ex: Message is delayed due to network troubles).",
|
|
15
|
+
Unsubscribe: "Unsubscribe or Remove request.",
|
|
16
|
+
Subscribe: "Subscribe request from someone wanting to get added to the mailing list.",
|
|
17
|
+
AutoResponder: '"Autoresponder" is an automatic email responder including nondescript NDRs and some "out of office" replies.',
|
|
18
|
+
AddressChange: "The recipient has requested an address change.",
|
|
19
|
+
DnsError: "A temporary DNS error.",
|
|
20
|
+
SpamNotification: "The message was delivered, but was either blocked by the user, or classified as spam, bulk mail, or had rejected content.",
|
|
21
|
+
OpenRelayTest: "The NDR is actually a test email message to see if the mail server is an open relay.",
|
|
22
|
+
Unknown: "Unable to classify the NDR.",
|
|
23
|
+
SoftBounce: "Unable to temporarily deliver message (i.e. mailbox full, account disabled, exceeds quota, out of disk space).",
|
|
24
|
+
VirusNotification: "The bounce is actually a virus notification warning about a virus/code infected message.",
|
|
25
|
+
ChallengeVerification: "The bounce is a challenge asking for verification you actually sent the email. Typcial challenges are made by Spam Arrest, or MailFrontier Matador.",
|
|
26
|
+
BadEmailAddress: "The address is not a valid email address.",
|
|
27
|
+
SpamComplaint: "The subscriber explicitly marked this message as spam.",
|
|
28
|
+
ManuallyDeactivated: "The email was manually deactivated.",
|
|
29
|
+
Unconfirmed: "The subscriber has not clicked on the confirmation link upon registration or import.",
|
|
30
|
+
Blocked: "Blocked from this ISP due to content or blacklisting.",
|
|
31
|
+
SMTPApiError: "An error occurred while accepting an email through the SMTP API.",
|
|
32
|
+
InboundError: "Unable to deliver inbound message to destination inbound hook.",
|
|
33
|
+
DMARCPolicy: "Email rejected due DMARC Policy.",
|
|
34
|
+
TemplateRenderingFailed: "An error occurred while attempting to render your template.",
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Bounce types whose effect on the address is known (docs/04 §1.4): `true` deactivates.
|
|
38
|
+
* HardBounce and SpamComplaint are DOC; `false` rows are INFERRED (Q13). SoftBounce "sometimes"
|
|
39
|
+
* deactivates (DOC); postmock never does (Q13). Types absent here have an unknown effect.
|
|
40
|
+
*/
|
|
41
|
+
const DEACTIVATES = {
|
|
42
|
+
HardBounce: true,
|
|
43
|
+
SpamComplaint: true,
|
|
44
|
+
Transient: false,
|
|
45
|
+
Subscribe: false,
|
|
46
|
+
AutoResponder: false,
|
|
47
|
+
AddressChange: false,
|
|
48
|
+
DnsError: false,
|
|
49
|
+
SpamNotification: false,
|
|
50
|
+
OpenRelayTest: false,
|
|
51
|
+
Unknown: false,
|
|
52
|
+
SoftBounce: false,
|
|
53
|
+
VirusNotification: false,
|
|
54
|
+
ChallengeVerification: false,
|
|
55
|
+
};
|
|
56
|
+
/** False for a bounce type whose effect on the address is not captured: recording one throws `Unsupported`. */
|
|
57
|
+
export const bounceEffectCaptured = (type) => DEACTIVATES[type] !== undefined;
|
|
58
|
+
export const recipientsOf = (message) => [...message.To, ...message.Cc, ...message.Bcc].map((a) => a.Email);
|
|
59
|
+
/** A bounce is readable until retention ends (T14). */
|
|
60
|
+
export const bounceVisible = (bounce, now) => now.getTime() - bounce.BouncedAt.getTime() < BOUNCE_RETENTION_MS;
|
|
61
|
+
export const dumpAvailable = (bounce, now) => bounce.Content !== "" && now.getTime() - bounce.BouncedAt.getTime() < DUMP_RETENTION_MS;
|
|
62
|
+
/** A bounce of this server within retention, or 422 / 1001. */
|
|
63
|
+
export function findBounce(runtime, server, id) {
|
|
64
|
+
const bounce = runtime.store.state.bounces.get(id);
|
|
65
|
+
if (bounce === undefined ||
|
|
66
|
+
bounce.ServerID !== server.ID ||
|
|
67
|
+
!bounceVisible(bounce, runtime.clock.now())) {
|
|
68
|
+
// Message text INFERRED from the summary row (refs/api_overview.md:117).
|
|
69
|
+
throw apiError(1001, { message: "The bounce was not found." });
|
|
70
|
+
}
|
|
71
|
+
return bounce;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The recipient server (or the recipient, for SpamComplaint) reports a bounce: T1, T3, T4.
|
|
75
|
+
* A deactivating type marks the bounce inactive and adds a Recipient row on the message stream
|
|
76
|
+
* unless one exists. Emits the bounce event, then `subscriptionChange` for a new row (Q12 order).
|
|
77
|
+
*/
|
|
78
|
+
export const recordBounce = (runtime, report) => recordBounceAt(runtime, report, {
|
|
79
|
+
id: runtime.store.nextId("bounce"),
|
|
80
|
+
at: runtime.clock.now(),
|
|
81
|
+
});
|
|
82
|
+
/** `recordBounce` with a bounce ID the caller claimed and a time: a seed records past bounces (docs/11 §5). */
|
|
83
|
+
export async function recordBounceAt(runtime, report, { id, at }) {
|
|
84
|
+
const { store, events } = runtime;
|
|
85
|
+
const { message, email, type } = report;
|
|
86
|
+
const deactivates = DEACTIVATES[type];
|
|
87
|
+
if (deactivates === undefined) {
|
|
88
|
+
throw new Unsupported(`bounce type ${type}: effect on the address is not captured (docs/04 Q13)`);
|
|
89
|
+
}
|
|
90
|
+
const reason = type === "SpamComplaint" ? "SpamComplaint" : "HardBounce";
|
|
91
|
+
const existing = findSuppression(store.state, message.ServerID, message.MessageStream, email);
|
|
92
|
+
if (deactivates && existing !== undefined && existing.SuppressionReason !== reason) {
|
|
93
|
+
throw new Unsupported(`${type} for ${email}, suppressed as ${existing.SuppressionReason}/${existing.Origin}: effect not captured`);
|
|
94
|
+
}
|
|
95
|
+
const bounce = {
|
|
96
|
+
ID: id,
|
|
97
|
+
ServerID: message.ServerID,
|
|
98
|
+
MessageStream: message.MessageStream,
|
|
99
|
+
MessageID: message.MessageID,
|
|
100
|
+
Type: type,
|
|
101
|
+
Tag: message.Tag,
|
|
102
|
+
Description: BOUNCE_DESCRIPTIONS[type],
|
|
103
|
+
Details: report.details,
|
|
104
|
+
Email: email,
|
|
105
|
+
From: message.From,
|
|
106
|
+
Subject: message.Subject ?? "",
|
|
107
|
+
BouncedAt: at,
|
|
108
|
+
Inactive: deactivates,
|
|
109
|
+
// A spam complaint cannot be reactivated (refs/webhooks_spam-complaint-webhook.md:6,58).
|
|
110
|
+
CanActivate: type !== "SpamComplaint",
|
|
111
|
+
Content: report.content,
|
|
112
|
+
Metadata: message.Metadata,
|
|
113
|
+
};
|
|
114
|
+
store.state.bounces.set(bounce.ID, bounce);
|
|
115
|
+
const event = type === "SpamComplaint" ? "spamComplaint" : "bounced";
|
|
116
|
+
await events.emit(event, { bounce });
|
|
117
|
+
if (deactivates && existing === undefined) {
|
|
118
|
+
await addSuppression(runtime, {
|
|
119
|
+
serverId: message.ServerID,
|
|
120
|
+
stream: message.MessageStream,
|
|
121
|
+
email,
|
|
122
|
+
reason,
|
|
123
|
+
origin: "Recipient",
|
|
124
|
+
message,
|
|
125
|
+
at,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return bounce;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The recipient unsubscribes through Postmark's link on a Broadcasts stream with
|
|
132
|
+
* `UnsubscribeHandlingType: Postmark` (T5). Returns false when the address is already unsubscribed;
|
|
133
|
+
* an unsubscribe over another row is not captured.
|
|
134
|
+
*/
|
|
135
|
+
export async function recordUnsubscribe(runtime, message, email) {
|
|
136
|
+
const existing = findSuppression(runtime.store.state, message.ServerID, message.MessageStream, email);
|
|
137
|
+
if (existing !== undefined) {
|
|
138
|
+
if (existing.SuppressionReason === "ManualSuppression" && existing.Origin === "Recipient") {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
throw new Unsupported(`unsubscribe over a ${existing.SuppressionReason}/${existing.Origin} row is not captured`);
|
|
142
|
+
}
|
|
143
|
+
await addSuppression(runtime, {
|
|
144
|
+
serverId: message.ServerID,
|
|
145
|
+
stream: message.MessageStream,
|
|
146
|
+
email,
|
|
147
|
+
reason: "ManualSuppression",
|
|
148
|
+
origin: "Recipient",
|
|
149
|
+
message,
|
|
150
|
+
at: runtime.clock.now(),
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
async function addSuppression(runtime, row) {
|
|
155
|
+
const now = row.at;
|
|
156
|
+
const suppression = {
|
|
157
|
+
ServerID: row.serverId,
|
|
158
|
+
MessageStream: row.stream,
|
|
159
|
+
EmailAddress: row.email,
|
|
160
|
+
SuppressionReason: row.reason,
|
|
161
|
+
Origin: row.origin,
|
|
162
|
+
CreatedAt: now,
|
|
163
|
+
};
|
|
164
|
+
runtime.store.state.suppressions.set(suppressionKey(row.serverId, row.stream, row.email), suppression);
|
|
165
|
+
// refs/webhooks_subscription-change-webhook.md:35-38: MessageID null for a manual suppression.
|
|
166
|
+
await runtime.events.emit("subscriptionChange", {
|
|
167
|
+
change: {
|
|
168
|
+
MessageID: row.message?.MessageID ?? null,
|
|
169
|
+
ServerID: row.serverId,
|
|
170
|
+
MessageStream: row.stream,
|
|
171
|
+
ChangedAt: now,
|
|
172
|
+
Recipient: row.email,
|
|
173
|
+
Origin: row.origin,
|
|
174
|
+
SuppressSending: true,
|
|
175
|
+
SuppressionReason: row.reason,
|
|
176
|
+
Tag: row.message?.Tag ?? null,
|
|
177
|
+
Metadata: row.message?.Metadata ?? {},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Removes a row the customer may lift and reactivates its bounces (T7, T8, T11). The
|
|
183
|
+
* `subscriptionChange` event has the null fields of a reactivation (docs/05 §2.6).
|
|
184
|
+
*/
|
|
185
|
+
async function reactivate(runtime, row) {
|
|
186
|
+
const { store, clock } = runtime;
|
|
187
|
+
store.state.suppressions.delete(suppressionKey(row.ServerID, row.MessageStream, row.EmailAddress));
|
|
188
|
+
const email = row.EmailAddress.toLowerCase();
|
|
189
|
+
// The bounces that caused the row turn active (docs/04 §3.2 T7, flag change INFERRED).
|
|
190
|
+
for (const bounce of store.state.bounces.values()) {
|
|
191
|
+
if (bounce.ServerID === row.ServerID &&
|
|
192
|
+
bounce.MessageStream === row.MessageStream &&
|
|
193
|
+
bounce.Email.toLowerCase() === email &&
|
|
194
|
+
bounce.Inactive) {
|
|
195
|
+
bounce.Inactive = false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
await runtime.events.emit("subscriptionChange", {
|
|
199
|
+
change: {
|
|
200
|
+
MessageID: null,
|
|
201
|
+
ServerID: row.ServerID,
|
|
202
|
+
MessageStream: row.MessageStream,
|
|
203
|
+
ChangedAt: clock.now(),
|
|
204
|
+
Recipient: row.EmailAddress,
|
|
205
|
+
Origin: "Customer",
|
|
206
|
+
SuppressSending: false,
|
|
207
|
+
SuppressionReason: null,
|
|
208
|
+
Tag: null,
|
|
209
|
+
Metadata: {},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** `PUT /bounces/{id}/activate` (T11, T12). */
|
|
214
|
+
export async function activateBounce(runtime, bounce) {
|
|
215
|
+
if (!bounce.CanActivate)
|
|
216
|
+
throw apiError(1003);
|
|
217
|
+
if (!bounce.Inactive) {
|
|
218
|
+
throw new Unsupported("activate on an active bounce is not captured (docs/04 Q9)");
|
|
219
|
+
}
|
|
220
|
+
const row = findSuppression(runtime.store.state, bounce.ServerID, bounce.MessageStream, bounce.Email);
|
|
221
|
+
// Every path that removes a row also turns its bounces active.
|
|
222
|
+
if (row === undefined)
|
|
223
|
+
throw new Error(`inactive bounce ${bounce.ID} has no suppression row`);
|
|
224
|
+
if (row.SuppressionReason !== "HardBounce") {
|
|
225
|
+
throw new Unsupported(`activate with a ${row.SuppressionReason}/${row.Origin} row is not captured`);
|
|
226
|
+
}
|
|
227
|
+
await reactivate(runtime, row);
|
|
228
|
+
}
|
|
229
|
+
// refs/api_suppressions-api.md:158-165.
|
|
230
|
+
const AUTHORITY = "You do not have the required authority to change this suppression.";
|
|
231
|
+
const INVALID_ADDRESS = "An invalid email address was provided.";
|
|
232
|
+
/**
|
|
233
|
+
* Address syntax for the Suppressions API: one `@`, no spaces, a dot in the domain. The exact rule
|
|
234
|
+
* is INFERRED; `not-a-correct-email-address` fails (sdk/postmark-dotnet/src/Postmark.Tests/ClientSuppressionTests.cs:40).
|
|
235
|
+
*/
|
|
236
|
+
export const isEmailAddress = (value) => /^[^@\s<>()",;:]+@[^@\s<>()",;:]+\.[^@\s<>()",;:]+$/.test(value);
|
|
237
|
+
/** `POST .../suppressions`, one item (T6). */
|
|
238
|
+
export async function suppressByCustomer(runtime, serverId, stream, email) {
|
|
239
|
+
if (!isEmailAddress(email))
|
|
240
|
+
return { EmailAddress: email, Status: "Failed", Message: INVALID_ADDRESS };
|
|
241
|
+
const existing = findSuppression(runtime.store.state, serverId, stream, email);
|
|
242
|
+
if (existing === undefined) {
|
|
243
|
+
await addSuppression(runtime, {
|
|
244
|
+
serverId,
|
|
245
|
+
stream,
|
|
246
|
+
email,
|
|
247
|
+
reason: "ManualSuppression",
|
|
248
|
+
origin: "Customer",
|
|
249
|
+
message: null,
|
|
250
|
+
at: runtime.clock.now(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
else if (existing.SuppressionReason === "SpamComplaint") {
|
|
254
|
+
return { EmailAddress: email, Status: "Failed", Message: AUTHORITY };
|
|
255
|
+
}
|
|
256
|
+
else if (existing.SuppressionReason !== "ManualSuppression" || existing.Origin !== "Customer") {
|
|
257
|
+
throw new Unsupported(`suppress ${email} over a ${existing.SuppressionReason}/${existing.Origin} row is not captured`);
|
|
258
|
+
}
|
|
259
|
+
return { EmailAddress: email, Status: "Suppressed", Message: null };
|
|
260
|
+
}
|
|
261
|
+
/** `POST .../suppressions/delete`, one item (T7–T10). */
|
|
262
|
+
export async function deleteSuppression(runtime, serverId, stream, email) {
|
|
263
|
+
// Failed for bad syntax on delete is INFERRED from create (docs/04 §2.4).
|
|
264
|
+
if (!isEmailAddress(email))
|
|
265
|
+
return { EmailAddress: email, Status: "Failed", Message: INVALID_ADDRESS };
|
|
266
|
+
const row = findSuppression(runtime.store.state, serverId, stream, email);
|
|
267
|
+
if (row === undefined)
|
|
268
|
+
return { EmailAddress: email, Status: "Deleted", Message: null };
|
|
269
|
+
if (row.SuppressionReason === "SpamComplaint") {
|
|
270
|
+
return { EmailAddress: email, Status: "Failed", Message: AUTHORITY };
|
|
271
|
+
}
|
|
272
|
+
const liftable = (row.SuppressionReason === "HardBounce" && row.Origin === "Recipient") ||
|
|
273
|
+
(row.SuppressionReason === "ManualSuppression" && row.Origin === "Customer");
|
|
274
|
+
if (!liftable) {
|
|
275
|
+
throw new Unsupported(`delete of a ${row.SuppressionReason}/${row.Origin} row is not captured (docs/04 Q4)`);
|
|
276
|
+
}
|
|
277
|
+
await reactivate(runtime, row);
|
|
278
|
+
return { EmailAddress: email, Status: "Deleted", Message: null };
|
|
279
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** `up` counts `../` prefixes; empty `keys` is `{{.}}`. */
|
|
2
|
+
interface Path {
|
|
3
|
+
raw: string;
|
|
4
|
+
up: number;
|
|
5
|
+
keys: string[];
|
|
6
|
+
}
|
|
7
|
+
type Node = {
|
|
8
|
+
kind: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "value";
|
|
12
|
+
path: Path;
|
|
13
|
+
escape: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "section";
|
|
16
|
+
path: Path;
|
|
17
|
+
inverted: boolean;
|
|
18
|
+
children: Node[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: "each";
|
|
21
|
+
path: Path;
|
|
22
|
+
children: Node[];
|
|
23
|
+
}
|
|
24
|
+
/** `{{{ @content }}}`: where a layout takes the template (article :320-326). */
|
|
25
|
+
| {
|
|
26
|
+
kind: "content";
|
|
27
|
+
};
|
|
28
|
+
/** A `/templates/validate` error: 1-based, `null` when unknown (refs/api_templates-api.md:842). */
|
|
29
|
+
export interface TemplateError {
|
|
30
|
+
Message: string;
|
|
31
|
+
Line: number | null;
|
|
32
|
+
CharacterPosition: number | null;
|
|
33
|
+
}
|
|
34
|
+
export interface ParsedTemplate {
|
|
35
|
+
nodes: Node[];
|
|
36
|
+
/** Number of `{{{ @content }}}` placeholders. */
|
|
37
|
+
contentPlaceholders: number;
|
|
38
|
+
}
|
|
39
|
+
export type ParseResult = {
|
|
40
|
+
ok: true;
|
|
41
|
+
template: ParsedTemplate;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
errors: TemplateError[];
|
|
45
|
+
};
|
|
46
|
+
/** Parses Mustachio source. Every syntax error is collected with its position. */
|
|
47
|
+
export declare function parseTemplate(source: string): ParseResult;
|
|
48
|
+
/**
|
|
49
|
+
* Renders a parsed template with a model. `content` fills `{{{ @content }}}` in a layout.
|
|
50
|
+
* A missing value renders as "" (docs/06 §3.5, INFERRED).
|
|
51
|
+
*/
|
|
52
|
+
export declare function renderTemplate(template: ParsedTemplate, model: unknown, content?: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* `SuggestedTemplateModel`: a placeholder `<key>_Value` for every key the templates read
|
|
55
|
+
* (refs/api_templates-api.md:844, :873-886).
|
|
56
|
+
*/
|
|
57
|
+
export declare function suggestModel(templates: readonly ParsedTemplate[]): Record<string, unknown>;
|
|
58
|
+
/** Deep merge; `override` values win and its keys come first (refs/api_templates-api.md:873-886). */
|
|
59
|
+
export declare function mergeModels(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown>;
|
|
60
|
+
export {};
|