@midsummer-ai/sdk 0.0.0-stage → 0.1.0-next.1305
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 +177 -0
- package/README.md +4 -2
- package/agents/contract.d.mts +9 -0
- package/agents/contract.mjs +221 -0
- package/agents/index.d.mts +128 -0
- package/agents/index.mjs +203 -0
- package/agents/stand-in.d.mts +35 -0
- package/agents/stand-in.mjs +149 -0
- package/ai/contract.d.mts +10 -0
- package/ai/contract.mjs +77 -0
- package/ai/index.d.mts +94 -0
- package/ai/index.mjs +193 -0
- package/ai/stand-in.d.mts +7 -0
- package/ai/stand-in.mjs +41 -0
- package/build-SQ44jwSa.d.mts +41 -0
- package/client.d.mts +45 -0
- package/client.mjs +106 -0
- package/connection-CxD_Nry5.mjs +1911 -0
- package/contract.d.mts +10 -0
- package/contract.mjs +469 -0
- package/database/build.d.mts +2 -0
- package/database/build.mjs +579 -0
- package/database/client.d.mts +60 -0
- package/database/client.mjs +133 -0
- package/database/contract.d.mts +11 -0
- package/database/contract.mjs +164 -0
- package/database/errors.d.mts +23 -0
- package/database/errors.mjs +13 -0
- package/database/server.d.mts +3 -0
- package/database/server.mjs +320 -0
- package/database/stand-in.d.mts +99 -0
- package/database/stand-in.mjs +430 -0
- package/email/client.d.mts +34 -0
- package/email/client.mjs +131 -0
- package/email/contract.d.mts +11 -0
- package/email/contract.mjs +99 -0
- package/email/index.d.mts +74 -0
- package/email/index.mjs +59 -0
- package/email/smtp.d.mts +22 -0
- package/email/smtp.mjs +296 -0
- package/email/stand-in.d.mts +13 -0
- package/email/stand-in.mjs +51 -0
- package/index-DdoK0y7D.d.mts +192 -0
- package/index-sgdyoA2P.d.mts +212 -0
- package/index.d.mts +2 -0
- package/index.mjs +137 -0
- package/names-DfCBKupJ.mjs +115 -0
- package/package.json +152 -4
- package/predict-Drfm_8pO.mjs +179 -0
- package/queues/contract.d.mts +13 -0
- package/queues/contract.mjs +278 -0
- package/queues/index.d.mts +184 -0
- package/queues/index.mjs +126 -0
- package/queues/stand-in.d.mts +7 -0
- package/queues/stand-in.mjs +110 -0
- package/queues/wire.d.mts +9 -0
- package/queues/wire.mjs +199 -0
- package/s3.d.mts +43 -0
- package/s3.mjs +1003 -0
- package/schema-BF-Y1EVu.mjs +142 -0
- package/schema-DS8x1Dn8.mjs +115 -0
- package/schema-f7E9uzUn.d.mts +74 -0
- package/server-C7O7CICU.d.mts +182 -0
- package/socket.d.mts +67 -0
- package/socket.mjs +263 -0
- package/stand-in.d.mts +7 -0
- package/stand-in.mjs +185 -0
- package/store-DDuOr-_F.mjs +237 -0
- package/tabs-4FLTfg1c.mjs +457 -0
- package/vector/contract.d.mts +10 -0
- package/vector/contract.mjs +635 -0
- package/vector/index.d.mts +2 -0
- package/vector/index.mjs +108 -0
- package/vector/stand-in.d.mts +11 -0
- package/vector/stand-in.mjs +169 -0
- package/vector/wire.d.mts +25 -0
- package/vector/wire.mjs +175 -0
- package/wire.d.mts +54 -0
- package/wire.mjs +731 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Email } from "./index.mjs";
|
|
2
|
+
//#region src/email/contract.d.ts
|
|
3
|
+
declare const CONTRACT_DOMAIN = "mail.acme.dev";
|
|
4
|
+
declare const CONTRACT_SUPPRESSED = "suppressed@example.com";
|
|
5
|
+
interface ContractCase {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
run(email: Email): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
declare const emailContract: ReadonlyArray<ContractCase>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { CONTRACT_DOMAIN, CONTRACT_SUPPRESSED, ContractCase, emailContract };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { EmailError } from "./index.mjs";
|
|
2
|
+
//#region src/email/contract.ts
|
|
3
|
+
/**
|
|
4
|
+
* Email's contract: what every engine promises, as cases any test runner can run. The Stand-in runs
|
|
5
|
+
* them in this package; the real Capability runs the same cases against a Project set up with the
|
|
6
|
+
* fixture: `mail.acme.dev` a verified Sending domain, and `suppressed@example.com` suppressed.
|
|
7
|
+
*
|
|
8
|
+
* for (const c of emailContract) test(c.name, () => c.run(email(standIn({ domains: [CONTRACT_DOMAIN], suppressed: [CONTRACT_SUPPRESSED] }))))
|
|
9
|
+
*/
|
|
10
|
+
const CONTRACT_DOMAIN = "mail.acme.dev";
|
|
11
|
+
const CONTRACT_SUPPRESSED = "suppressed@example.com";
|
|
12
|
+
const emailContract = [
|
|
13
|
+
{
|
|
14
|
+
name: "a Message from a verified Sending domain is sent, and the same key sends it once",
|
|
15
|
+
async run(email) {
|
|
16
|
+
const message = {
|
|
17
|
+
from: "Acme <hi@mail.acme.dev>",
|
|
18
|
+
to: "ada@example.com",
|
|
19
|
+
subject: "Welcome",
|
|
20
|
+
text: "Hello",
|
|
21
|
+
key: "welcome-ada"
|
|
22
|
+
};
|
|
23
|
+
const first = await email.send(message);
|
|
24
|
+
equal(first.state, "sent", "the state");
|
|
25
|
+
equal(first.suppressed, [], "the suppressed");
|
|
26
|
+
equal((await email.send(message)).id, first.id, "the id of the same key sent again");
|
|
27
|
+
if ((await email.send({
|
|
28
|
+
...message,
|
|
29
|
+
key: "welcome-ada-2"
|
|
30
|
+
})).id === first.id) throw new Error("another key answered the first Message");
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "a Message from an address off a verified Sending domain is refused as unverified",
|
|
35
|
+
async run(email) {
|
|
36
|
+
await refuses(() => email.send({
|
|
37
|
+
from: "hi@acme.dev",
|
|
38
|
+
to: "ada@example.com",
|
|
39
|
+
subject: "Hi",
|
|
40
|
+
text: "Hello"
|
|
41
|
+
}), "unverified");
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "a suppressed recipient is left out and named; a Message with nobody left is suppressed",
|
|
46
|
+
async run(email) {
|
|
47
|
+
const some = await email.send({
|
|
48
|
+
from: "hi@mail.acme.dev",
|
|
49
|
+
to: ["Suppressed@example.com", "ada@example.com"],
|
|
50
|
+
subject: "Hi",
|
|
51
|
+
text: "Hello"
|
|
52
|
+
});
|
|
53
|
+
equal(some.state, "sent", "the state with someone left");
|
|
54
|
+
equal(some.suppressed, ["suppressed@example.com"], "the suppressed");
|
|
55
|
+
equal((await email.send({
|
|
56
|
+
from: "hi@mail.acme.dev",
|
|
57
|
+
to: "suppressed@example.com",
|
|
58
|
+
subject: "Hi",
|
|
59
|
+
text: "Hello"
|
|
60
|
+
})).state, "suppressed", "the state with nobody left");
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "a Message that is not one is refused as invalid before it is sent",
|
|
65
|
+
async run(email) {
|
|
66
|
+
await refuses(() => email.send({
|
|
67
|
+
from: "hi@mail.acme.dev",
|
|
68
|
+
to: "not an address",
|
|
69
|
+
subject: "Hi",
|
|
70
|
+
text: "Hello"
|
|
71
|
+
}), "invalid");
|
|
72
|
+
await refuses(() => email.send({
|
|
73
|
+
from: "hi@mail.acme.dev",
|
|
74
|
+
to: [],
|
|
75
|
+
subject: "Hi",
|
|
76
|
+
text: "Hello"
|
|
77
|
+
}), "invalid");
|
|
78
|
+
await refuses(() => email.send({
|
|
79
|
+
from: "hi@mail.acme.dev",
|
|
80
|
+
to: "ada@example.com",
|
|
81
|
+
subject: "Hi"
|
|
82
|
+
}), "invalid");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
function equal(actual, expected, what) {
|
|
87
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
88
|
+
}
|
|
89
|
+
async function refuses(call, code) {
|
|
90
|
+
try {
|
|
91
|
+
await call();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof EmailError && error.code === code) return;
|
|
94
|
+
throw new Error(`expected ${code}, got ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`expected ${code}, but it was sent`);
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
export { CONTRACT_DOMAIN, CONTRACT_SUPPRESSED, emailContract };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//#region src/email/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Email from a Project's code: Messages sent from its verified Sending domains.
|
|
4
|
+
*
|
|
5
|
+
* const mail = email(client("https://api.<site>", { key })) // an Email key, from `midsummer email key create`
|
|
6
|
+
* const sent = await mail.send({ from: "Acme <hi@mail.acme.dev>", to: "ada@example.com", subject: "Welcome", text: "Hello", key: "welcome-ada" })
|
|
7
|
+
*
|
|
8
|
+
* `engine` is where Messages go: the Stand-in (`@midsummer-ai/sdk/email/stand-in`) for `midsummer dev`,
|
|
9
|
+
* or the real Capability through `@midsummer-ai/sdk/email/client`. Every engine is held to the cases in
|
|
10
|
+
* `@midsummer-ai/sdk/email/contract`.
|
|
11
|
+
*
|
|
12
|
+
* Addresses and the Message's shape are checked here, before anything leaves the caller. Reuse a
|
|
13
|
+
* Message's `key` to retry while the original Message is retained for thirty days. After that
|
|
14
|
+
* Message is purged, the same key can send another Message. A suppressed recipient (one that bounced
|
|
15
|
+
* or complained) is left out and named in `suppressed`; a Message with nobody left is `suppressed`.
|
|
16
|
+
* Every failure is an `EmailError`, whose `code` tells you which kind.
|
|
17
|
+
*
|
|
18
|
+
* Delivery events reach a Project's webhooks signed; `verifyWebhook` checks a delivery.
|
|
19
|
+
*/
|
|
20
|
+
type EmailErrorCode = "invalid" | "unverified" | "forbidden" | "unavailable";
|
|
21
|
+
declare class EmailError extends Error {
|
|
22
|
+
readonly code: EmailErrorCode;
|
|
23
|
+
constructor(code: EmailErrorCode, message: string);
|
|
24
|
+
}
|
|
25
|
+
/** An address is `ada@example.com` or `Ada <ada@example.com>`. */
|
|
26
|
+
interface OutgoingMessage {
|
|
27
|
+
readonly from: string;
|
|
28
|
+
readonly to: string | ReadonlyArray<string>;
|
|
29
|
+
readonly cc?: ReadonlyArray<string>;
|
|
30
|
+
readonly bcc?: ReadonlyArray<string>;
|
|
31
|
+
readonly replyTo?: string;
|
|
32
|
+
readonly subject: string;
|
|
33
|
+
readonly text?: string;
|
|
34
|
+
readonly html?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Reuse the key to retry while the original Message is retained for thirty days. After that
|
|
37
|
+
* Message is purged, the same key can send another Message.
|
|
38
|
+
*/
|
|
39
|
+
readonly key?: string;
|
|
40
|
+
}
|
|
41
|
+
/** A Message as the engine took it: `to` always a list. */
|
|
42
|
+
interface CheckedMessage extends Omit<OutgoingMessage, "to"> {
|
|
43
|
+
readonly to: ReadonlyArray<string>;
|
|
44
|
+
}
|
|
45
|
+
interface SentMessage {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
/** `queued` while the send is still under way; `failed` when it was refused, with `error`. */
|
|
48
|
+
readonly state: "sent" | "queued" | "suppressed" | "failed";
|
|
49
|
+
readonly suppressed: ReadonlyArray<string>;
|
|
50
|
+
readonly error?: string;
|
|
51
|
+
}
|
|
52
|
+
/** Where Messages go: the Seam under `email()`. The Stand-in and the real Capability are its Adapters. */
|
|
53
|
+
interface EmailEngine {
|
|
54
|
+
send(message: CheckedMessage): Promise<SentMessage>;
|
|
55
|
+
}
|
|
56
|
+
interface Email {
|
|
57
|
+
send(message: OutgoingMessage): Promise<SentMessage>;
|
|
58
|
+
}
|
|
59
|
+
/** The bare address of `Ada <ada@acme.dev>` or `ada@acme.dev`, lowercase; `invalid` when it is none. */
|
|
60
|
+
declare function addressOf(value: string): string;
|
|
61
|
+
/** At most this many recipients, to, cc and bcc together. */
|
|
62
|
+
declare const MAX_RECIPIENTS = 50;
|
|
63
|
+
declare function email(engine: EmailEngine): Email;
|
|
64
|
+
/**
|
|
65
|
+
* Whether a webhook delivery is genuine: its `Midsummer-Signature` header (`t=<ms>,v1=<hex>`) is the
|
|
66
|
+
* HMAC-SHA256 of `<t>.<body>` under the webhook's secret, made within `toleranceMs` (five minutes) of
|
|
67
|
+
* `now`. Pass the body exactly as it arrived. Deliveries are at least once: dedupe by `Midsummer-Event-Id`.
|
|
68
|
+
*/
|
|
69
|
+
declare function verifyWebhook(secret: string, signature: string | null, body: string, options?: {
|
|
70
|
+
readonly now?: number;
|
|
71
|
+
readonly toleranceMs?: number;
|
|
72
|
+
}): Promise<boolean>;
|
|
73
|
+
//#endregion
|
|
74
|
+
export { CheckedMessage, Email, EmailEngine, EmailError, EmailErrorCode, MAX_RECIPIENTS, OutgoingMessage, SentMessage, addressOf, email, verifyWebhook };
|
package/email/index.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/email/index.ts
|
|
2
|
+
var EmailError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "EmailError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
const ADDRESS = /^[^\s@<>()",;:]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i;
|
|
11
|
+
/** The bare address of `Ada <ada@acme.dev>` or `ada@acme.dev`, lowercase; `invalid` when it is none. */
|
|
12
|
+
function addressOf(value) {
|
|
13
|
+
const bare = (/<([^<>]+)>\s*$/.exec(value)?.[1] ?? value).trim().toLowerCase();
|
|
14
|
+
if (!ADDRESS.test(bare) || bare.length > 254) throw new EmailError("invalid", `${value} is not an email address`);
|
|
15
|
+
return bare;
|
|
16
|
+
}
|
|
17
|
+
/** At most this many recipients, to, cc and bcc together. */
|
|
18
|
+
const MAX_RECIPIENTS = 50;
|
|
19
|
+
function email(engine) {
|
|
20
|
+
return { async send(message) {
|
|
21
|
+
const to = typeof message.to === "string" ? [message.to] : [...message.to];
|
|
22
|
+
if (!to.length) throw new EmailError("invalid", "a Message needs someone to send to");
|
|
23
|
+
const all = [
|
|
24
|
+
message.from,
|
|
25
|
+
...to,
|
|
26
|
+
...message.cc ?? [],
|
|
27
|
+
...message.bcc ?? [],
|
|
28
|
+
...message.replyTo ? [message.replyTo] : []
|
|
29
|
+
];
|
|
30
|
+
all.forEach(addressOf);
|
|
31
|
+
if (all.length - 1 - (message.replyTo ? 1 : 0) > 50) throw new EmailError("invalid", `a Message goes to at most 50 recipients`);
|
|
32
|
+
if (!message.subject.trim()) throw new EmailError("invalid", "a Message needs a subject");
|
|
33
|
+
if (message.text === void 0 && message.html === void 0) throw new EmailError("invalid", "a Message needs text or html");
|
|
34
|
+
return engine.send({
|
|
35
|
+
...message,
|
|
36
|
+
to
|
|
37
|
+
});
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
/**
|
|
42
|
+
* Whether a webhook delivery is genuine: its `Midsummer-Signature` header (`t=<ms>,v1=<hex>`) is the
|
|
43
|
+
* HMAC-SHA256 of `<t>.<body>` under the webhook's secret, made within `toleranceMs` (five minutes) of
|
|
44
|
+
* `now`. Pass the body exactly as it arrived. Deliveries are at least once: dedupe by `Midsummer-Event-Id`.
|
|
45
|
+
*/
|
|
46
|
+
async function verifyWebhook(secret, signature, body, options = {}) {
|
|
47
|
+
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signature ?? "");
|
|
48
|
+
if (!match) return false;
|
|
49
|
+
const at = Number(match[1]);
|
|
50
|
+
if (Math.abs((options.now ?? Date.now()) - at) > (options.toleranceMs ?? 3e5)) return false;
|
|
51
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
|
|
52
|
+
name: "HMAC",
|
|
53
|
+
hash: "SHA-256"
|
|
54
|
+
}, false, ["verify"]);
|
|
55
|
+
const given = new Uint8Array(match[2].match(/../g).map((pair) => parseInt(pair, 16)));
|
|
56
|
+
return crypto.subtle.verify("HMAC", key, given, encoder.encode(`${at}.${body}`));
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { EmailError, MAX_RECIPIENTS, addressOf, email, verifyWebhook };
|
package/email/smtp.d.mts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CheckedMessage, SentMessage } from "./index.mjs";
|
|
2
|
+
//#region src/email/smtp.d.ts
|
|
3
|
+
/** A `send` that hands each message to Email at `origin` (the API's), with the client's key. */
|
|
4
|
+
declare function relayTo(origin: string, options?: {
|
|
5
|
+
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
6
|
+
}): (key: string, message: CheckedMessage) => Promise<SentMessage>;
|
|
7
|
+
declare function serveSmtp(options: {
|
|
8
|
+
readonly port: number;
|
|
9
|
+
readonly hostname?: string;
|
|
10
|
+
/** The name it greets with. */
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
readonly send: (key: string, message: CheckedMessage) => Promise<SentMessage>;
|
|
13
|
+
readonly tls?: {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly cert: string;
|
|
16
|
+
};
|
|
17
|
+
}): Promise<{
|
|
18
|
+
readonly port: number;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { relayTo, serveSmtp };
|
package/email/smtp.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { EmailError, email } from "./index.mjs";
|
|
2
|
+
import { client } from "./client.mjs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
4
|
+
import { createServer as createServer$1 } from "node:tls";
|
|
5
|
+
//#region src/email/smtp.ts
|
|
6
|
+
/**
|
|
7
|
+
* Email for clients that speak only SMTP: a relay that takes mail by SMTP, signed in with an Email key
|
|
8
|
+
* as the password (username `midsummer`), and sends each message through Email's wire, as the SDK's
|
|
9
|
+
* client does. It keeps nothing, so any number of them can run anywhere that can reach the API.
|
|
10
|
+
*
|
|
11
|
+
* const relay = await serveSmtp({ port: 587, send: relayTo("https://api.<site>"), tls: { key, cert } })
|
|
12
|
+
*
|
|
13
|
+
* It speaks EHLO, AUTH PLAIN and LOGIN, MAIL, RCPT, DATA, RSET, NOOP and QUIT. With `tls` every
|
|
14
|
+
* connection is TLS from its first byte (implicit TLS, as on port 465); without it, AUTH is taken only
|
|
15
|
+
* from this computer, so a key never crosses a network in the clear. The message's `To` and `Cc`
|
|
16
|
+
* recipients that the envelope names are its to and cc, and the envelope's others its bcc (a message
|
|
17
|
+
* naming none of them goes to each blind recipient alone); its `Message-ID` is the Message's key, so a client sending it again sends it once. Its text and HTML
|
|
18
|
+
* parts are read from plain, multipart, quoted-printable and base64 bodies.
|
|
19
|
+
*
|
|
20
|
+
* Email's refusals come back as SMTP replies: an unknown key `535`, a sender off a verified Sending
|
|
21
|
+
* domain `550`, anything not a Message `554`, and Email out of reach `451`, which a client tries again.
|
|
22
|
+
*/
|
|
23
|
+
/** At most this many bytes of message. */
|
|
24
|
+
const MAX_BYTES = 2e6;
|
|
25
|
+
/** A `send` that hands each message to Email at `origin` (the API's), with the client's key. */
|
|
26
|
+
function relayTo(origin, options = {}) {
|
|
27
|
+
return (key, message) => email(client(origin, {
|
|
28
|
+
key,
|
|
29
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
30
|
+
})).send(message);
|
|
31
|
+
}
|
|
32
|
+
async function serveSmtp(options) {
|
|
33
|
+
const name = options.name ?? "midsummer";
|
|
34
|
+
const handle = (socket) => session(socket, {
|
|
35
|
+
name,
|
|
36
|
+
send: options.send,
|
|
37
|
+
secure: Boolean(options.tls)
|
|
38
|
+
});
|
|
39
|
+
const server = options.tls ? createServer$1({
|
|
40
|
+
key: options.tls.key,
|
|
41
|
+
cert: options.tls.cert
|
|
42
|
+
}, handle) : createServer(handle);
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
server.once("error", reject);
|
|
45
|
+
server.listen(options.port, options.hostname ?? "127.0.0.1", () => resolve());
|
|
46
|
+
});
|
|
47
|
+
const address = server.address();
|
|
48
|
+
return {
|
|
49
|
+
port: typeof address === "object" && address ? address.port : options.port,
|
|
50
|
+
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Whether a connection is from this computer, where a key may cross without TLS. */
|
|
54
|
+
const local = (socket) => [
|
|
55
|
+
"127.0.0.1",
|
|
56
|
+
"::1",
|
|
57
|
+
"::ffff:127.0.0.1"
|
|
58
|
+
].includes(socket.remoteAddress ?? "");
|
|
59
|
+
function session(socket, options) {
|
|
60
|
+
let buffer = Buffer.alloc(0);
|
|
61
|
+
let key;
|
|
62
|
+
let from;
|
|
63
|
+
let recipients = [];
|
|
64
|
+
let data;
|
|
65
|
+
let size = 0;
|
|
66
|
+
let login;
|
|
67
|
+
let user = "";
|
|
68
|
+
let busy = Promise.resolve();
|
|
69
|
+
const reply = (line) => {
|
|
70
|
+
socket.write(`${line}\r\n`);
|
|
71
|
+
};
|
|
72
|
+
const reset = () => {
|
|
73
|
+
from = void 0;
|
|
74
|
+
recipients = [];
|
|
75
|
+
data = void 0;
|
|
76
|
+
size = 0;
|
|
77
|
+
};
|
|
78
|
+
reply(`220 ${options.name} ESMTP ready`);
|
|
79
|
+
const signIn = (user, password) => {
|
|
80
|
+
if (user !== "midsummer" || !password) return reply("535 5.7.8 Sign in as midsummer with an Email key as the password.");
|
|
81
|
+
key = password;
|
|
82
|
+
reply("235 2.7.0 Signed in.");
|
|
83
|
+
};
|
|
84
|
+
async function finish(raw) {
|
|
85
|
+
try {
|
|
86
|
+
const all = [];
|
|
87
|
+
for (const message of parse(raw, from, recipients)) all.push(await options.send(key, message));
|
|
88
|
+
const failed = all.find((sent) => sent.state === "failed");
|
|
89
|
+
const suppressed = all.flatMap((sent) => sent.suppressed);
|
|
90
|
+
reply(failed ? `554 5.6.0 ${failed.error ?? "The Message was refused."}` : `250 2.0.0 ${all.map((sent) => `${sent.id} ${sent.state}`).join(", ")}${suppressed.length ? `, suppressed ${suppressed.join(" ")}` : ""}`);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const text = error instanceof Error ? error.message.replace(/[\r\n]+/g, " ") : "The Message was refused.";
|
|
93
|
+
if (!(error instanceof EmailError)) reply(`451 4.3.0 ${text}`);
|
|
94
|
+
else reply(`${{
|
|
95
|
+
forbidden: "535 5.7.8",
|
|
96
|
+
unverified: "550 5.7.1",
|
|
97
|
+
invalid: "554 5.6.0",
|
|
98
|
+
unavailable: "451 4.3.0"
|
|
99
|
+
}[error.code]} ${text}`);
|
|
100
|
+
}
|
|
101
|
+
reset();
|
|
102
|
+
}
|
|
103
|
+
async function command(line) {
|
|
104
|
+
if (data) {
|
|
105
|
+
if (line === ".") {
|
|
106
|
+
const raw = Buffer.concat(data);
|
|
107
|
+
data = void 0;
|
|
108
|
+
if (size > MAX_BYTES) {
|
|
109
|
+
reset();
|
|
110
|
+
return reply(`552 5.3.4 A message is at most ${MAX_BYTES} bytes.`);
|
|
111
|
+
}
|
|
112
|
+
return finish(raw);
|
|
113
|
+
}
|
|
114
|
+
const content = Buffer.from(`${line.startsWith(".") ? line.slice(1) : line}\r\n`, "latin1");
|
|
115
|
+
size += content.length;
|
|
116
|
+
if (size <= MAX_BYTES) data.push(content);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (login) {
|
|
120
|
+
const value = Buffer.from(line, "base64").toString("utf8");
|
|
121
|
+
if (login === "plain") {
|
|
122
|
+
login = void 0;
|
|
123
|
+
const [, user = "", password = ""] = value.split("\0");
|
|
124
|
+
return signIn(user, password);
|
|
125
|
+
}
|
|
126
|
+
if (login === "user") {
|
|
127
|
+
login = "pass";
|
|
128
|
+
user = value;
|
|
129
|
+
return reply("334 UGFzc3dvcmQ6");
|
|
130
|
+
}
|
|
131
|
+
login = void 0;
|
|
132
|
+
return signIn(user, value);
|
|
133
|
+
}
|
|
134
|
+
const [verb = "", ...rest] = line.split(" ");
|
|
135
|
+
const argument = rest.join(" ");
|
|
136
|
+
switch (verb.toUpperCase()) {
|
|
137
|
+
case "EHLO": return reply([
|
|
138
|
+
`250-${options.name}`,
|
|
139
|
+
...options.secure || local(socket) ? ["250-AUTH PLAIN LOGIN"] : [],
|
|
140
|
+
`250-SIZE ${MAX_BYTES}`,
|
|
141
|
+
"250-8BITMIME",
|
|
142
|
+
"250 SMTPUTF8"
|
|
143
|
+
].join("\r\n"));
|
|
144
|
+
case "HELO": return reply(`250 ${options.name}`);
|
|
145
|
+
case "AUTH": {
|
|
146
|
+
if (!options.secure && !local(socket)) return reply("538 5.7.11 Sign in only over TLS.");
|
|
147
|
+
const [mechanism = "", initial] = argument.split(" ");
|
|
148
|
+
if (mechanism.toUpperCase() === "PLAIN") {
|
|
149
|
+
if (initial) {
|
|
150
|
+
const [, user = "", password = ""] = Buffer.from(initial, "base64").toString("utf8").split("\0");
|
|
151
|
+
return signIn(user, password);
|
|
152
|
+
}
|
|
153
|
+
login = "plain";
|
|
154
|
+
return reply("334 ");
|
|
155
|
+
}
|
|
156
|
+
if (mechanism.toUpperCase() === "LOGIN") {
|
|
157
|
+
login = "user";
|
|
158
|
+
return reply("334 VXNlcm5hbWU6");
|
|
159
|
+
}
|
|
160
|
+
return reply("504 5.5.4 Use AUTH PLAIN or LOGIN.");
|
|
161
|
+
}
|
|
162
|
+
case "MAIL": {
|
|
163
|
+
if (!key) return reply("530 5.7.0 Sign in first, with an Email key.");
|
|
164
|
+
const address = /^FROM:\s*<([^>]*)>/i.exec(argument)?.[1];
|
|
165
|
+
if (address === void 0) return reply("501 5.5.4 Say MAIL FROM:<address>.");
|
|
166
|
+
reset();
|
|
167
|
+
from = address;
|
|
168
|
+
return reply("250 2.1.0 OK");
|
|
169
|
+
}
|
|
170
|
+
case "RCPT": {
|
|
171
|
+
if (from === void 0) return reply("503 5.5.1 Say MAIL FROM first.");
|
|
172
|
+
const address = /^TO:\s*<([^>]+)>/i.exec(argument)?.[1];
|
|
173
|
+
if (!address) return reply("501 5.5.4 Say RCPT TO:<address>.");
|
|
174
|
+
if (recipients.length >= 50) return reply(`452 4.5.3 At most 50 recipients.`);
|
|
175
|
+
recipients.push(address);
|
|
176
|
+
return reply("250 2.1.5 OK");
|
|
177
|
+
}
|
|
178
|
+
case "DATA":
|
|
179
|
+
if (!recipients.length) return reply("503 5.5.1 Say RCPT TO first.");
|
|
180
|
+
data = [];
|
|
181
|
+
return reply("354 End the message with a line holding only a dot.");
|
|
182
|
+
case "RSET":
|
|
183
|
+
reset();
|
|
184
|
+
return reply("250 2.0.0 OK");
|
|
185
|
+
case "NOOP": return reply("250 2.0.0 OK");
|
|
186
|
+
case "QUIT":
|
|
187
|
+
reply("221 2.0.0 Bye");
|
|
188
|
+
socket.end();
|
|
189
|
+
return;
|
|
190
|
+
default: return reply("502 5.5.2 Not a command here.");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
socket.on("data", (chunk) => {
|
|
194
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
195
|
+
for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) {
|
|
196
|
+
const line = buffer.subarray(0, end).toString("latin1").replace(/\r$/, "");
|
|
197
|
+
buffer = buffer.subarray(end + 1);
|
|
198
|
+
busy = busy.then(() => command(line)).catch(() => {
|
|
199
|
+
socket.destroy();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
socket.on("error", () => socket.destroy());
|
|
204
|
+
}
|
|
205
|
+
/** A header's value with its encoded words (RFC 2047) decoded. */
|
|
206
|
+
function words(value) {
|
|
207
|
+
return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=\s*(?==\?)?/g, (_, charset, encoding, text) => {
|
|
208
|
+
return decode(encoding.toUpperCase() === "B" ? Buffer.from(text, "base64") : Buffer.from(text.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(parseInt(h, 16))), "latin1"), charset);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function decode(bytes, charset = "utf-8") {
|
|
212
|
+
try {
|
|
213
|
+
return new TextDecoder(charset).decode(bytes);
|
|
214
|
+
} catch {
|
|
215
|
+
return new TextDecoder().decode(bytes);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** A part's headers, unfolded and keyed lowercase, and its body's bytes. */
|
|
219
|
+
function split(raw) {
|
|
220
|
+
const text = raw.toString("latin1");
|
|
221
|
+
const at = text.search(/\r?\n\r?\n/);
|
|
222
|
+
const head = at < 0 ? text : text.slice(0, at);
|
|
223
|
+
const body = at < 0 ? "" : text.slice(at).replace(/^\r?\n\r?\n/, "");
|
|
224
|
+
const headers = /* @__PURE__ */ new Map();
|
|
225
|
+
for (const line of head.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
|
|
226
|
+
const colon = line.indexOf(":");
|
|
227
|
+
if (colon > 0) headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
headers,
|
|
231
|
+
body: Buffer.from(body, "latin1")
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** A parameter of a header such as Content-Type: `boundary`, `charset`. */
|
|
235
|
+
const parameter = (header, name) => new RegExp(`;\\s*${name}="?([^";]+)"?`, "i").exec(header)?.[1];
|
|
236
|
+
/** The text and HTML of a part, by its content type, through multiparts. */
|
|
237
|
+
function bodies(headers, body, found) {
|
|
238
|
+
const type = headers.get("content-type") ?? "text/plain";
|
|
239
|
+
if (/^multipart\//i.test(type)) {
|
|
240
|
+
const boundary = parameter(type, "boundary");
|
|
241
|
+
if (!boundary) return;
|
|
242
|
+
for (const piece of body.toString("latin1").split(`--${boundary}`).slice(1)) {
|
|
243
|
+
if (piece.startsWith("--")) break;
|
|
244
|
+
const part = split(Buffer.from(piece.replace(/^\r?\n/, ""), "latin1"));
|
|
245
|
+
bodies(part.headers, part.body, found);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const encoding = (headers.get("content-transfer-encoding") ?? "7bit").toLowerCase();
|
|
250
|
+
const content = decode(encoding === "base64" ? Buffer.from(body.toString("latin1").replace(/\s+/g, ""), "base64") : encoding === "quoted-printable" ? Buffer.from(body.toString("latin1").replace(/=\r?\n/g, "").replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1") : body, parameter(type, "charset")).replace(/\r\n$/, "");
|
|
251
|
+
if (/^text\/html/i.test(type)) found.html ??= content;
|
|
252
|
+
else if (/^text\/plain/i.test(type)) found.text ??= content;
|
|
253
|
+
}
|
|
254
|
+
/** The addresses of a header such as To: `Ada <ada@example.com>, grace@example.com`. */
|
|
255
|
+
function addresses(value) {
|
|
256
|
+
if (!value) return [];
|
|
257
|
+
return (value.match(/(?:"[^"]*"|<[^>]*>|[^,])+/g) ?? []).map((item) => (/<([^>]+)>/.exec(item)?.[1] ?? item).trim().toLowerCase()).filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* A message as Email takes it, from what the client sent and its envelope. The envelope's recipients
|
|
261
|
+
* its `To` and `Cc` name are its to and cc, and the rest its bcc. A Message has someone in to, so one
|
|
262
|
+
* whose headers name nobody of the envelope (every recipient blind) goes as one Message per recipient,
|
|
263
|
+
* each to that recipient alone, so none sees another's address; with only Cc named, those are its to.
|
|
264
|
+
*/
|
|
265
|
+
function parse(raw, envelope, recipients) {
|
|
266
|
+
const { headers, body } = split(raw);
|
|
267
|
+
const found = {};
|
|
268
|
+
bodies(headers, body, found);
|
|
269
|
+
const bare = [...new Set(recipients.map((address) => address.toLowerCase()))];
|
|
270
|
+
const named = (header) => addresses(headers.get(header)).filter((address) => bare.includes(address));
|
|
271
|
+
const to = named("to"), cc = named("cc").filter((address) => !to.includes(address));
|
|
272
|
+
const bcc = bare.filter((address) => !to.includes(address) && !cc.includes(address));
|
|
273
|
+
const replyTo = headers.get("reply-to");
|
|
274
|
+
const id = headers.get("message-id");
|
|
275
|
+
const common = {
|
|
276
|
+
from: words(headers.get("from") ?? envelope),
|
|
277
|
+
...replyTo ? { replyTo: words(replyTo) } : {},
|
|
278
|
+
subject: words(headers.get("subject") ?? "") || "(no subject)",
|
|
279
|
+
...found.text !== void 0 ? { text: found.text } : {},
|
|
280
|
+
...found.html !== void 0 ? { html: found.html } : {}
|
|
281
|
+
};
|
|
282
|
+
if (!to.length && !cc.length) return bcc.map((address) => ({
|
|
283
|
+
...common,
|
|
284
|
+
to: [address],
|
|
285
|
+
...id ? { key: `smtp:${id}:${address}` } : {}
|
|
286
|
+
}));
|
|
287
|
+
return [{
|
|
288
|
+
...common,
|
|
289
|
+
to: to.length ? to : cc,
|
|
290
|
+
...to.length && cc.length ? { cc } : {},
|
|
291
|
+
...bcc.length ? { bcc } : {},
|
|
292
|
+
...id ? { key: `smtp:${id}` } : {}
|
|
293
|
+
}];
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
export { relayTo, serveSmtp };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CheckedMessage, EmailEngine } from "./index.mjs";
|
|
2
|
+
//#region src/email/stand-in.d.ts
|
|
3
|
+
declare function standIn(options: {
|
|
4
|
+
readonly domains: ReadonlyArray<string>;
|
|
5
|
+
readonly suppressed?: ReadonlyArray<string>;
|
|
6
|
+
}): EmailEngine & {
|
|
7
|
+
readonly sent: ReadonlyArray<CheckedMessage & {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
}>;
|
|
10
|
+
suppress(address: string): void;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { standIn };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { EmailError, addressOf } from "./index.mjs";
|
|
2
|
+
//#region src/email/stand-in.ts
|
|
3
|
+
/**
|
|
4
|
+
* Email's Stand-in: the Capability's behaviour in memory, for `midsummer dev` and tests. It sends
|
|
5
|
+
* nothing anywhere; what it would send is kept in `sent`. It passes the contract
|
|
6
|
+
* (`@midsummer-ai/sdk/email/contract`) except durability and delivery.
|
|
7
|
+
*
|
|
8
|
+
* const engine = standIn({ domains: ["mail.acme.dev"] })
|
|
9
|
+
* await email(engine).send({ from: "hi@mail.acme.dev", to: "ada@example.com", subject: "Hi", text: "Hello" })
|
|
10
|
+
* engine.sent // what was sent
|
|
11
|
+
*
|
|
12
|
+
* `domains` are the verified Sending domains; `suppressed` the addresses no longer sent to.
|
|
13
|
+
*/
|
|
14
|
+
function standIn(options) {
|
|
15
|
+
const domains = new Set(options.domains.map((name) => name.toLowerCase()));
|
|
16
|
+
const suppressed = new Set((options.suppressed ?? []).map(addressOf));
|
|
17
|
+
const sent = [];
|
|
18
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
19
|
+
let count = 0;
|
|
20
|
+
return {
|
|
21
|
+
sent,
|
|
22
|
+
suppress: (address) => {
|
|
23
|
+
suppressed.add(addressOf(address));
|
|
24
|
+
},
|
|
25
|
+
async send(message) {
|
|
26
|
+
const from = addressOf(message.from);
|
|
27
|
+
if (!domains.has(from.slice(from.indexOf("@") + 1))) throw new EmailError("unverified", `${from} is not on a verified Sending domain`);
|
|
28
|
+
const earlier = message.key !== void 0 ? byKey.get(message.key) : void 0;
|
|
29
|
+
if (earlier) return earlier;
|
|
30
|
+
const recipients = [...new Set([
|
|
31
|
+
...message.to,
|
|
32
|
+
...message.cc ?? [],
|
|
33
|
+
...message.bcc ?? []
|
|
34
|
+
].map(addressOf))];
|
|
35
|
+
const left = recipients.filter((address) => !suppressed.has(address));
|
|
36
|
+
const answer = {
|
|
37
|
+
id: `msg_${++count}`,
|
|
38
|
+
state: left.length ? "sent" : "suppressed",
|
|
39
|
+
suppressed: recipients.filter((address) => suppressed.has(address))
|
|
40
|
+
};
|
|
41
|
+
if (left.length) sent.push({
|
|
42
|
+
...message,
|
|
43
|
+
id: answer.id
|
|
44
|
+
});
|
|
45
|
+
if (message.key !== void 0) byKey.set(message.key, answer);
|
|
46
|
+
return answer;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { standIn };
|