@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Method + path patterns (`/templates/:idOrAlias`). Literal segments match without case and a
|
|
3
|
+
* trailing slash is ignored (docs/08 R2). A literal beats a param at the same position, so
|
|
4
|
+
* `PUT /templates/push` wins over `PUT /templates/:idOrAlias` (docs/08 §2.5).
|
|
5
|
+
*/
|
|
6
|
+
export class RouteTable {
|
|
7
|
+
entries = [];
|
|
8
|
+
add(route) {
|
|
9
|
+
const segments = parsePattern(route.path);
|
|
10
|
+
const shape = shapeOf(segments);
|
|
11
|
+
const clash = this.entries.find((e) => e.route.method === route.method && shapeOf(e.segments) === shape);
|
|
12
|
+
if (clash)
|
|
13
|
+
throw new Error(`route ${route.method} ${route.path} clashes with ${clash.route.path}`);
|
|
14
|
+
this.entries.push({ route, segments });
|
|
15
|
+
}
|
|
16
|
+
match(method, pathname) {
|
|
17
|
+
const parts = pathSegments(pathname);
|
|
18
|
+
if (parts === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
let best;
|
|
21
|
+
for (const { route, segments } of this.entries) {
|
|
22
|
+
if (route.method !== method.toUpperCase() || segments.length !== parts.length)
|
|
23
|
+
continue;
|
|
24
|
+
const params = {};
|
|
25
|
+
const ok = segments.every((segment, i) => {
|
|
26
|
+
const part = parts[i];
|
|
27
|
+
if ("param" in segment)
|
|
28
|
+
params[segment.param] = part;
|
|
29
|
+
return "param" in segment || segment.literal === part.toLowerCase();
|
|
30
|
+
});
|
|
31
|
+
const rank = segments.map((s) => ("literal" in s ? "0" : "1")).join("");
|
|
32
|
+
if (ok && (best === undefined || rank < best.rank))
|
|
33
|
+
best = { route, params, rank };
|
|
34
|
+
}
|
|
35
|
+
return best && { route: best.route, params: best.params };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Decoded segments of a request path. One trailing slash is ignored (docs/08 R2); any other empty
|
|
40
|
+
* segment (`//email`) or a malformed percent-escape matches no route.
|
|
41
|
+
*/
|
|
42
|
+
function pathSegments(pathname) {
|
|
43
|
+
const parts = pathname.replace(/^\//, "").replace(/\/$/, "").split("/");
|
|
44
|
+
if (parts.some((p) => p === ""))
|
|
45
|
+
return undefined;
|
|
46
|
+
try {
|
|
47
|
+
return parts.map(decodeURIComponent);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function parsePattern(path) {
|
|
54
|
+
return path
|
|
55
|
+
.split("/")
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.map((s) => (s.startsWith(":") ? { param: s.slice(1) } : { literal: s.toLowerCase() }));
|
|
58
|
+
}
|
|
59
|
+
const shapeOf = (segments) => segments.map((s) => ("literal" in s ? s.literal : ":")).join("/");
|
|
60
|
+
export const apiRoutes = new RouteTable();
|
|
61
|
+
/** Registers a Postmark API route. Each `src/api/<group>/routes.ts` calls it at import. */
|
|
62
|
+
export function defineRoute(route) {
|
|
63
|
+
apiRoutes.add(route);
|
|
64
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.ts";
|
|
2
|
+
import type { InboundMessage } from "../state/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* POSTs an inbound message to the server's `InboundHookUrl` (docs/05 §3.6, §4.1) and tracks its
|
|
5
|
+
* status: `Processed` on success, `Scheduled` while a retry waits, `Failed` after a 403 or the last
|
|
6
|
+
* retry. A server without a hook URL processes the message at once. The status names follow the
|
|
7
|
+
* inbound search filter (refs/api_messages-api.md:315); the mapping is INFERRED.
|
|
8
|
+
*/
|
|
9
|
+
export declare function deliverInbound(runtime: Runtime, message: InboundMessage): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { deliver, INBOUND } from "../webhooks/deliver.js";
|
|
2
|
+
import { inboundPayload } from "../webhooks/payloads.js";
|
|
3
|
+
/**
|
|
4
|
+
* POSTs an inbound message to the server's `InboundHookUrl` (docs/05 §3.6, §4.1) and tracks its
|
|
5
|
+
* status: `Processed` on success, `Scheduled` while a retry waits, `Failed` after a 403 or the last
|
|
6
|
+
* retry. A server without a hook URL processes the message at once. The status names follow the
|
|
7
|
+
* inbound search filter (refs/api_messages-api.md:315); the mapping is INFERRED.
|
|
8
|
+
*/
|
|
9
|
+
export async function deliverInbound(runtime, message) {
|
|
10
|
+
const server = runtime.store.state.servers.get(message.ServerID);
|
|
11
|
+
// Like outbound events, an event of a server that no longer exists goes nowhere.
|
|
12
|
+
if (server === undefined)
|
|
13
|
+
return;
|
|
14
|
+
if (server.InboundHookUrl === "") {
|
|
15
|
+
message.Status = "Processed";
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
await deliver(runtime, {
|
|
19
|
+
target: {
|
|
20
|
+
serverId: server.ID,
|
|
21
|
+
hook: { kind: "server", field: "InboundHookUrl" },
|
|
22
|
+
url: server.InboundHookUrl,
|
|
23
|
+
httpAuth: null,
|
|
24
|
+
headers: [],
|
|
25
|
+
},
|
|
26
|
+
recordType: "Inbound",
|
|
27
|
+
payload: inboundPayload(message, server.RawEmailEnabled),
|
|
28
|
+
policy: INBOUND,
|
|
29
|
+
onResult: (result) => {
|
|
30
|
+
message.Status =
|
|
31
|
+
result === "success" ? "Processed" : result === "retry" ? "Scheduled" : "Failed";
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Header, InboundAddress, InboundAttachment } from "../state/types.ts";
|
|
2
|
+
/** What inbound processing reads from a received MIME message (docs/05 §4.2). */
|
|
3
|
+
export interface ParsedMail {
|
|
4
|
+
from: InboundAddress;
|
|
5
|
+
to: InboundAddress[];
|
|
6
|
+
cc: InboundAddress[];
|
|
7
|
+
replyTo: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
/** The `Date` header as the sender wrote it. */
|
|
10
|
+
date: string;
|
|
11
|
+
textBody: string;
|
|
12
|
+
htmlBody: string;
|
|
13
|
+
strippedTextReply: string;
|
|
14
|
+
headers: Header[];
|
|
15
|
+
attachments: InboundAttachment[];
|
|
16
|
+
}
|
|
17
|
+
/** Text after `+` in the local part, or `""` (docs/05 §4.2). */
|
|
18
|
+
export declare const mailboxHash: (email: string) => string;
|
|
19
|
+
/** The legacy string form: `"Name" <email>` or a bare `email`, comma-separated (docs/05 §4.2). */
|
|
20
|
+
export declare const legacyList: (list: InboundAddress[]) => string;
|
|
21
|
+
export declare function parseMime(mime: string): Promise<ParsedMail>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { simpleParser } from "mailparser";
|
|
2
|
+
/** Text after `+` in the local part, or `""` (docs/05 §4.2). */
|
|
3
|
+
export const mailboxHash = (email) => {
|
|
4
|
+
const local = email.slice(0, email.lastIndexOf("@"));
|
|
5
|
+
const plus = local.indexOf("+");
|
|
6
|
+
return plus === -1 ? "" : local.slice(plus + 1);
|
|
7
|
+
};
|
|
8
|
+
const full = (a) => ({
|
|
9
|
+
Email: a.address ?? "",
|
|
10
|
+
Name: a.name,
|
|
11
|
+
MailboxHash: mailboxHash(a.address ?? ""),
|
|
12
|
+
});
|
|
13
|
+
const addresses = (field) => (Array.isArray(field) ? field : field === undefined ? [] : [field])
|
|
14
|
+
.flatMap((o) => o.value)
|
|
15
|
+
.flatMap((a) => a.group ?? [a])
|
|
16
|
+
.map(full);
|
|
17
|
+
/** The legacy string form: `"Name" <email>` or a bare `email`, comma-separated (docs/05 §4.2). */
|
|
18
|
+
export const legacyList = (list) => list.map((a) => (a.Name === "" ? a.Email : `"${a.Name}" <${a.Email}>`)).join(", ");
|
|
19
|
+
// The doc example `Headers` holds MIME-Version, Message-ID and trace headers, but not the headers
|
|
20
|
+
// that have their own field (refs/user-guide_inbound_parse-an-email.md:113-146; INFERRED).
|
|
21
|
+
const OWN_FIELD = new Set([
|
|
22
|
+
"from",
|
|
23
|
+
"to",
|
|
24
|
+
"cc",
|
|
25
|
+
"bcc",
|
|
26
|
+
"reply-to",
|
|
27
|
+
"subject",
|
|
28
|
+
"date",
|
|
29
|
+
"content-type",
|
|
30
|
+
"content-transfer-encoding",
|
|
31
|
+
]);
|
|
32
|
+
// A reply's quoted part starts at an attribution line or the first quoted line (INFERRED).
|
|
33
|
+
const QUOTE_START = /^(On .+ wrote:|>)/m;
|
|
34
|
+
export async function parseMime(mime) {
|
|
35
|
+
const mail = await simpleParser(mime, {
|
|
36
|
+
skipHtmlToText: true,
|
|
37
|
+
skipTextToHtml: true,
|
|
38
|
+
skipImageLinks: true,
|
|
39
|
+
skipTextLinks: true,
|
|
40
|
+
});
|
|
41
|
+
const text = mail.text ?? "";
|
|
42
|
+
const isReply = mail.headers.has("in-reply-to") || mail.headers.has("references");
|
|
43
|
+
const quote = QUOTE_START.exec(text);
|
|
44
|
+
const lineOf = (key) => mail.headerLines.find((h) => h.key === key)?.line;
|
|
45
|
+
const headerValue = (line) => line
|
|
46
|
+
.slice(line.indexOf(":") + 1)
|
|
47
|
+
.replace(/\r?\n/g, "")
|
|
48
|
+
.trim();
|
|
49
|
+
return {
|
|
50
|
+
from: addresses(mail.from)[0] ?? { Email: "", Name: "", MailboxHash: "" },
|
|
51
|
+
to: addresses(mail.to),
|
|
52
|
+
cc: addresses(mail.cc),
|
|
53
|
+
replyTo: legacyList(addresses(mail.replyTo)),
|
|
54
|
+
subject: mail.subject ?? "",
|
|
55
|
+
date: headerValue(lineOf("date") ?? ":"),
|
|
56
|
+
textBody: text,
|
|
57
|
+
htmlBody: mail.html === false ? "" : mail.html,
|
|
58
|
+
// docs/05 §4.2: only a reply with a plain part gets one.
|
|
59
|
+
strippedTextReply: isReply ? (quote === null ? text : text.slice(0, quote.index)).trim() : "",
|
|
60
|
+
headers: mail.headerLines
|
|
61
|
+
.filter((h) => !OWN_FIELD.has(h.key))
|
|
62
|
+
.map((h) => ({ Name: h.line.slice(0, h.line.indexOf(":")), Value: headerValue(h.line) })),
|
|
63
|
+
attachments: mail.attachments.map((a) => ({
|
|
64
|
+
Name: a.filename ?? "",
|
|
65
|
+
Content: a.content.toString("base64"),
|
|
66
|
+
ContentType: a.contentType,
|
|
67
|
+
ContentLength: a.content.length,
|
|
68
|
+
ContentID: a.cid ?? "",
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Runtime } from "../runtime.ts";
|
|
2
|
+
import type { InboundMessage } from "../state/types.ts";
|
|
3
|
+
/** A message as it reaches Postmark's MX: its source, the SMTP envelope and SpamAssassin's verdict. */
|
|
4
|
+
export interface ReceivedMail {
|
|
5
|
+
mime: string;
|
|
6
|
+
/** RCPT TO addresses; a recipient outside the headers is a Bcc. */
|
|
7
|
+
rcptTo: string[];
|
|
8
|
+
spamScore: number;
|
|
9
|
+
spamTests: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Receives one mail: one inbound message per server it reaches, with that server's first inbound
|
|
13
|
+
* recipient as `OriginalRecipient` (INFERRED). A sender that matches an inbound rule, or a spam score
|
|
14
|
+
* above a set `InboundSpamThreshold`, blocks the message (docs/05 §4.1). Emits `inboundReceived` per
|
|
15
|
+
* message; the webhooks plugin posts every message that is not blocked.
|
|
16
|
+
*/
|
|
17
|
+
export declare function receiveInbound(runtime: Runtime, mail: ReceivedMail): Promise<InboundMessage[]>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { newMessageId } from "../state/ids.js";
|
|
2
|
+
import { legacyList, mailboxHash, parseMime } from "./parse.js";
|
|
3
|
+
/** Whether `email` is an inbound address of `server`: its hash address or its inbound domain. */
|
|
4
|
+
function isInboundAddress(server, email) {
|
|
5
|
+
const at = email.lastIndexOf("@");
|
|
6
|
+
const local = email.slice(0, at).toLowerCase().split("+")[0];
|
|
7
|
+
const domain = email.slice(at + 1).toLowerCase();
|
|
8
|
+
return ((domain === "inbound.postmarkapp.com" && local === server.InboundHash.toLowerCase()) ||
|
|
9
|
+
(server.InboundDomain !== "" && domain === server.InboundDomain.toLowerCase()));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Receives one mail: one inbound message per server it reaches, with that server's first inbound
|
|
13
|
+
* recipient as `OriginalRecipient` (INFERRED). A sender that matches an inbound rule, or a spam score
|
|
14
|
+
* above a set `InboundSpamThreshold`, blocks the message (docs/05 §4.1). Emits `inboundReceived` per
|
|
15
|
+
* message; the webhooks plugin posts every message that is not blocked.
|
|
16
|
+
*/
|
|
17
|
+
export async function receiveInbound(runtime, mail) {
|
|
18
|
+
const { state } = runtime.store;
|
|
19
|
+
const parsed = await parseMime(mail.mime);
|
|
20
|
+
const now = runtime.clock.now();
|
|
21
|
+
const spamHeaders = [
|
|
22
|
+
{ Name: "X-Spam-Status", Value: mail.spamScore > 5 ? "Yes" : "No" },
|
|
23
|
+
{ Name: "X-Spam-Score", Value: String(mail.spamScore) },
|
|
24
|
+
{ Name: "X-Spam-Tests", Value: mail.spamTests.join(",") },
|
|
25
|
+
];
|
|
26
|
+
const messages = [];
|
|
27
|
+
for (const server of state.servers.values()) {
|
|
28
|
+
const recipient = mail.rcptTo.find((r) => isInboundAddress(server, r));
|
|
29
|
+
const stream = [...state.streams.values()].find((s) => s.ServerID === server.ID && s.MessageStreamType === "Inbound");
|
|
30
|
+
if (recipient === undefined || stream === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const inHeaders = [...parsed.to, ...parsed.cc].some((a) => a.Email.toLowerCase() === recipient.toLowerCase());
|
|
33
|
+
// docs/05 §4.2 Bcc rules: the inbound address shows as Bcc only when no To or Cc names it.
|
|
34
|
+
const bcc = inHeaders
|
|
35
|
+
? []
|
|
36
|
+
: [{ Email: recipient, Name: "", MailboxHash: mailboxHash(recipient) }];
|
|
37
|
+
const blockedReason = blockReason(runtime, server, parsed.from.Email, mail.spamScore);
|
|
38
|
+
const message = {
|
|
39
|
+
MessageID: newMessageId(),
|
|
40
|
+
ServerID: server.ID,
|
|
41
|
+
MessageStream: stream.ID,
|
|
42
|
+
From: parsed.from.Email,
|
|
43
|
+
FromName: parsed.from.Name,
|
|
44
|
+
FromFull: parsed.from,
|
|
45
|
+
To: legacyList(parsed.to),
|
|
46
|
+
ToFull: parsed.to,
|
|
47
|
+
Cc: legacyList(parsed.cc),
|
|
48
|
+
CcFull: parsed.cc,
|
|
49
|
+
Bcc: legacyList(bcc),
|
|
50
|
+
BccFull: bcc,
|
|
51
|
+
OriginalRecipient: recipient,
|
|
52
|
+
ReplyTo: parsed.replyTo,
|
|
53
|
+
Subject: parsed.subject,
|
|
54
|
+
MailboxHash: mailboxHash(recipient),
|
|
55
|
+
Date: parsed.date,
|
|
56
|
+
TextBody: parsed.textBody,
|
|
57
|
+
HtmlBody: parsed.htmlBody,
|
|
58
|
+
StrippedTextReply: parsed.strippedTextReply,
|
|
59
|
+
Tag: "",
|
|
60
|
+
Headers: [...spamHeaders, ...parsed.headers],
|
|
61
|
+
Attachments: parsed.attachments,
|
|
62
|
+
Status: blockedReason === null ? "Queued" : "Blocked",
|
|
63
|
+
BlockedReason: blockedReason,
|
|
64
|
+
ReceivedAt: now,
|
|
65
|
+
rawEmail: mail.mime,
|
|
66
|
+
};
|
|
67
|
+
state.inbound.set(message.MessageID, message);
|
|
68
|
+
messages.push(message);
|
|
69
|
+
}
|
|
70
|
+
for (const message of messages)
|
|
71
|
+
await runtime.events.emit("inboundReceived", { message });
|
|
72
|
+
return messages;
|
|
73
|
+
}
|
|
74
|
+
/** Why a message is blocked, or null. The reason texts are INFERRED. */
|
|
75
|
+
function blockReason(runtime, server, from, spamScore) {
|
|
76
|
+
const sender = from.toLowerCase();
|
|
77
|
+
const rule = [...runtime.store.state.inboundRules.values()].find((r) => {
|
|
78
|
+
const value = r.Rule.toLowerCase();
|
|
79
|
+
return (r.ServerID === server.ID &&
|
|
80
|
+
(value.includes("@") ? sender === value : sender.slice(sender.lastIndexOf("@") + 1) === value));
|
|
81
|
+
});
|
|
82
|
+
if (rule !== undefined)
|
|
83
|
+
return `Blocked by inbound rule '${rule.Rule}'.`;
|
|
84
|
+
// A threshold of 0 blocks nothing (INFERRED).
|
|
85
|
+
if (server.InboundSpamThreshold > 0 && spamScore > server.InboundSpamThreshold) {
|
|
86
|
+
return `Spam score ${spamScore} is above the threshold ${server.InboundSpamThreshold}.`;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
package/dist/src/main.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { applyFlags, configFromEnv, USAGE } from "./config.js";
|
|
3
|
+
import { startPostmock } from "./server.js";
|
|
4
|
+
if (applyFlags(process.argv.slice(2), process.env) === "help") {
|
|
5
|
+
console.log(USAGE);
|
|
6
|
+
process.exit(0);
|
|
7
|
+
}
|
|
8
|
+
const config = configFromEnv(process.env);
|
|
9
|
+
const running = await startPostmock(config);
|
|
10
|
+
const urls = Object.entries(running.listeners).map(([name, url]) => `${name}=${url}`);
|
|
11
|
+
console.log(`postmock ${urls.join(" ")} seed=${config.seed}`);
|
|
12
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
13
|
+
process.once(signal, () => void running.close().then(() => process.exit(0)));
|
|
14
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** An address as the control API takes it: `a@example.com` or `{email, name}`. */
|
|
2
|
+
export type MailAddress = string | {
|
|
3
|
+
email: string;
|
|
4
|
+
name?: string | undefined;
|
|
5
|
+
};
|
|
6
|
+
/** A message a real sender could write (CONTROL-API.md `POST /control/inbound`). */
|
|
7
|
+
export interface MailFields {
|
|
8
|
+
from: MailAddress;
|
|
9
|
+
to: MailAddress[];
|
|
10
|
+
cc: MailAddress[];
|
|
11
|
+
replyTo?: MailAddress | undefined;
|
|
12
|
+
subject: string;
|
|
13
|
+
text?: string | undefined;
|
|
14
|
+
html?: string | undefined;
|
|
15
|
+
headers: Array<{
|
|
16
|
+
name: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}>;
|
|
19
|
+
attachments: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
/** Base64. */
|
|
22
|
+
content: string;
|
|
23
|
+
contentType: string;
|
|
24
|
+
contentId?: string | undefined;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
/** RFC 5322 date in UTC: `Thu, 05 Nov 2026 21:33:54 +0000`. */
|
|
28
|
+
export declare const rfc5322Date: (d: Date) => string;
|
|
29
|
+
/**
|
|
30
|
+
* Writes the MIME source a mail client would send for these fields. Inbound processing then parses
|
|
31
|
+
* it like any received mail, so both control API input forms take one path.
|
|
32
|
+
*/
|
|
33
|
+
export declare function composeMime(fields: MailFields, now: Date): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const ascii = (text) => /^[\x20-\x7e]*$/.test(text);
|
|
3
|
+
/** RFC 2047 encoded word for non-ASCII header text. */
|
|
4
|
+
const word = (text) => ascii(text) ? text : `=?UTF-8?B?${Buffer.from(text).toString("base64")}?=`;
|
|
5
|
+
const address = (a) => {
|
|
6
|
+
const { email, name } = typeof a === "string" ? { email: a, name: undefined } : a;
|
|
7
|
+
if (name === undefined || name === "")
|
|
8
|
+
return email;
|
|
9
|
+
return ascii(name)
|
|
10
|
+
? `"${name.replace(/["\\]/g, "\\$&")}" <${email}>`
|
|
11
|
+
: `${word(name)} <${email}>`;
|
|
12
|
+
};
|
|
13
|
+
const base64Lines = (content) => (Buffer.isBuffer(content) ? content : Buffer.from(content))
|
|
14
|
+
.toString("base64")
|
|
15
|
+
.replace(/.{76}/g, "$&\r\n");
|
|
16
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
17
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
18
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
19
|
+
/** RFC 5322 date in UTC: `Thu, 05 Nov 2026 21:33:54 +0000`. */
|
|
20
|
+
export const rfc5322Date = (d) => `${DAYS[d.getUTCDay()]}, ${two(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
|
|
21
|
+
`${two(d.getUTCHours())}:${two(d.getUTCMinutes())}:${two(d.getUTCSeconds())} +0000`;
|
|
22
|
+
/**
|
|
23
|
+
* Writes the MIME source a mail client would send for these fields. Inbound processing then parses
|
|
24
|
+
* it like any received mail, so both control API input forms take one path.
|
|
25
|
+
*/
|
|
26
|
+
export function composeMime(fields, now) {
|
|
27
|
+
const boundary = () => `postmock-${randomUUID()}`;
|
|
28
|
+
const textPart = (type, body) => `Content-Type: ${type}; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${base64Lines(body)}\r\n`;
|
|
29
|
+
const multipart = (subtype, parts) => {
|
|
30
|
+
const b = boundary();
|
|
31
|
+
return `Content-Type: multipart/${subtype}; boundary="${b}"\r\n\r\n${parts.map((p) => `--${b}\r\n${p}`).join("")}--${b}--\r\n`;
|
|
32
|
+
};
|
|
33
|
+
const bodies = [
|
|
34
|
+
...(fields.text !== undefined ? [textPart("text/plain", fields.text)] : []),
|
|
35
|
+
...(fields.html !== undefined ? [textPart("text/html", fields.html)] : []),
|
|
36
|
+
];
|
|
37
|
+
const content = bodies.length === 2
|
|
38
|
+
? multipart("alternative", bodies)
|
|
39
|
+
: (bodies[0] ?? textPart("text/plain", ""));
|
|
40
|
+
const attachments = fields.attachments.map((a) => `Content-Type: ${a.contentType}; name="${word(a.name)}"\r\n` +
|
|
41
|
+
`Content-Disposition: ${a.contentId === undefined ? "attachment" : "inline"}; filename="${word(a.name)}"\r\n` +
|
|
42
|
+
(a.contentId === undefined ? "" : `Content-ID: <${a.contentId}>\r\n`) +
|
|
43
|
+
`Content-Transfer-Encoding: base64\r\n\r\n${base64Lines(Buffer.from(a.content, "base64"))}\r\n`);
|
|
44
|
+
const given = (name) => fields.headers.some((h) => h.name.toLowerCase() === name);
|
|
45
|
+
const headers = [
|
|
46
|
+
`From: ${address(fields.from)}`,
|
|
47
|
+
...(fields.to.length > 0 ? [`To: ${fields.to.map(address).join(", ")}`] : []),
|
|
48
|
+
...(fields.cc.length > 0 ? [`Cc: ${fields.cc.map(address).join(", ")}`] : []),
|
|
49
|
+
...(fields.replyTo !== undefined ? [`Reply-To: ${address(fields.replyTo)}`] : []),
|
|
50
|
+
`Subject: ${word(fields.subject)}`,
|
|
51
|
+
...(given("date") ? [] : [`Date: ${rfc5322Date(now)}`]),
|
|
52
|
+
...(given("message-id") ? [] : [`Message-ID: <${randomUUID()}@postmock>`]),
|
|
53
|
+
"MIME-Version: 1.0",
|
|
54
|
+
...fields.headers.map((h) => `${h.name}: ${word(h.value)}`),
|
|
55
|
+
];
|
|
56
|
+
const body = attachments.length > 0 ? multipart("mixed", [content, ...attachments]) : content;
|
|
57
|
+
return `${headers.join("\r\n")}\r\n${body}`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Address } from "../state/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Splits a Postmark address list: commas outside double quotes separate entries; each entry is
|
|
4
|
+
* `addr` or `[name] <addr>` (docs/03 §1.8, INFERRED). Returns undefined for a malformed entry.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseAddressList(raw: string): Address[] | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a Postmark address list: commas outside double quotes separate entries; each entry is
|
|
3
|
+
* `addr` or `[name] <addr>` (docs/03 §1.8, INFERRED). Returns undefined for a malformed entry.
|
|
4
|
+
*/
|
|
5
|
+
export function parseAddressList(raw) {
|
|
6
|
+
const entries = [];
|
|
7
|
+
let current = "";
|
|
8
|
+
let quoted = false;
|
|
9
|
+
for (const char of raw) {
|
|
10
|
+
if (char === '"')
|
|
11
|
+
quoted = !quoted;
|
|
12
|
+
if (char === "," && !quoted) {
|
|
13
|
+
entries.push(current);
|
|
14
|
+
current = "";
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
current += char;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
entries.push(current);
|
|
21
|
+
const addresses = [];
|
|
22
|
+
for (const entry of entries.map((e) => e.trim())) {
|
|
23
|
+
if (entry === "")
|
|
24
|
+
continue;
|
|
25
|
+
const named = /^(.*?)\s*<([^<>\s]+@[^<>\s]+)>$/.exec(entry);
|
|
26
|
+
if (named?.[2] !== undefined) {
|
|
27
|
+
const name = (named[1] ?? "").replace(/^"(.*)"$/, "$1").replace(/\\"/g, '"');
|
|
28
|
+
addresses.push({ Email: named[2], Name: name === "" ? null : name });
|
|
29
|
+
}
|
|
30
|
+
else if (/^[^<>\s"]+@[^<>\s"]+$/.test(entry)) {
|
|
31
|
+
addresses.push({ Email: entry, Name: null });
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return addresses;
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ErrorBody } from "../errors.ts";
|
|
2
|
+
/** 406 for a single send: the SDK fixture text (docs/03 §3.2, docs/01 §4.1). */
|
|
3
|
+
export declare const inactiveRecipientsError: (addresses: readonly string[]) => ErrorBody;
|
|
4
|
+
/**
|
|
5
|
+
* A batch item carries its own 406 wording, with a trailing space (refs/api_email-api.md:301-313).
|
|
6
|
+
* Other errors are the same as on a single send.
|
|
7
|
+
*/
|
|
8
|
+
export declare function batchItemError(error: ErrorBody): ErrorBody;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { errorBody } from "../errors.js";
|
|
2
|
+
/** 406 for a single send: the SDK fixture text (docs/03 §3.2, docs/01 §4.1). */
|
|
3
|
+
export const inactiveRecipientsError = (addresses) => errorBody(406, { params: { addresses: addresses.join(", ") } });
|
|
4
|
+
// The gem regex (docs/01 §4.1): the strictest of the three SDK regexes.
|
|
5
|
+
const ADDRESSES = /Found inactive addresses: (.+?)\. Inactive/;
|
|
6
|
+
/**
|
|
7
|
+
* A batch item carries its own 406 wording, with a trailing space (refs/api_email-api.md:301-313).
|
|
8
|
+
* Other errors are the same as on a single send.
|
|
9
|
+
*/
|
|
10
|
+
export function batchItemError(error) {
|
|
11
|
+
if (error.ErrorCode !== 406)
|
|
12
|
+
return error;
|
|
13
|
+
const addresses = ADDRESSES.exec(error.Message)?.[1];
|
|
14
|
+
if (addresses === undefined)
|
|
15
|
+
throw new Error(`406 without an address list: ${error.Message}`);
|
|
16
|
+
return {
|
|
17
|
+
ErrorCode: 406,
|
|
18
|
+
Message: `You tried to send to a recipient that has been marked as inactive. Found inactive addresses: ${addresses}. Inactive recipients are ones that have generated a hard bounce, a spam complaint, or a manual suppression. `,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type ErrorBody } from "../errors.ts";
|
|
3
|
+
import type { ServerAuth } from "../http/routes.ts";
|
|
4
|
+
import type { Runtime } from "../runtime.ts";
|
|
5
|
+
import { type TestTokenContext } from "../state/servers.ts";
|
|
6
|
+
import type { Address, OutboundMessage } from "../state/types.ts";
|
|
7
|
+
/**
|
|
8
|
+
* One message exactly as its channel received it: `/email` and batch JSON values (T1), templates
|
|
9
|
+
* and bulk after render (T3), SMTP header text and MIME parts (T6). `validateOutbound` owns every
|
|
10
|
+
* Postmark data check (types, address syntax, required fields, limits, enums), so each ErrorCode has one
|
|
11
|
+
* source. `undefined` means the sender left the field out.
|
|
12
|
+
*/
|
|
13
|
+
export type OutboundDraft = Record<keyof ReturnType<typeof draftSchema>["shape"], unknown>;
|
|
14
|
+
/** Field shape checks, in draft field order. REST sends JSON types; SMTP sends header text (docs/07 §1.3). */
|
|
15
|
+
declare const draftSchema: (channel: "rest" | "smtp") => z.ZodObject<{
|
|
16
|
+
From: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
17
|
+
To: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
18
|
+
Cc: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
19
|
+
Bcc: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
20
|
+
ReplyTo: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
21
|
+
Subject: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
22
|
+
HtmlBody: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
23
|
+
TextBody: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
24
|
+
Tag: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
25
|
+
MessageStream: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
26
|
+
Headers: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
27
|
+
Name: z.ZodString;
|
|
28
|
+
Value: z.ZodString;
|
|
29
|
+
}, z.core.$strip>>>, unknown>;
|
|
30
|
+
Attachments: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
31
|
+
Name: z.ZodString;
|
|
32
|
+
Content: z.ZodPipe<z.ZodString, z.ZodTransform<Buffer<ArrayBuffer>, string>>;
|
|
33
|
+
ContentType: z.ZodString;
|
|
34
|
+
ContentID: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
35
|
+
}, z.core.$strip>>>, unknown>;
|
|
36
|
+
Metadata: z.ZodPreprocess<z.ZodOptional<z.ZodPreprocess<z.ZodRecord<z.ZodString, z.ZodString>, unknown>>, unknown>;
|
|
37
|
+
TrackOpens: z.ZodPreprocess<z.ZodOptional<z.ZodBoolean>, unknown> | z.ZodPreprocess<z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<boolean, string>>>, unknown>;
|
|
38
|
+
TrackLinks: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
|
|
39
|
+
HtmlAndText: "HtmlAndText";
|
|
40
|
+
HtmlOnly: "HtmlOnly";
|
|
41
|
+
None: "None";
|
|
42
|
+
TextOnly: "TextOnly";
|
|
43
|
+
}>>, unknown>;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
type Draft = z.output<ReturnType<typeof draftSchema>>;
|
|
46
|
+
/**
|
|
47
|
+
* The draft of one JSON message object. Keys match without case at every level, `ContentId` too
|
|
48
|
+
* (docs/08 R8); unknown keys are ignored (R12). Every JSON channel builds its draft here.
|
|
49
|
+
*/
|
|
50
|
+
export declare function draftFromJson(body: Record<string, unknown>): OutboundDraft;
|
|
51
|
+
/**
|
|
52
|
+
* A REST submission gets its MIME source from the pipeline, written from the draft. An SMTP
|
|
53
|
+
* submission carries `rawSource`: the delivered copy the dump endpoint serves.
|
|
54
|
+
*/
|
|
55
|
+
export type Submission = {
|
|
56
|
+
/** A test context is `POSTMARK_API_TEST`: validate, never store or deliver (docs/02 §3.3). */
|
|
57
|
+
auth: ServerAuth | TestTokenContext;
|
|
58
|
+
draft: OutboundDraft;
|
|
59
|
+
/** Request JSON (REST) or raw MIME (SMTP), kept for `GET /control/messages`. */
|
|
60
|
+
request: unknown;
|
|
61
|
+
bulkRequestId: string | null;
|
|
62
|
+
templateId: number | null;
|
|
63
|
+
} & ({
|
|
64
|
+
channel: "rest";
|
|
65
|
+
} | {
|
|
66
|
+
channel: "smtp";
|
|
67
|
+
rawSource: string;
|
|
68
|
+
});
|
|
69
|
+
export type SubmitResult = {
|
|
70
|
+
outcome: "accepted";
|
|
71
|
+
message: OutboundMessage;
|
|
72
|
+
}
|
|
73
|
+
/** Test token: valid, not stored. */
|
|
74
|
+
| {
|
|
75
|
+
outcome: "validated";
|
|
76
|
+
messageId: string;
|
|
77
|
+
submittedAt: Date;
|
|
78
|
+
} | {
|
|
79
|
+
outcome: "rejected";
|
|
80
|
+
error: ErrorBody;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Some recipients suppressed: the rest were accepted, the caller answers 406. INFERRED from an
|
|
84
|
+
* unverified integrator report (docs/04 §3.3, Q6).
|
|
85
|
+
*/
|
|
86
|
+
| {
|
|
87
|
+
outcome: "partiallySuppressed";
|
|
88
|
+
message: OutboundMessage;
|
|
89
|
+
error: ErrorBody;
|
|
90
|
+
};
|
|
91
|
+
/** A draft that passed every data check, ready for `acceptOutbound`. */
|
|
92
|
+
export interface ValidOutbound {
|
|
93
|
+
submission: Submission;
|
|
94
|
+
draft: Draft & {
|
|
95
|
+
From: string;
|
|
96
|
+
To: string;
|
|
97
|
+
};
|
|
98
|
+
streamId: string;
|
|
99
|
+
from: Address;
|
|
100
|
+
lists: Record<"To" | "Cc" | "Bcc" | "ReplyTo", Address[]>;
|
|
101
|
+
recipients: Address[];
|
|
102
|
+
}
|
|
103
|
+
export type Validation = {
|
|
104
|
+
outcome: "valid";
|
|
105
|
+
outbound: ValidOutbound;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `field` keys the bulk `Errors` map (refs/api_bulk-email.md:213-231). Which field Postmark
|
|
109
|
+
* names is documented for addresses only; the others are INFERRED.
|
|
110
|
+
*/
|
|
111
|
+
| {
|
|
112
|
+
outcome: "rejected";
|
|
113
|
+
field: string;
|
|
114
|
+
error: ErrorBody;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The checks of a send, with no state change: the data checks (field types, size, stream,
|
|
118
|
+
* addresses, recipients, content, limits, attachments) and the sender check. Account approval and
|
|
119
|
+
* suppressions belong to `acceptOutbound`. Throws `Unsupported` for uncaptured cases.
|
|
120
|
+
*/
|
|
121
|
+
export declare function validateOutbound(runtime: Runtime, submission: Submission): Validation;
|
|
122
|
+
/**
|
|
123
|
+
* Which error Postmark answers for a message that fails a data check and the sender check is not
|
|
124
|
+
* captured (docs/03 §3.4). A caller that answers a data error calls this first. The test token
|
|
125
|
+
* belongs to no account and has no sender check (sdk/postmark-gem/spec/integration/api_client_messages_spec.rb:5-10,
|
|
126
|
+
* :45-48; sdk/postmark-java/src/test/java/integration/MessageTest.java:27-36).
|
|
127
|
+
*/
|
|
128
|
+
export declare function refuseSenderAfterDataError(runtime: Runtime, auth: Submission["auth"], rawFrom: unknown): void;
|
|
129
|
+
/**
|
|
130
|
+
* Stores every accepted message, then emits `sent` for each, in order. A `sent` listener that
|
|
131
|
+
* changes state (archives a stream, adds a suppression) cannot affect a later message of the
|
|
132
|
+
* same request.
|
|
133
|
+
*/
|
|
134
|
+
export declare function acceptOutbounds(runtime: Runtime, outbounds: readonly ValidOutbound[]): Promise<SubmitResult[]>;
|
|
135
|
+
export declare function acceptOutbound(runtime: Runtime, outbound: ValidOutbound): Promise<SubmitResult>;
|
|
136
|
+
/** `validateOutbound`, then `acceptOutbound` for a valid draft. */
|
|
137
|
+
export declare function submitOutbound(runtime: Runtime, submission: Submission): Promise<SubmitResult>;
|
|
138
|
+
export {};
|