@volter/world-core 3.0.164 → 3.0.166

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.
Files changed (60) hide show
  1. package/attach.cjs +7 -3
  2. package/dist/attach.cjs +7 -3
  3. package/dist/generated/pack-facts.json +44 -3660
  4. package/dist/pack-facts.cjs +4 -4
  5. package/dist/src/browser.d.ts +4 -0
  6. package/dist/src/browser.js +10 -0
  7. package/dist/src/checkout.d.ts +4 -0
  8. package/dist/src/checkout.js +13 -0
  9. package/dist/src/derived-core.d.ts +45 -5
  10. package/dist/src/derived-core.js +53 -8
  11. package/dist/src/derived-real.js +144 -2
  12. package/dist/src/derived.d.ts +1 -1
  13. package/dist/src/executor.d.ts +2 -2
  14. package/dist/src/executor.js +13 -0
  15. package/dist/src/git/index.d.ts +1 -0
  16. package/dist/src/git/index.js +1 -0
  17. package/dist/src/git/smart-http-client.d.ts +28 -0
  18. package/dist/src/git/smart-http-client.js +95 -0
  19. package/dist/src/git/smart-http.d.ts +3 -0
  20. package/dist/src/git/smart-http.js +9 -0
  21. package/dist/src/head.d.ts +16 -1
  22. package/dist/src/head.js +52 -7
  23. package/dist/src/html.d.ts +29 -0
  24. package/dist/src/html.js +64 -0
  25. package/dist/src/index.d.ts +3 -1
  26. package/dist/src/index.js +3 -0
  27. package/dist/src/pack-fetch.js +41 -2
  28. package/dist/src/pack-paths.d.ts +2 -3
  29. package/dist/src/pack-paths.js +3 -13
  30. package/dist/src/packRegistry.d.ts +17 -1
  31. package/dist/src/runtime.d.ts +2 -0
  32. package/dist/src/runtime.js +3 -0
  33. package/dist/src/serve-http.d.ts +1 -1
  34. package/dist/src/serve-http.js +11 -2
  35. package/dist/src/smtp-server.d.ts +78 -0
  36. package/dist/src/smtp-server.js +160 -0
  37. package/dist/src/smtp.d.ts +29 -11
  38. package/dist/src/smtp.js +221 -52
  39. package/generated/pack-facts.json +44 -3660
  40. package/pack-facts.cjs +4 -4
  41. package/package.json +8 -2
  42. package/src/browser.ts +10 -0
  43. package/src/checkout.ts +12 -0
  44. package/src/derived-core.ts +68 -12
  45. package/src/derived-real.ts +121 -2
  46. package/src/derived.ts +2 -2
  47. package/src/executor.ts +13 -2
  48. package/src/git/index.ts +1 -0
  49. package/src/git/smart-http-client.ts +99 -0
  50. package/src/git/smart-http.ts +7 -0
  51. package/src/head.ts +37 -6
  52. package/src/html.ts +72 -0
  53. package/src/index.ts +4 -1
  54. package/src/pack-fetch.ts +35 -2
  55. package/src/pack-paths.ts +3 -11
  56. package/src/packRegistry.ts +9 -1
  57. package/src/runtime.ts +3 -0
  58. package/src/serve-http.ts +11 -3
  59. package/src/smtp-server.ts +166 -0
  60. package/src/smtp.ts +199 -53
@@ -0,0 +1,160 @@
1
+ /** A connection's bytes framed as SMTP frames them, each piece posted to the pack's front (`smtpFront`) through its
2
+ * fetch with the session the connection carries: commands are lines ended by CRLF; after a 354, the message's lines up
3
+ * to the lone dot, their leading dot removed; after a 334, one line of AUTH response. Pipelined commands are answered
4
+ * in order. */
5
+ export function smtpStream(fetch, bridge) {
6
+ return (sink, peer) => {
7
+ const encoder = new TextEncoder();
8
+ const decoder = new TextDecoder();
9
+ let buffer = '';
10
+ let session = bridge.fresh();
11
+ let mode = 'command';
12
+ let data = [];
13
+ let closed = false;
14
+ let chain = Promise.resolve();
15
+ const post = async (body) => {
16
+ if (closed)
17
+ return;
18
+ let a;
19
+ try {
20
+ const res = await fetch(new Request('http://smtp.wire/', { method: 'POST', headers: { 'content-type': 'application/json', [bridge.header]: bridge.key(), 'x-forwarded-for': peer }, body: JSON.stringify({ session, ...body }) }));
21
+ a = res.ok ? (await res.json()) : bridge.failure(session);
22
+ if (!Array.isArray(a.replies) || !a.session || !['command', 'data', 'auth'].includes(a.mode))
23
+ a = bridge.failure(session);
24
+ }
25
+ catch {
26
+ a = bridge.failure(session);
27
+ }
28
+ session = a.session;
29
+ mode = a.mode;
30
+ sink.write(encoder.encode(a.replies.map((r) => `${r}\r\n`).join('')));
31
+ if (a.close) {
32
+ closed = true;
33
+ sink.end();
34
+ }
35
+ };
36
+ const line = async (text) => {
37
+ if (mode === 'data') {
38
+ if (text === '.') {
39
+ const message = data.join('\r\n') + (data.length ? '\r\n' : '');
40
+ data = [];
41
+ mode = 'command';
42
+ await post({ data: message });
43
+ return;
44
+ }
45
+ data.push(text.startsWith('.') ? text.slice(1) : text);
46
+ return;
47
+ }
48
+ if (mode === 'auth') {
49
+ await post({ response: text });
50
+ return;
51
+ }
52
+ await post({ line: decoder.decode(Uint8Array.from(text, (c) => c.charCodeAt(0))) });
53
+ };
54
+ const pump = () => {
55
+ let at;
56
+ while ((at = buffer.indexOf('\r\n')) >= 0) {
57
+ const text = buffer.slice(0, at);
58
+ buffer = buffer.slice(at + 2);
59
+ chain = chain.then(() => line(text));
60
+ }
61
+ };
62
+ chain = chain.then(() => post({ open: true }));
63
+ return {
64
+ data(chunk) { if (closed)
65
+ return; buffer += Array.from(chunk, (b) => String.fromCharCode(b)).join(''); pump(); },
66
+ settled: () => chain,
67
+ close() { closed = true; },
68
+ };
69
+ };
70
+ }
71
+ /** The pack's front (its `around`): a request the stream signed with the pack's key is a piece of a connection, answered
72
+ * by the pack's server; any other request is the pack's own (its doors and screens). */
73
+ export async function smtpFront(ctx, next, bridge, server) {
74
+ const request = ctx.call.request;
75
+ if (!request.headers.has(bridge.header))
76
+ return next();
77
+ if (request.headers.get(bridge.header) !== bridge.key())
78
+ return new Response('not the server\'s wire', { status: 403 });
79
+ const w = (ctx.body ?? {});
80
+ const s = w.session ?? bridge.fresh();
81
+ if (w.open)
82
+ return Response.json(await server.open());
83
+ if (w.data !== undefined)
84
+ return Response.json(await server.data(s, w.data));
85
+ if (w.response !== undefined && server.response)
86
+ return Response.json(await server.response(s, w.response));
87
+ return Response.json(await server.command(s, String(w.line ?? '')));
88
+ }
89
+ /** A command's verb (upper-cased) and its argument. */
90
+ export function smtpCommand(line) {
91
+ const [verb = '', ...rest] = line.split(' ');
92
+ return { verb: verb.toUpperCase(), arg: rest.join(' ') };
93
+ }
94
+ /** The session machine's word on a command from where a session stands (the manifest's states): a refusal's reply, or
95
+ * undefined to go on. */
96
+ export function smtpOrder(ctx, machine, stage, op, to) {
97
+ const refusal = ctx.legal(machine, 'stage', op, stage, to);
98
+ return refusal ? `${refusal.status} ${String(refusal.code)} ${refusal.message}` : undefined;
99
+ }
100
+ /** EHLO's multi-line 250 reply: the server's name, then each keyword. */
101
+ // source: RFC 5321 §4.1.1.1 "ehlo-ok-rsp"
102
+ export const smtpEhlo = (keywords) => keywords.map((k, i) => `250${i === keywords.length - 1 ? ' ' : '-'}${k}`);
103
+ /** An address in angle brackets (`<a@b>`, `<>` for the null sender), or undefined when it is not one. */
104
+ function smtpAddress(arg) {
105
+ const m = /^<([^<>\s]*)>$/.exec(arg.trim());
106
+ if (!m)
107
+ return undefined;
108
+ const a = m[1];
109
+ if (a === '')
110
+ return '';
111
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(a) ? a : undefined;
112
+ }
113
+ /** An address's domain, lower-cased. */
114
+ export const smtpDomain = (address) => address.slice(address.lastIndexOf('@') + 1).toLowerCase();
115
+ /** MAIL's argument: the sender and its SIZE, BODY and SMTPUTF8 parameters, or why it is refused (the pack words it). */
116
+ // source: RFC 5321 §4.1.1.2 "MAIL FROM:<reverse-path> [SP <mail-parameters> ] <CRLF>"
117
+ export function smtpMailFrom(arg) {
118
+ const m = /^FROM:\s*(<[^>]*>)((?:\s+\S+)*)\s*$/i.exec(arg);
119
+ if (!m)
120
+ return { refused: 'syntax' };
121
+ const from = smtpAddress(m[1]);
122
+ if (from === undefined)
123
+ return { refused: 'sender' };
124
+ let size;
125
+ let smtputf8 = false;
126
+ for (const parameter of m[2].trim().split(/\s+/).filter(Boolean)) {
127
+ const [k = '', v] = parameter.split('=');
128
+ if (k.toUpperCase() === 'SIZE') {
129
+ if (!/^\d+$/.test(v ?? ''))
130
+ return { refused: 'size' };
131
+ size = Number(v);
132
+ }
133
+ else if (k.toUpperCase() === 'BODY' && ['7BIT', '8BITMIME'].includes((v ?? '').toUpperCase()))
134
+ continue;
135
+ else if (k.toUpperCase() === 'SMTPUTF8' && v === undefined)
136
+ smtputf8 = true;
137
+ else
138
+ return { refused: 'parameter', parameter };
139
+ }
140
+ return { from, ...(size !== undefined ? { size } : {}), smtputf8 };
141
+ }
142
+ /** RCPT's argument: the recipient, or why it is refused (the pack words it). */
143
+ // source: RFC 5321 §4.1.1.3 "RCPT TO:<forward-path> [ SP <rcpt-parameters> ] <CRLF>"
144
+ export function smtpRcptTo(arg) {
145
+ const m = /^TO:\s*(<[^>]*>)(?:\s+\S+)*\s*$/i.exec(arg);
146
+ if (!m)
147
+ return { refused: 'syntax' };
148
+ // source: RFC 5321 §4.1.1.11 "command, it will return code 555."
149
+ if (/^TO:\s*<[^>]*>\s+\S/i.test(arg))
150
+ return { refused: 'parameter', parameter: arg.slice(arg.indexOf('>') + 1).trim() };
151
+ const to = smtpAddress(m[1]);
152
+ return to ? { to } : { refused: 'recipient' };
153
+ }
154
+ /** A transaction's state cleared, the greeting kept (RSET, a new EHLO, the end of DATA): the session's `stage`,
155
+ * `greeted` and an empty `rcpt`, with whatever of the session the pack keeps across transactions (`kept`). */
156
+ // source: RFC 5321 §4.1.1.5 "This command specifies that the current mail transaction will be aborted."
157
+ export function smtpCleared(s, stage, kept) {
158
+ const greeted = stage === 'helo' || stage === 'ehlo' ? stage : s.greeted;
159
+ return { ...kept, stage, rcpt: [], ...(greeted ? { greeted } : {}) };
160
+ }
@@ -1,16 +1,34 @@
1
- /** Where a mail goes: the SMTP host and port, and the sender. */
2
- export type SmtpRoute = {
3
- host: string;
4
- port: number;
1
+ import type { TwinStream } from './twin-fetch.js';
2
+ /** One mail, from its sender to its recipients: the sender's own message bytes, or a vendor's HTML mail the kernel
3
+ * composes (GoTrue's links). */
4
+ export type Mail = {
5
5
  from: string;
6
+ to: string[];
7
+ } & ({
8
+ raw: Uint8Array | string;
9
+ } | {
6
10
  senderName?: string;
7
- };
8
- /** One mail: its recipient, subject and HTML body. */
9
- export type Mail = {
10
- to: string;
11
11
  subject: string;
12
12
  html: string;
13
+ headers?: Record<string, string>;
14
+ });
15
+ /** One recipient's answer: the receiving server's reply to its `RCPT`, or to the message when it accepted the
16
+ * recipient; or the kernel's own refusal when no server receives the recipient's domain. */
17
+ export type MailReply = {
18
+ to: string;
19
+ code: number;
20
+ reply: string;
21
+ };
22
+ /** A mail server this process answers itself (a journey walk's companion over the walk's own World): its SMTP side
23
+ * port's byte-stream door (`create<Name>TwinStream`), for the domains `receives` names (its host rules). */
24
+ type InProcessMailServer = {
25
+ vendor: string;
26
+ receives: (domain: string) => boolean;
27
+ stream: TwinStream;
13
28
  };
14
- /** Send one mail. Throws when it cannot be delivered (refused by the World's egress rule, unreachable, or refused by the
15
- * server), so the vendor answers its own mailer failure. */
16
- export declare function sendMail(route: SmtpRoute, mail: Mail, headers?: Record<string, string>): Promise<void>;
29
+ /** The mail servers this process answers itself, consulted before the instance's; returns those set before, which the
30
+ * caller puts back. */
31
+ export declare function setInProcessMailServers(servers: ReadonlyArray<InProcessMailServer>): ReadonlyArray<InProcessMailServer>;
32
+ /** Hand one mail to each recipient's mailbox provider; every recipient's reply, in the order given. */
33
+ export declare function sendMail(mail: Mail): Promise<MailReply[]>;
34
+ export {};
package/dist/src/smtp.js CHANGED
@@ -1,73 +1,242 @@
1
- // A vendor's mail to a person, sent over SMTP (plain, no authentication) to the host the vendor's settings name — in a
2
- // World, the World's mail twin — as the vendor's own mailer sends it (GoTrue's confirmation and magic links). A handler
3
- // reaches it as `ctx.mail`. The World's egress rule is applied before the socket opens: a sealed World or one with a
4
- // network policy refuses a host outside it; a local World with neither lets a configured outside host be reached, as it
5
- // lets the application reach one. The socket is opened only when a mail is sent (nothing at import).
1
+ // A vendor's mail handed to each recipient's mailbox provider (ADR 0032 §3), as a real relay or a real Resend hands mail
2
+ // to the server of the recipient's domain. A handler reaches it as `ctx.mail`. The recipient's domain decides the server:
3
+ // the World's twin whose descriptor names that domain (its host rules) and opens an SMTP side port (`sidePorts`, scheme
4
+ // `smtp`), at the listener this boot recorded for it (`sides.<name>` in the instance file), or, for a twin this process
5
+ // answers itself (a journey walk's companion, `setInProcessMailServers`), on its byte-stream door in process, as
6
+ // vendorFetch reaches an in-process twin (vendor-call.ts). A domain no twin names is
7
+ // refused here, never sent out of the World: a reserved name (RFC 6761: `.test`, `.example`, `.invalid`, `.localhost`) as
8
+ // RFC 5321's non-existent domain, any other as a destination the World cannot route to. Every recipient gets its own
9
+ // reply; nothing throws for a refusal. The socket is opened only when a mail is sent (nothing at import).
10
+ import { twinOrigins, vendorsOfHost } from '../vendor-hosts.cjs';
11
+ let inProcessMailServers = [];
12
+ /** The mail servers this process answers itself, consulted before the instance's; returns those set before, which the
13
+ * caller puts back. */
14
+ export function setInProcessMailServers(servers) {
15
+ const previous = inProcessMailServers;
16
+ inProcessMailServers = servers;
17
+ return previous;
18
+ }
19
+ /** A connection to a server's listener. */
20
+ async function socketWire(server) {
21
+ const { connect } = await import('node:net');
22
+ const socket = connect(server.port, server.host);
23
+ socket.setEncoding('latin1');
24
+ socket.setTimeout(SMTP_TIMEOUT_MS, () => socket.destroy(new Error(`no answer in ${SMTP_TIMEOUT_MS} ms`)));
25
+ const failed = new Promise((_, reject) => { socket.once('error', reject); socket.once('close', () => reject(new Error('connection closed'))); });
26
+ failed.catch(() => { });
27
+ return {
28
+ label: `${server.host}:${server.port}`, failed, close: () => socket.destroy(),
29
+ ready: new Promise((resolve) => { socket.once('connect', () => resolve()); }),
30
+ write: (data) => { socket.write(Buffer.from(data, 'latin1')); },
31
+ onData: (fn) => { socket.on('data', fn); },
32
+ };
33
+ }
34
+ /** A connection to a server this process answers itself: its byte-stream door, handed the bytes as a listener would. */
35
+ function streamWire(server) {
36
+ let deliver;
37
+ let held = '';
38
+ let end;
39
+ const failed = new Promise((_, reject) => { end = reject; });
40
+ failed.catch(() => { });
41
+ // the door is opened before the timer starts, so a door that throws as it opens leaves nothing behind
42
+ const connection = server.stream({
43
+ write: (bytes) => { const text = Buffer.from(bytes).toString('latin1'); if (deliver)
44
+ deliver(text);
45
+ else
46
+ held += text; },
47
+ end: () => end(new Error('connection closed')),
48
+ }, '127.0.0.1');
49
+ const timer = setTimeout(() => end(new Error(`no answer in ${SMTP_TIMEOUT_MS} ms`)), SMTP_TIMEOUT_MS);
50
+ return {
51
+ label: `${server.vendor} (in process)`, failed, ready: Promise.resolve(),
52
+ close: () => { clearTimeout(timer); connection.close(); },
53
+ write: (data) => connection.data(new Uint8Array(Buffer.from(data, 'latin1'))),
54
+ onData: (fn) => { deliver = fn; if (held) {
55
+ const text = held;
56
+ held = '';
57
+ fn(text);
58
+ } },
59
+ };
60
+ }
61
+ /** The client's side of one connection. It owns every reply the server sends from the moment it reads the wire: a reply
62
+ * that arrives before anything awaits it (a greeting a server sends as it opens) is kept, in order, for the next read. */
6
63
  class SmtpSession {
7
- socket;
64
+ wire;
8
65
  buffer = '';
9
66
  waiting = [];
10
- constructor(socket) {
11
- this.socket = socket;
12
- socket.setEncoding('utf8');
13
- socket.on('data', (chunk) => {
67
+ arrived = [];
68
+ constructor(wire) {
69
+ this.wire = wire;
70
+ wire.onData((chunk) => {
14
71
  this.buffer += chunk;
15
72
  let end;
16
73
  // a reply is complete at a line whose code is followed by a space (multi-line replies use a dash)
17
- while ((end = this.buffer.search(/^\d{3} .*\r?\n/m)) >= 0) {
74
+ while ((end = this.buffer.search(/^\d{3}(?: .*)?\r?\n/m)) >= 0) {
18
75
  const lineEnd = this.buffer.indexOf('\n', end);
19
- const reply = this.buffer.slice(0, lineEnd + 1);
76
+ const text = this.buffer.slice(0, lineEnd + 1).trim();
20
77
  this.buffer = this.buffer.slice(lineEnd + 1);
21
- this.waiting.shift()?.(reply.trim().split(/\r?\n/).pop());
78
+ const reply = { code: Number(text.split(/\r?\n/).pop().slice(0, 3)), text: text.split(/\r?\n/).join('\n') };
79
+ const reader = this.waiting.shift();
80
+ if (reader)
81
+ reader(reply);
82
+ else
83
+ this.arrived.push(reply);
22
84
  }
23
85
  });
24
86
  }
25
- reply() { return new Promise((resolve) => this.waiting.push(resolve)); }
26
- async command(line, expect) {
87
+ reply() {
88
+ const held = this.arrived.shift();
89
+ return held ? Promise.resolve(held) : new Promise((resolve) => this.waiting.push(resolve));
90
+ }
91
+ send(data) {
27
92
  const next = this.reply();
28
- this.socket.write(`${line}\r\n`);
29
- const reply = await next;
30
- if (!reply.startsWith(String(expect)))
31
- throw new Error(`SMTP ${line.split(' ')[0]}: ${reply}`);
93
+ this.wire.write(data);
94
+ return next;
32
95
  }
33
96
  }
34
- /** How long a mail may take, connect to QUIT (a host that never answers must not hold the request open). */
97
+ /** How long one server may take, connect to QUIT (a twin that never answers must not hold the request open). */
35
98
  const SMTP_TIMEOUT_MS = 10_000;
36
- /** Send one mail. Throws when it cannot be delivered (refused by the World's egress rule, unreachable, or refused by the
37
- * server), so the vendor answers its own mailer failure. */
38
- export async function sendMail(route, mail, headers = {}) {
39
- const [{ connect }, { worldEgressRefusal }] = await Promise.all([
40
- import('node:net'),
41
- import('../network-policy.cjs'),
42
- ]);
43
- // sending mail is a write: an external relay needs a `writes` grant
44
- const refusal = worldEgressRefusal(`http://${route.host}:${route.port}/`, process.env, 'SMTP');
45
- if (refusal !== null)
46
- throw new Error(refusal);
47
- const socket = connect(route.port, route.host);
48
- socket.setTimeout(SMTP_TIMEOUT_MS, () => socket.destroy(new Error(`SMTP ${route.host}:${route.port}: no answer in ${SMTP_TIMEOUT_MS} ms`)));
49
- const session = new SmtpSession(socket);
99
+ /** RFC 6761's names, for which DNS answers no such domain. */
100
+ const RESERVED = /(^|\.)(test|example|invalid|localhost)$/;
101
+ const domainOf = (address) => address.slice(address.lastIndexOf('@') + 1).toLowerCase().replace(/\.$/, '');
102
+ /** The SMTP server of the World's twin that receives mail for `domain`, if one runs: one this process answers itself,
103
+ * else the listener the instance records. */
104
+ async function mailServerOf(domain) {
105
+ const local = inProcessMailServers.find((server) => server.receives(domain));
106
+ if (local)
107
+ return local;
108
+ const { packFacts } = await import('../pack-facts.cjs');
109
+ const packs = packFacts().packs;
110
+ const receiving = vendorsOfHost(domain).flatMap((vendor) => {
111
+ const side = packs[vendor]?.sidePorts?.find((s) => s.scheme === 'smtp');
112
+ return side ? [{ vendor, side: side.name }] : [];
113
+ });
114
+ if (!receiving.length)
115
+ return undefined;
116
+ const file = process.env.VOLTER_WORLD_INSTANCE;
117
+ if (!file)
118
+ return undefined;
119
+ let instance;
120
+ try {
121
+ const { readFile } = await import('node:fs/promises');
122
+ instance = JSON.parse(await readFile(file, 'utf8'));
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ const twins = { ...twinOrigins(process.env), ...twinOrigins(instance.env ?? {}) };
128
+ for (const { vendor, side } of receiving) {
129
+ const origin = twins[vendor];
130
+ const service = origin === undefined ? undefined : Object.values(instance.services ?? {}).find((s) => s.url?.replace(/\/$/, '') === origin);
131
+ const at = service?.sides?.[side]?.url;
132
+ if (at) {
133
+ const url = new URL(at);
134
+ return { host: url.hostname, port: Number(url.port) };
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+ /** The message's bytes as SMTP carries them after DATA: CRLF line ends, leading dots doubled, ended by `.`. */
140
+ function dataOf(mail, to) {
141
+ let text;
142
+ if ('raw' in mail)
143
+ text = typeof mail.raw === 'string' ? Buffer.from(mail.raw, 'utf8').toString('latin1') : Buffer.from(mail.raw).toString('latin1');
144
+ else {
145
+ const from = mail.senderName ? `"${mail.senderName.replace(/"/g, '')}" <${mail.from}>` : mail.from;
146
+ text = Buffer.from([
147
+ `From: ${from}`, `To: ${to.join(', ')}`, `Subject: ${mail.subject}`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=utf-8',
148
+ ...Object.entries(mail.headers ?? {}).map(([k, v]) => `${k}: ${v}`), '', mail.html,
149
+ ].join('\r\n'), 'utf8').toString('latin1');
150
+ }
151
+ text = text.replace(/\r?\n/g, '\r\n');
152
+ if (!text.endsWith('\r\n'))
153
+ text += '\r\n';
154
+ return `${text.replace(/(^|\r\n)\./g, '$1..')}.\r\n`;
155
+ }
156
+ /** One SMTP transaction with one server, for the recipients at its domains. */
157
+ async function transfer(server, mail, recipients) {
158
+ const replies = new Map();
159
+ // a server whose connection cannot be opened answers every recipient a temporary failure (451), as a refused connect
160
+ let wire;
161
+ try {
162
+ wire = 'stream' in server ? streamWire(server) : await socketWire(server);
163
+ }
164
+ catch (error) {
165
+ const label = 'stream' in server ? `${server.vendor} (in process)` : `${server.host}:${server.port}`;
166
+ return recipients.map((to) => ({ to, code: 451, reply: `451 4.4.1 ${label}: ${error instanceof Error ? error.message : String(error)}` }));
167
+ }
168
+ const session = new SmtpSession(wire);
50
169
  const greeting = session.reply();
51
- const failed = new Promise((_, reject) => { socket.once('error', reject); socket.once('close', () => reject(new Error('SMTP connection closed'))); });
52
- failed.catch(() => { });
53
- const step = (p) => Promise.race([p, failed]);
54
- await step(new Promise((resolve) => { socket.once('connect', () => resolve()); }));
170
+ const step = (p) => Promise.race([p, wire.failed]);
171
+ const all = (reply) => { for (const to of recipients)
172
+ if (!replies.has(to))
173
+ replies.set(to, reply); };
55
174
  try {
56
- if (!(await step(greeting)).startsWith('220'))
57
- throw new Error('SMTP: no greeting');
58
- await step(session.command(`EHLO ${route.from.split('@')[1] ?? 'localhost'}`, 250));
59
- await step(session.command(`MAIL FROM:<${route.from}>`, 250));
60
- await step(session.command(`RCPT TO:<${mail.to}>`, 250));
61
- await step(session.command('DATA', 354));
62
- const from = route.senderName ? `"${route.senderName.replace(/"/g, '')}" <${route.from}>` : route.from;
63
- const lines = [
64
- `From: ${from}`, `To: ${mail.to}`, `Subject: ${mail.subject}`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=utf-8',
65
- ...Object.entries(headers).map(([k, v]) => `${k}: ${v}`), '', ...mail.html.split(/\r?\n/).map((l) => (l.startsWith('.') ? `.${l}` : l)),
66
- ];
67
- await step(session.command(`${lines.join('\r\n')}\r\n.`, 250));
68
- await step(session.command('QUIT', 221)).catch(() => { });
175
+ await step(wire.ready);
176
+ const hello = await step(greeting);
177
+ if (hello.code !== 220) {
178
+ all(hello);
179
+ return finish();
180
+ }
181
+ const ehlo = await step(session.send(`EHLO ${domainOf(mail.from) || 'localhost'}\r\n`));
182
+ if (ehlo.code !== 250) {
183
+ all(ehlo);
184
+ return finish();
185
+ }
186
+ const data = dataOf(mail, recipients);
187
+ // 8-bit bytes go only to a server that takes them (RFC 6152)
188
+ const eightBit = /[\x80-\xff]/.test(data) && /^250[ -]8BITMIME\b/im.test(ehlo.text) ? ' BODY=8BITMIME' : '';
189
+ const from = await step(session.send(`MAIL FROM:<${mail.from}>${eightBit}\r\n`));
190
+ if (from.code !== 250) {
191
+ all(from);
192
+ return finish();
193
+ }
194
+ const accepted = [];
195
+ for (const to of recipients) {
196
+ const rcpt = await step(session.send(`RCPT TO:<${to}>\r\n`));
197
+ if (rcpt.code === 250 || rcpt.code === 251)
198
+ accepted.push(to);
199
+ else
200
+ replies.set(to, rcpt);
201
+ }
202
+ if (accepted.length) {
203
+ const ready = await step(session.send('DATA\r\n'));
204
+ all(ready.code === 354 ? await step(session.send(data)) : ready);
205
+ }
206
+ await step(session.send('QUIT\r\n')).catch(() => { });
207
+ }
208
+ catch (error) {
209
+ all({ code: 451, text: `451 4.4.1 ${wire.label}: ${error instanceof Error ? error.message : String(error)}` });
69
210
  }
70
211
  finally {
71
- socket.destroy();
212
+ wire.close();
213
+ }
214
+ return finish();
215
+ function finish() { return recipients.map((to) => ({ to, code: replies.get(to).code, reply: replies.get(to).text })); }
216
+ }
217
+ /** Hand one mail to each recipient's mailbox provider; every recipient's reply, in the order given. */
218
+ export async function sendMail(mail) {
219
+ const byDomain = new Map();
220
+ for (const to of mail.to)
221
+ byDomain.set(domainOf(to), [...(byDomain.get(domainOf(to)) ?? []), to]);
222
+ const replies = new Map();
223
+ for (const [domain, recipients] of byDomain) {
224
+ let server;
225
+ try {
226
+ server = await mailServerOf(domain);
227
+ }
228
+ catch (error) {
229
+ for (const to of recipients)
230
+ replies.set(to, { to, code: 451, reply: `451 4.4.1 ${domain}: ${error instanceof Error ? error.message : String(error)}` });
231
+ continue;
232
+ }
233
+ const answered = server
234
+ ? await transfer(server, mail, recipients)
235
+ : recipients.map((to) => RESERVED.test(domain)
236
+ ? { to, code: 550, reply: `550 5.1.2 ${domain}: no such domain (a reserved name, RFC 6761)` }
237
+ : { to, code: 550, reply: `550 5.4.4 ${domain}: no twin in this World receives its mail` });
238
+ for (const reply of answered)
239
+ replies.set(reply.to, reply);
72
240
  }
241
+ return mail.to.map((to) => replies.get(to));
73
242
  }