@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.
- package/attach.cjs +7 -3
- package/dist/attach.cjs +7 -3
- package/dist/generated/pack-facts.json +44 -3660
- package/dist/pack-facts.cjs +4 -4
- package/dist/src/browser.d.ts +4 -0
- package/dist/src/browser.js +10 -0
- package/dist/src/checkout.d.ts +4 -0
- package/dist/src/checkout.js +13 -0
- package/dist/src/derived-core.d.ts +45 -5
- package/dist/src/derived-core.js +53 -8
- package/dist/src/derived-real.js +144 -2
- package/dist/src/derived.d.ts +1 -1
- package/dist/src/executor.d.ts +2 -2
- package/dist/src/executor.js +13 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/smart-http-client.d.ts +28 -0
- package/dist/src/git/smart-http-client.js +95 -0
- package/dist/src/git/smart-http.d.ts +3 -0
- package/dist/src/git/smart-http.js +9 -0
- package/dist/src/head.d.ts +16 -1
- package/dist/src/head.js +52 -7
- package/dist/src/html.d.ts +29 -0
- package/dist/src/html.js +64 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +3 -0
- package/dist/src/pack-fetch.js +41 -2
- package/dist/src/pack-paths.d.ts +2 -3
- package/dist/src/pack-paths.js +3 -13
- package/dist/src/packRegistry.d.ts +17 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/serve-http.d.ts +1 -1
- package/dist/src/serve-http.js +11 -2
- package/dist/src/smtp-server.d.ts +78 -0
- package/dist/src/smtp-server.js +160 -0
- package/dist/src/smtp.d.ts +29 -11
- package/dist/src/smtp.js +221 -52
- package/generated/pack-facts.json +44 -3660
- package/pack-facts.cjs +4 -4
- package/package.json +8 -2
- package/src/browser.ts +10 -0
- package/src/checkout.ts +12 -0
- package/src/derived-core.ts +68 -12
- package/src/derived-real.ts +121 -2
- package/src/derived.ts +2 -2
- package/src/executor.ts +13 -2
- package/src/git/index.ts +1 -0
- package/src/git/smart-http-client.ts +99 -0
- package/src/git/smart-http.ts +7 -0
- package/src/head.ts +37 -6
- package/src/html.ts +72 -0
- package/src/index.ts +4 -1
- package/src/pack-fetch.ts +35 -2
- package/src/pack-paths.ts +3 -11
- package/src/packRegistry.ts +9 -1
- package/src/runtime.ts +3 -0
- package/src/serve-http.ts +11 -3
- package/src/smtp-server.ts +166 -0
- package/src/smtp.ts +199 -53
package/src/smtp.ts
CHANGED
|
@@ -1,77 +1,223 @@
|
|
|
1
|
-
// A vendor's mail to
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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<(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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}
|
|
89
|
+
while ((end = this.buffer.search(/^\d{3}(?: .*)?\r?\n/m)) >= 0) {
|
|
24
90
|
const lineEnd = this.buffer.indexOf('\n', end);
|
|
25
|
-
const
|
|
91
|
+
const text = this.buffer.slice(0, lineEnd + 1).trim();
|
|
26
92
|
this.buffer = this.buffer.slice(lineEnd + 1);
|
|
27
|
-
|
|
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<
|
|
32
|
-
|
|
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.
|
|
35
|
-
|
|
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
|
|
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
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
await step(
|
|
64
|
-
|
|
65
|
-
await step(session.
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
}
|