@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
package/src/smtp.ts CHANGED
@@ -1,77 +1,223 @@
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
+ import type { TwinStream } from './twin-fetch.ts';
6
12
 
7
- /** Where a mail goes: the SMTP host and port, and the sender. */
8
- export type SmtpRoute = { host: string; port: number; from: string; senderName?: string };
9
- /** One mail: its recipient, subject and HTML body. */
10
- export type Mail = { to: string; subject: string; html: string };
13
+ /** One mail, from its sender to its recipients: the sender's own message bytes, or a vendor's HTML mail the kernel
14
+ * composes (GoTrue's links). */
15
+ export type Mail = { from: string; to: string[] } & (
16
+ | { raw: Uint8Array | string }
17
+ | { senderName?: string; subject: string; html: string; headers?: Record<string, string> }
18
+ );
19
+ /** One recipient's answer: the receiving server's reply to its `RCPT`, or to the message when it accepted the
20
+ * recipient; or the kernel's own refusal when no server receives the recipient's domain. */
21
+ export type MailReply = { to: string; code: number; reply: string };
11
22
 
12
- type Socket = import('node:net').Socket;
23
+ type Reply = { code: number; text: string };
13
24
 
25
+ /** A mail server this process answers itself (a journey walk's companion over the walk's own World): its SMTP side
26
+ * port's byte-stream door (`create<Name>TwinStream`), for the domains `receives` names (its host rules). */
27
+ type InProcessMailServer = { vendor: string; receives: (domain: string) => boolean; stream: TwinStream };
28
+ let inProcessMailServers: ReadonlyArray<InProcessMailServer> = [];
29
+
30
+ /** The mail servers this process answers itself, consulted before the instance's; returns those set before, which the
31
+ * caller puts back. */
32
+ export function setInProcessMailServers(servers: ReadonlyArray<InProcessMailServer>): ReadonlyArray<InProcessMailServer> {
33
+ const previous = inProcessMailServers;
34
+ inProcessMailServers = servers;
35
+ return previous;
36
+ }
37
+
38
+ /** One connection to a server: bytes out, text in (latin1, one char per byte), and its end. */
39
+ type Wire = { label: string; write(data: string): void; onData(fn: (chunk: string) => void): void; ready: Promise<void>; failed: Promise<never>; close(): void };
40
+
41
+ /** A connection to a server's listener. */
42
+ async function socketWire(server: { host: string; port: number }): Promise<Wire> {
43
+ const { connect } = await import('node:net');
44
+ const socket = connect(server.port, server.host);
45
+ socket.setEncoding('latin1');
46
+ socket.setTimeout(SMTP_TIMEOUT_MS, () => socket.destroy(new Error(`no answer in ${SMTP_TIMEOUT_MS} ms`)));
47
+ const failed = new Promise<never>((_, reject) => { socket.once('error', reject); socket.once('close', () => reject(new Error('connection closed'))); });
48
+ failed.catch(() => {});
49
+ return {
50
+ label: `${server.host}:${server.port}`, failed, close: () => socket.destroy(),
51
+ ready: new Promise<void>((resolve) => { socket.once('connect', () => resolve()); }),
52
+ write: (data) => { socket.write(Buffer.from(data, 'latin1')); },
53
+ onData: (fn) => { socket.on('data', fn); },
54
+ };
55
+ }
56
+
57
+ /** A connection to a server this process answers itself: its byte-stream door, handed the bytes as a listener would. */
58
+ function streamWire(server: InProcessMailServer): Wire {
59
+ let deliver: ((chunk: string) => void) | undefined;
60
+ let held = '';
61
+ let end!: (error: Error) => void;
62
+ const failed = new Promise<never>((_, reject) => { end = reject; });
63
+ failed.catch(() => {});
64
+ // the door is opened before the timer starts, so a door that throws as it opens leaves nothing behind
65
+ const connection = server.stream({
66
+ write: (bytes) => { const text = Buffer.from(bytes).toString('latin1'); if (deliver) deliver(text); else held += text; },
67
+ end: () => end(new Error('connection closed')),
68
+ }, '127.0.0.1');
69
+ const timer = setTimeout(() => end(new Error(`no answer in ${SMTP_TIMEOUT_MS} ms`)), SMTP_TIMEOUT_MS);
70
+ return {
71
+ label: `${server.vendor} (in process)`, failed, ready: Promise.resolve(),
72
+ close: () => { clearTimeout(timer); connection.close(); },
73
+ write: (data) => connection.data(new Uint8Array(Buffer.from(data, 'latin1'))),
74
+ onData: (fn) => { deliver = fn; if (held) { const text = held; held = ''; fn(text); } },
75
+ };
76
+ }
77
+
78
+ /** The client's side of one connection. It owns every reply the server sends from the moment it reads the wire: a reply
79
+ * that arrives before anything awaits it (a greeting a server sends as it opens) is kept, in order, for the next read. */
14
80
  class SmtpSession {
15
81
  private buffer = '';
16
- private waiting: Array<(line: string) => void> = [];
17
- constructor(private readonly socket: Socket) {
18
- socket.setEncoding('utf8');
19
- socket.on('data', (chunk: string) => {
82
+ private waiting: Array<(reply: Reply) => void> = [];
83
+ private arrived: Reply[] = [];
84
+ constructor(private readonly wire: Wire) {
85
+ wire.onData((chunk: string) => {
20
86
  this.buffer += chunk;
21
87
  let end: number;
22
88
  // a reply is complete at a line whose code is followed by a space (multi-line replies use a dash)
23
- while ((end = this.buffer.search(/^\d{3} .*\r?\n/m)) >= 0) {
89
+ while ((end = this.buffer.search(/^\d{3}(?: .*)?\r?\n/m)) >= 0) {
24
90
  const lineEnd = this.buffer.indexOf('\n', end);
25
- const reply = this.buffer.slice(0, lineEnd + 1);
91
+ const text = this.buffer.slice(0, lineEnd + 1).trim();
26
92
  this.buffer = this.buffer.slice(lineEnd + 1);
27
- this.waiting.shift()?.(reply.trim().split(/\r?\n/).pop()!);
93
+ const reply = { code: Number(text.split(/\r?\n/).pop()!.slice(0, 3)), text: text.split(/\r?\n/).join('\n') };
94
+ const reader = this.waiting.shift();
95
+ if (reader) reader(reply); else this.arrived.push(reply);
28
96
  }
29
97
  });
30
98
  }
31
- reply(): Promise<string> { return new Promise((resolve) => this.waiting.push(resolve)); }
32
- async command(line: string, expect: number): Promise<void> {
99
+ reply(): Promise<Reply> {
100
+ const held = this.arrived.shift();
101
+ return held ? Promise.resolve(held) : new Promise((resolve) => this.waiting.push(resolve));
102
+ }
103
+ send(data: string): Promise<Reply> {
33
104
  const next = this.reply();
34
- this.socket.write(`${line}\r\n`);
35
- const reply = await next;
36
- if (!reply.startsWith(String(expect))) throw new Error(`SMTP ${line.split(' ')[0]}: ${reply}`);
105
+ this.wire.write(data);
106
+ return next;
37
107
  }
38
108
  }
39
109
 
40
- /** How long a mail may take, connect to QUIT (a host that never answers must not hold the request open). */
110
+ /** How long one server may take, connect to QUIT (a twin that never answers must not hold the request open). */
41
111
  const SMTP_TIMEOUT_MS = 10_000;
112
+ /** RFC 6761's names, for which DNS answers no such domain. */
113
+ const RESERVED = /(^|\.)(test|example|invalid|localhost)$/;
114
+
115
+ const domainOf = (address: string): string => address.slice(address.lastIndexOf('@') + 1).toLowerCase().replace(/\.$/, '');
42
116
 
43
- /** Send one mail. Throws when it cannot be delivered (refused by the World's egress rule, unreachable, or refused by the
44
- * server), so the vendor answers its own mailer failure. */
45
- export async function sendMail(route: SmtpRoute, mail: Mail, headers: Record<string, string> = {}): Promise<void> {
46
- const [{ connect }, { worldEgressRefusal }] = await Promise.all([
47
- import('node:net'),
48
- import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string, env?: NodeJS.ProcessEnv, method?: string) => string | null }>,
49
- ]);
50
- // sending mail is a write: an external relay needs a `writes` grant
51
- const refusal = worldEgressRefusal(`http://${route.host}:${route.port}/`, process.env, 'SMTP');
52
- if (refusal !== null) throw new Error(refusal);
53
- const socket = connect(route.port, route.host);
54
- socket.setTimeout(SMTP_TIMEOUT_MS, () => socket.destroy(new Error(`SMTP ${route.host}:${route.port}: no answer in ${SMTP_TIMEOUT_MS} ms`)));
55
- const session = new SmtpSession(socket);
117
+ /** The SMTP server of the World's twin that receives mail for `domain`, if one runs: one this process answers itself,
118
+ * else the listener the instance records. */
119
+ async function mailServerOf(domain: string): Promise<InProcessMailServer | { host: string; port: number } | undefined> {
120
+ const local = inProcessMailServers.find((server) => server.receives(domain));
121
+ if (local) return local;
122
+ const { packFacts } = await import('../pack-facts.cjs') as { packFacts: () => { packs: Record<string, { sidePorts?: Array<{ name: string; scheme?: string }> }> } };
123
+ const packs = packFacts().packs;
124
+ const receiving = vendorsOfHost(domain).flatMap((vendor) => {
125
+ const side = packs[vendor]?.sidePorts?.find((s) => s.scheme === 'smtp');
126
+ return side ? [{ vendor, side: side.name }] : [];
127
+ });
128
+ if (!receiving.length) return undefined;
129
+ const file = process.env.VOLTER_WORLD_INSTANCE;
130
+ if (!file) return undefined;
131
+ let instance: { env?: Record<string, string>; services?: Record<string, { url?: string; sides?: Record<string, { url: string }> }> };
132
+ try {
133
+ const { readFile } = await import('node:fs/promises');
134
+ instance = JSON.parse(await readFile(file, 'utf8'));
135
+ } catch { return undefined; }
136
+ const twins = { ...twinOrigins(process.env), ...twinOrigins(instance.env ?? {}) } as Record<string, string>;
137
+ for (const { vendor, side } of receiving) {
138
+ const origin = twins[vendor];
139
+ const service = origin === undefined ? undefined : Object.values(instance.services ?? {}).find((s) => s.url?.replace(/\/$/, '') === origin);
140
+ const at = service?.sides?.[side]?.url;
141
+ if (at) { const url = new URL(at); return { host: url.hostname, port: Number(url.port) }; }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ /** The message's bytes as SMTP carries them after DATA: CRLF line ends, leading dots doubled, ended by `.`. */
147
+ function dataOf(mail: Mail, to: string[]): string {
148
+ let text: string;
149
+ if ('raw' in mail) text = typeof mail.raw === 'string' ? Buffer.from(mail.raw, 'utf8').toString('latin1') : Buffer.from(mail.raw).toString('latin1');
150
+ else {
151
+ const from = mail.senderName ? `"${mail.senderName.replace(/"/g, '')}" <${mail.from}>` : mail.from;
152
+ text = Buffer.from([
153
+ `From: ${from}`, `To: ${to.join(', ')}`, `Subject: ${mail.subject}`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=utf-8',
154
+ ...Object.entries(mail.headers ?? {}).map(([k, v]) => `${k}: ${v}`), '', mail.html,
155
+ ].join('\r\n'), 'utf8').toString('latin1');
156
+ }
157
+ text = text.replace(/\r?\n/g, '\r\n');
158
+ if (!text.endsWith('\r\n')) text += '\r\n';
159
+ return `${text.replace(/(^|\r\n)\./g, '$1..')}.\r\n`;
160
+ }
161
+
162
+ /** One SMTP transaction with one server, for the recipients at its domains. */
163
+ async function transfer(server: InProcessMailServer | { host: string; port: number }, mail: Mail, recipients: string[]): Promise<MailReply[]> {
164
+ const replies = new Map<string, Reply>();
165
+ // a server whose connection cannot be opened answers every recipient a temporary failure (451), as a refused connect
166
+ let wire: Wire;
167
+ try { wire = 'stream' in server ? streamWire(server) : await socketWire(server); }
168
+ catch (error) {
169
+ const label = 'stream' in server ? `${server.vendor} (in process)` : `${server.host}:${server.port}`;
170
+ return recipients.map((to) => ({ to, code: 451, reply: `451 4.4.1 ${label}: ${error instanceof Error ? error.message : String(error)}` }));
171
+ }
172
+ const session = new SmtpSession(wire);
56
173
  const greeting = session.reply();
57
- const failed = new Promise<never>((_, reject) => { socket.once('error', reject); socket.once('close', () => reject(new Error('SMTP connection closed'))); });
58
- failed.catch(() => {});
59
- const step = <T>(p: Promise<T>): Promise<T> => Promise.race([p, failed]);
60
- await step(new Promise<void>((resolve) => { socket.once('connect', () => resolve()); }));
174
+ const step = <T>(p: Promise<T>): Promise<T> => Promise.race([p, wire.failed]);
175
+ const all = (reply: Reply) => { for (const to of recipients) if (!replies.has(to)) replies.set(to, reply); };
61
176
  try {
62
- if (!(await step(greeting)).startsWith('220')) throw new Error('SMTP: no greeting');
63
- await step(session.command(`EHLO ${route.from.split('@')[1] ?? 'localhost'}`, 250));
64
- await step(session.command(`MAIL FROM:<${route.from}>`, 250));
65
- await step(session.command(`RCPT TO:<${mail.to}>`, 250));
66
- await step(session.command('DATA', 354));
67
- const from = route.senderName ? `"${route.senderName.replace(/"/g, '')}" <${route.from}>` : route.from;
68
- const lines = [
69
- `From: ${from}`, `To: ${mail.to}`, `Subject: ${mail.subject}`, 'MIME-Version: 1.0', 'Content-Type: text/html; charset=utf-8',
70
- ...Object.entries(headers).map(([k, v]) => `${k}: ${v}`), '', ...mail.html.split(/\r?\n/).map((l) => (l.startsWith('.') ? `.${l}` : l)),
71
- ];
72
- await step(session.command(`${lines.join('\r\n')}\r\n.`, 250));
73
- await step(session.command('QUIT', 221)).catch(() => {});
177
+ await step(wire.ready);
178
+ const hello = await step(greeting);
179
+ if (hello.code !== 220) { all(hello); return finish(); }
180
+ const ehlo = await step(session.send(`EHLO ${domainOf(mail.from) || 'localhost'}\r\n`));
181
+ if (ehlo.code !== 250) { all(ehlo); return finish(); }
182
+ const data = dataOf(mail, recipients);
183
+ // 8-bit bytes go only to a server that takes them (RFC 6152)
184
+ const eightBit = /[\x80-\xff]/.test(data) && /^250[ -]8BITMIME\b/im.test(ehlo.text) ? ' BODY=8BITMIME' : '';
185
+ const from = await step(session.send(`MAIL FROM:<${mail.from}>${eightBit}\r\n`));
186
+ if (from.code !== 250) { all(from); return finish(); }
187
+ const accepted: string[] = [];
188
+ for (const to of recipients) {
189
+ const rcpt = await step(session.send(`RCPT TO:<${to}>\r\n`));
190
+ if (rcpt.code === 250 || rcpt.code === 251) accepted.push(to); else replies.set(to, rcpt);
191
+ }
192
+ if (accepted.length) {
193
+ const ready = await step(session.send('DATA\r\n'));
194
+ all(ready.code === 354 ? await step(session.send(data)) : ready);
195
+ }
196
+ await step(session.send('QUIT\r\n')).catch(() => {});
197
+ } catch (error) {
198
+ all({ code: 451, text: `451 4.4.1 ${wire.label}: ${error instanceof Error ? error.message : String(error)}` });
74
199
  } finally {
75
- socket.destroy();
200
+ wire.close();
201
+ }
202
+ return finish();
203
+ function finish(): MailReply[] { return recipients.map((to) => ({ to, code: replies.get(to)!.code, reply: replies.get(to)!.text })); }
204
+ }
205
+
206
+ /** Hand one mail to each recipient's mailbox provider; every recipient's reply, in the order given. */
207
+ export async function sendMail(mail: Mail): Promise<MailReply[]> {
208
+ const byDomain = new Map<string, string[]>();
209
+ for (const to of mail.to) byDomain.set(domainOf(to), [...(byDomain.get(domainOf(to)) ?? []), to]);
210
+ const replies = new Map<string, MailReply>();
211
+ for (const [domain, recipients] of byDomain) {
212
+ let server: Awaited<ReturnType<typeof mailServerOf>>;
213
+ try { server = await mailServerOf(domain); }
214
+ catch (error) { for (const to of recipients) replies.set(to, { to, code: 451, reply: `451 4.4.1 ${domain}: ${error instanceof Error ? error.message : String(error)}` }); continue; }
215
+ const answered = server
216
+ ? await transfer(server, mail, recipients)
217
+ : recipients.map((to) => RESERVED.test(domain)
218
+ ? { to, code: 550, reply: `550 5.1.2 ${domain}: no such domain (a reserved name, RFC 6761)` }
219
+ : { to, code: 550, reply: `550 5.4.4 ${domain}: no twin in this World receives its mail` });
220
+ for (const reply of answered) replies.set(reply.to, reply);
76
221
  }
222
+ return mail.to.map((to) => replies.get(to)!);
77
223
  }