@volter/twin-gmail 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/dist/generated/pack-facts.json +47 -0
- package/dist/journeys/customer-life.json +1013 -0
- package/dist/journeys/decisions.json +77 -0
- package/dist/journeys/demand.json +43 -0
- package/dist/journeys/first-use.json +24 -0
- package/dist/journeys/vendor-examples.json +7 -0
- package/dist/spec/SOURCE.md +13 -0
- package/dist/spec/rfc5321.txt +5323 -0
- package/dist/spec/sources.json +25 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +19 -0
- package/dist/src/engine/mail.d.ts +19 -0
- package/dist/src/engine/mail.js +89 -0
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +18 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +11 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +68 -0
- package/dist/src/screens/home.d.ts +2 -0
- package/dist/src/screens/home.js +3 -0
- package/dist/src/screens/mail.d.ts +2 -0
- package/dist/src/screens/mail.js +142 -0
- package/dist/src/semantics/around.d.ts +2 -0
- package/dist/src/semantics/around.js +97 -0
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +1 -0
- package/dist/src/semantics/shared.d.ts +52 -0
- package/dist/src/semantics/shared.js +97 -0
- package/dist/src/semantics/states.d.ts +4 -0
- package/dist/src/semantics/states.js +33 -0
- package/dist/src/server.d.ts +9 -0
- package/dist/src/server.js +23 -0
- package/dist/src/stream.d.ts +2 -0
- package/dist/src/stream.js +10 -0
- package/generated/pack-facts.json +47 -0
- package/journeys/customer-life.json +1013 -0
- package/journeys/decisions.json +77 -0
- package/journeys/demand.json +43 -0
- package/journeys/first-use.json +24 -0
- package/journeys/vendor-examples.json +7 -0
- package/package.json +52 -4
- package/spec/SOURCE.md +13 -0
- package/spec/rfc5321.txt +5323 -0
- package/spec/sources.json +25 -0
- package/src/cli.ts +20 -0
- package/src/engine/mail.ts +76 -0
- package/src/fetch.ts +19 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +13 -0
- package/src/manifest.ts +86 -0
- package/src/screens/home.ts +6 -0
- package/src/screens/mail.ts +128 -0
- package/src/semantics/around.ts +91 -0
- package/src/semantics/index.ts +6 -0
- package/src/semantics/shared.ts +111 -0
- package/src/semantics/states.ts +41 -0
- package/src/server.ts +19 -0
- package/src/stream.ts +11 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"https://knowledge.workspace.google.com/admin/support/troubleshooting/gmail-smtp-errors-and-codes": {
|
|
3
|
+
"status": 200,
|
|
4
|
+
"checked": "2026-11-11",
|
|
5
|
+
"quotes": {
|
|
6
|
+
"gsmtp (Google SMTP) is added to all errors": true,
|
|
7
|
+
"The email account that you tried to reach does not exist.": true,
|
|
8
|
+
"Your message exceeded Google's message size limits.": true
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"https://www.rfc-editor.org/rfc/rfc2047": {
|
|
12
|
+
"status": 200,
|
|
13
|
+
"checked": "2026-10-03",
|
|
14
|
+
"quotes": {
|
|
15
|
+
"encoded-word = \"=?\" charset \"?\" encoding \"?\" encoded-text \"?=\"": true
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"https://www.rfc-editor.org/rfc/rfc5322": {
|
|
19
|
+
"status": 200,
|
|
20
|
+
"checked": "2026-10-03",
|
|
21
|
+
"quotes": {
|
|
22
|
+
"Unfolding is accomplished by simply removing any CRLF that is immediately followed by WSP": true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// world-gmail: serve the gmail twin (its webmail, and its receiving SMTP server on the side port the World allocates).
|
|
3
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
4
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
5
|
+
import { createGmailTwinServer } from "./server.js";
|
|
6
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
7
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
8
|
+
const smtpPort = Number(optionValue(rest, '--smtp-port', '0')) || undefined;
|
|
9
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
10
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
11
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
12
|
+
const s = await createGmailTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}), ...(smtpPort ? { smtpPort } : {}) });
|
|
13
|
+
process.stdout.write(`gmail twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
14
|
+
process.stdout.write(`gmail twin receiving mail on smtp://127.0.0.1:${s.smtpPort}\n`);
|
|
15
|
+
await keepProcessAlive();
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
process.stdout.write('Usage: world-gmail serve [--port N] [--smtp-port N] [--root DIR] [--read-only]\n');
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type Attachment = {
|
|
2
|
+
filename: string | null;
|
|
3
|
+
contentType: string;
|
|
4
|
+
size: number;
|
|
5
|
+
contentId: string | null;
|
|
6
|
+
base64: string;
|
|
7
|
+
};
|
|
8
|
+
type Parsed = {
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
subject: string;
|
|
11
|
+
from: string;
|
|
12
|
+
to: string[];
|
|
13
|
+
cc: string[];
|
|
14
|
+
text: string | null;
|
|
15
|
+
html: string | null;
|
|
16
|
+
attachments: Attachment[];
|
|
17
|
+
};
|
|
18
|
+
export declare function parse(data: string): Parsed;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Headers and body split at the first empty line; folded headers unfolded, names lowercased (RFC 5322 §2.2.3). */
|
|
2
|
+
// source: https://www.rfc-editor.org/rfc/rfc5322 "Unfolding is accomplished by simply removing any CRLF that is immediately followed by WSP"
|
|
3
|
+
function split(raw) {
|
|
4
|
+
const at = raw.search(/\r?\n\r?\n/);
|
|
5
|
+
const head = at < 0 ? raw : raw.slice(0, at);
|
|
6
|
+
const body = at < 0 ? '' : raw.slice(at).replace(/^\r?\n\r?\n/, '');
|
|
7
|
+
const headers = {};
|
|
8
|
+
for (const line of head.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
|
|
9
|
+
const i = line.indexOf(':');
|
|
10
|
+
if (i > 0)
|
|
11
|
+
headers[line.slice(0, i).trim().toLowerCase()] ??= utf8(bytesOf(line.slice(i + 1).trim()));
|
|
12
|
+
}
|
|
13
|
+
return { headers, body };
|
|
14
|
+
}
|
|
15
|
+
const param = (value, name) => {
|
|
16
|
+
const m = new RegExp(`;\\s*${name}\\*?=\\s*(?:"([^"]*)"|([^;\\s]+))`, 'i').exec(value);
|
|
17
|
+
const v = m?.[1] ?? m?.[2];
|
|
18
|
+
return v?.startsWith("utf-8''") || v?.startsWith("UTF-8''") ? decodeURIComponent(v.slice(7)) : v;
|
|
19
|
+
};
|
|
20
|
+
const bytesOf = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff);
|
|
21
|
+
const utf8 = (b) => new TextDecoder().decode(b);
|
|
22
|
+
const textFor = (b, charset) => { try {
|
|
23
|
+
return new TextDecoder(charset ?? 'utf-8').decode(b);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return utf8(b);
|
|
27
|
+
} };
|
|
28
|
+
/** A part's body decoded by its Content-Transfer-Encoding: base64 and quoted-printable to bytes (RFC 2045 §6). */
|
|
29
|
+
function decoded(part) {
|
|
30
|
+
const enc = (part.headers['content-transfer-encoding'] ?? '7bit').toLowerCase();
|
|
31
|
+
if (enc === 'base64') {
|
|
32
|
+
try {
|
|
33
|
+
return bytesOf(atob(part.body.replace(/\s+/g, '')));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return bytesOf(part.body);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (enc === 'quoted-printable')
|
|
40
|
+
return bytesOf(part.body.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(Number.parseInt(h, 16))).split('').map((c) => (c.charCodeAt(0) > 0xff ? '?' : c)).join(''));
|
|
41
|
+
return bytesOf(part.body);
|
|
42
|
+
}
|
|
43
|
+
/** Encoded words in a header (`=?UTF-8?B?…?=`, `=?UTF-8?Q?…?=`), decoded (RFC 2047). */
|
|
44
|
+
// source: https://www.rfc-editor.org/rfc/rfc2047 "encoded-word = \"=?\" charset \"?\" encoding \"?\" encoded-text \"?=\""
|
|
45
|
+
function words(value) {
|
|
46
|
+
return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=(\s+(?==\?))?/g, (_, _cs, e, t) => {
|
|
47
|
+
try {
|
|
48
|
+
return textFor(e.toUpperCase() === 'B' ? bytesOf(atob(t)) : bytesOf(t.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => String.fromCharCode(Number.parseInt(h, 16)))), _cs);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return t;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const addresses = (v) => (v ? words(v).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map((a) => (/<([^>]+)>/.exec(a)?.[1] ?? a).trim()).filter(Boolean) : []);
|
|
56
|
+
/** The message's parts: a multipart's each (recursively), else the one. */
|
|
57
|
+
function leaves(part) {
|
|
58
|
+
const type = part.headers['content-type'] ?? 'text/plain';
|
|
59
|
+
const boundary = /^multipart\//i.test(type) ? param(type, 'boundary') : undefined;
|
|
60
|
+
if (!boundary)
|
|
61
|
+
return [part];
|
|
62
|
+
const pieces = part.body.split(new RegExp(`\\r?\\n?--${boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:--)?[ \\t]*\\r?\\n?`));
|
|
63
|
+
return pieces.slice(1, -1).filter((p) => p.trim()).flatMap((p) => leaves(split(p)));
|
|
64
|
+
}
|
|
65
|
+
export function parse(data) {
|
|
66
|
+
const top = split(data);
|
|
67
|
+
let text = null;
|
|
68
|
+
let html = null;
|
|
69
|
+
const attachments = [];
|
|
70
|
+
for (const leaf of leaves(top)) {
|
|
71
|
+
const type = (leaf.headers['content-type'] ?? 'text/plain').split(';')[0].trim().toLowerCase();
|
|
72
|
+
const disposition = leaf.headers['content-disposition'] ?? '';
|
|
73
|
+
const filename = param(disposition, 'filename') ?? param(leaf.headers['content-type'] ?? '', 'name');
|
|
74
|
+
const bytes = decoded(leaf);
|
|
75
|
+
if (!/^attachment/i.test(disposition) && !filename && type === 'text/plain' && text === null) {
|
|
76
|
+
text = textFor(bytes, param(leaf.headers['content-type'] ?? '', 'charset'));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!/^attachment/i.test(disposition) && !filename && type === 'text/html' && html === null) {
|
|
80
|
+
html = textFor(bytes, param(leaf.headers['content-type'] ?? '', 'charset'));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
let bin = '';
|
|
84
|
+
for (const b of bytes)
|
|
85
|
+
bin += String.fromCharCode(b);
|
|
86
|
+
attachments.push({ filename: filename ? words(filename) : null, contentType: type, size: bytes.length, contentId: leaf.headers['content-id']?.replace(/^<|>$/g, '') ?? null, base64: btoa(bin) });
|
|
87
|
+
}
|
|
88
|
+
return { headers: top.headers, subject: words(top.headers.subject ?? ''), from: addresses(top.headers.from)[0] ?? '', to: addresses(top.headers.to), cc: addresses(top.headers.cc), text, html, attachments };
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the discovery door, the clock,
|
|
2
|
+
// the World's doors, the vendor's screens and the API from the pack's parts (architecture, "The pack contract").
|
|
3
|
+
import { createPackFetch } from '@volter/world-core';
|
|
4
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
5
|
+
import { manifest } from "./manifest.js";
|
|
6
|
+
import { around } from "./semantics/around.js";
|
|
7
|
+
import { gmailSemantics } from "./semantics/index.js";
|
|
8
|
+
import { screen as screen_home } from "./screens/home.js";
|
|
9
|
+
import { screen as screen_mail } from "./screens/mail.js";
|
|
10
|
+
export function createGmailFetch(options = {}) {
|
|
11
|
+
return createPackFetch({
|
|
12
|
+
manifest,
|
|
13
|
+
surface: surface,
|
|
14
|
+
handlers: gmailSemantics,
|
|
15
|
+
around,
|
|
16
|
+
screens: { "home": screen_home, "mail": screen_mail },
|
|
17
|
+
}, options);
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","spec":"rfc5321.txt","format":"command-reply","version":"RFC 5321","anyCommand":[500,501,421],"commands":[{"id":"CONNECTION ESTABLISHMENT","intermediate":[],"success":[220],"error":[554]},{"id":"EHLO","intermediate":[],"success":[250],"error":[504,550,502]},{"id":"HELO","intermediate":[],"success":[250],"error":[504,550,502]},{"id":"MAIL","intermediate":[],"success":[250],"error":[552,451,452,550,553,503,455,555]},{"id":"RCPT","intermediate":[],"success":[250,251],"error":[550,551,552,553,450,451,452,503,455,555]},{"id":"DATA","intermediate":[354],"success":[],"error":[503,554],"then":{"intermediate":[],"success":[250],"error":[552,554,451,452,450,550]}},{"id":"RSET","intermediate":[],"success":[250],"error":[]},{"id":"VRFY","intermediate":[],"success":[250,251,252],"error":[550,551,553,502,504]},{"id":"EXPN","intermediate":[],"success":[250,252],"error":[550,500,502,504]},{"id":"HELP","intermediate":[],"success":[211,214],"error":[502,504]},{"id":"NOOP","intermediate":[],"success":[250],"error":[]},{"id":"QUIT","intermediate":[],"success":[221],"error":[]}]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { manifest } from './manifest.js';
|
|
2
|
+
export { createGmailFetch } from './fetch.js';
|
|
3
|
+
export { createGmailTwinServer } from './server.js';
|
|
4
|
+
export { createGmailTwinStream } from './stream.js';
|
|
5
|
+
export { manifest };
|
|
6
|
+
export declare const pack: import("@volter/world-core").PackDescriptor;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @volter/twin-gmail — the gmail twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the
|
|
2
|
+
// kernel derives the vendor-backed half from the generated surface, unless the manifest declares none (vendorBacked.none;
|
|
3
|
+
// architecture, "The real-system adapters").
|
|
4
|
+
import { packOf, registerPack } from '@volter/world-core';
|
|
5
|
+
import { manifest } from "./manifest.js";
|
|
6
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
7
|
+
export { createGmailFetch } from "./fetch.js";
|
|
8
|
+
export { createGmailTwinServer } from "./server.js";
|
|
9
|
+
export { createGmailTwinStream } from "./stream.js";
|
|
10
|
+
export { manifest };
|
|
11
|
+
export const pack = registerPack(packOf(manifest, surface));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { states } from "./semantics/states.js";
|
|
2
|
+
export const manifest = {
|
|
3
|
+
vendor: 'gmail',
|
|
4
|
+
service: 'gmail',
|
|
5
|
+
discovery: {
|
|
6
|
+
twinOf: "Gmail: its receiving SMTP server (gmail.com's MX) and its webmail at mail.google.com",
|
|
7
|
+
identity: "The SMTP side port takes mail from any sender, as an MX does, for gmail.com accounts Google holds. Webmail reads the person's Google session (googleoauth's SID cookie).",
|
|
8
|
+
stores: "each Google account's mailbox: the messages it received, with their envelope, bytes, headers, bodies and attachments",
|
|
9
|
+
},
|
|
10
|
+
body: { json: 'always' },
|
|
11
|
+
ids: { template: '{letters:16}' },
|
|
12
|
+
time: 'iso',
|
|
13
|
+
// the screens' and the wire's refusals; the wire answers in SMTP replies (./semantics/shared.ts)
|
|
14
|
+
error: { error: '{message}' },
|
|
15
|
+
readOnly: { status: 403, message: 'the twin is read-only' },
|
|
16
|
+
notFound: { status: 404, message: 'Not Found' },
|
|
17
|
+
list: { style: 'envelope', envelope: { messages: '{data}' }, limit: { param: 'limit', default: 50, max: 500 } },
|
|
18
|
+
deleted: {},
|
|
19
|
+
vendorBacked: { none: 'Mail reaches Gmail over SMTP from a sending twin inside the World; there is no derived HTTP perform or vendor resource refresh (the Gmail API is a later card).' },
|
|
20
|
+
unmodeled: ['EXPN', 'HELP'],
|
|
21
|
+
// who a message's records name (ADR 0036, "Replicating an issue"): its mailbox, the Google account it was kept for,
|
|
22
|
+
// and its envelope's addresses; the recipient as Gmail delivers it, where `local+tag@gmail.com` is the mailbox
|
|
23
|
+
// `local@gmail.com` labelled (shared.ts accountOf), so a pseudonymized pull keeps the tag on the mailbox's pseudonym (`email+`)
|
|
24
|
+
privacy: {
|
|
25
|
+
identifiers: {
|
|
26
|
+
message: { mailbox: 'email', 'envelope.to': 'email+', 'envelope.from': 'email', receivedAt: 'time' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
ownerReads: [
|
|
30
|
+
{ owner: 'googleoauth', resource: '_person' },
|
|
31
|
+
{ owner: 'googleoauth', resource: 'account' },
|
|
32
|
+
{ owner: 'googleoauth', resource: '_web_session' },
|
|
33
|
+
],
|
|
34
|
+
resources: {
|
|
35
|
+
Session: { storedAs: '_session', refresh: { none: 'Connection-local state, not a vendor resource.' }, idPrefix: 'session', ...states.Session },
|
|
36
|
+
message: { refresh: { none: 'The Gmail API, which would read a mailbox back, is a later card.' }, idPrefix: '', ids: '{letters:16}' },
|
|
37
|
+
},
|
|
38
|
+
screens: [{
|
|
39
|
+
// Gmail's webmail (screens/mail.ts): the signed-in person's inbox, a message with its links, and its attachments
|
|
40
|
+
id: 'mail', kind: 'workspace', host: 'mail.google.com', path: '/mail',
|
|
41
|
+
demand: "a person reads the mail an application sent them (Rallly's invitation with its invite.ics, Dub's links) and follows its link", status: 'done',
|
|
42
|
+
controls: ['Inbox', 'Download'], source: 'https://support.google.com/mail/answer/8494',
|
|
43
|
+
}, {
|
|
44
|
+
// gmail.com itself, which sends a visitor to the webmail
|
|
45
|
+
id: 'home', kind: 'flow', host: 'gmail.com', path: '/',
|
|
46
|
+
demand: 'a person who opens gmail.com reaches their inbox', status: 'done',
|
|
47
|
+
source: 'https://support.google.com/mail/answer/8494',
|
|
48
|
+
}],
|
|
49
|
+
descriptor: {
|
|
50
|
+
protocol: '3',
|
|
51
|
+
transport: 'rest',
|
|
52
|
+
archetype: 'crud',
|
|
53
|
+
bin: 'world-gmail',
|
|
54
|
+
resources: ['message'],
|
|
55
|
+
specSource: "spec/rfc5321.txt (RFC 5321): the command-reply table of Gmail's receiving server (spec/SOURCE.md)",
|
|
56
|
+
description: "Gmail twin — gmail.com's receiving SMTP server, which keeps mail for the World's Google accounts and refuses other addresses, and the webmail at mail.google.com where the signed-in person reads it and downloads its attachments.",
|
|
57
|
+
adoption: { sdks: [], envStems: [] },
|
|
58
|
+
// gmail.com: the domain whose mail this server receives (the kernel's ctx.mail routes by it), and its site; the
|
|
59
|
+
// webmail's host
|
|
60
|
+
hosts: [{ host: 'mail.google.com' }, { host: 'gmail.com' }],
|
|
61
|
+
endpointEnvNone: 'no application reads Gmail by a base URL: mail reaches it from the World\'s senders (ctx.mail), and a person opens its webmail',
|
|
62
|
+
// a gmail.com address is a Google account, made at Google's own sign-up (googleoauth's accounts.google.com/signup,
|
|
63
|
+
// which leaves the person signed in): the door the World's generated seed calls for each declared person whose
|
|
64
|
+
// address this pack's mail domain is (ADR 0033 §3)
|
|
65
|
+
seed: { signUp: 'https://accounts.google.com/signup' },
|
|
66
|
+
sidePorts: [{ name: 'smtp', option: 'smtpPort', flag: '--smtp-port', scheme: 'smtp', note: "Gmail's receiving SMTP server (gmail-smtp-in.l.google.com), without TLS: mail for gmail.com's accounts, handed on by the World's senders through ctx.mail" }],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Gmail's webmail (mail.google.com/mail): the signed-in person's inbox, one message with its links, and its attachments,
|
|
2
|
+
// under the person's Google session (googleoauth's SID cookie, read through the kernel's cross-pack read: ADR 0032 §2).
|
|
3
|
+
// A browser with no Google session is sent to Google's sign-in, which returns it here.
|
|
4
|
+
//
|
|
5
|
+
// GET /mail, /mail/u/0/ the inbox, newest first
|
|
6
|
+
// GET /mail/u/0/inbox/<id> one message: its sender, recipients, subject and body, links followable
|
|
7
|
+
// GET /mail/u/0/inbox/<id>/attachments/<index> one attachment's bytes, downloaded
|
|
8
|
+
//
|
|
9
|
+
// Where the documentation stops: Gmail draws its pages in the browser and names a message in the page's fragment
|
|
10
|
+
// (`#inbox/<id>`); the twin renders each on the server at a path of its own, so a person (or a walk) opens and clicks it
|
|
11
|
+
// as a page. A message's HTML is sanitized on the server by the kernel's HTML grammar and shown inline in the message
|
|
12
|
+
// body, as Gmail shows it, its links opening a new tab; the body is the containing boundary of everything it draws.
|
|
13
|
+
// Composing, replying, labels, search and the other folders are not built.
|
|
14
|
+
import { html } from '@volter/world-core';
|
|
15
|
+
import { MESSAGE, signedInAccount } from "../semantics/shared.js";
|
|
16
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
17
|
+
// What Gmail keeps of a received message (ADR 0032 §4): these elements go with their content; these are kept; any other
|
|
18
|
+
// element is unwrapped, its children kept. Only these attributes stay, so no received class or id poses as the
|
|
19
|
+
// webmail's own element.
|
|
20
|
+
const DROPPED = new Set(['script', 'style', 'link', 'meta', 'base', 'title', 'head', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'form', 'input', 'button', 'select', 'textarea', 'option', 'template', 'noscript', 'svg', 'math', 'video', 'audio', 'source', 'track', 'canvas']);
|
|
21
|
+
const KEPT = new Set(['a', 'abbr', 'address', 'b', 'bdi', 'bdo', 'big', 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'div', 'dl', 'dt', 'em', 'font', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'mark', 'ol', 'p', 'pre', 'q', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'wbr']);
|
|
22
|
+
const ATTRIBUTES = new Set(['href', 'src', 'alt', 'title', 'width', 'height', 'align', 'valign', 'colspan', 'rowspan', 'border', 'cellpadding', 'cellspacing', 'bgcolor', 'color', 'face', 'size', 'dir', 'lang', 'style']);
|
|
23
|
+
/** A style attribute as Gmail keeps it (ADR 0032 §4): without a remote load, a script or positioning, for an ordinary
|
|
24
|
+
* spelling. This is fidelity with Gmail, not the boundary: the message body contains whatever a style draws (BODY) and
|
|
25
|
+
* the page's CSP refuses every load, so an escaped spelling this misses stays inside the body and loads nothing. */
|
|
26
|
+
const style = (value) => value.split(';').map((declaration) => declaration.trim()).filter((declaration) => {
|
|
27
|
+
if (!declaration)
|
|
28
|
+
return false;
|
|
29
|
+
const lower = declaration.toLowerCase();
|
|
30
|
+
if (lower.includes('url(') || lower.includes('expression(') || lower.includes('@import'))
|
|
31
|
+
return false;
|
|
32
|
+
const property = lower.slice(0, lower.indexOf(':') === -1 ? lower.length : lower.indexOf(':')).trim();
|
|
33
|
+
return property !== 'position' && property !== 'behavior' && property !== '-moz-binding';
|
|
34
|
+
}).join('; ');
|
|
35
|
+
// The message body is the containing boundary of what the message draws: paint containment makes it the containing
|
|
36
|
+
// block of every positioned descendant, `position: fixed` included, and clips what they paint to it (CSS Containment
|
|
37
|
+
// Level 2 §3.3, https://www.w3.org/TR/css-contain-2/#containment-paint); its own stacking context keeps the message's
|
|
38
|
+
// z-index inside it.
|
|
39
|
+
const BODY = 'contain:paint;overflow:hidden;isolation:isolate;position:relative';
|
|
40
|
+
const scheme = (value) => { try {
|
|
41
|
+
return new URL(value).protocol;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return '';
|
|
45
|
+
} };
|
|
46
|
+
function keep(nodes) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
if (node.type === 'text') {
|
|
50
|
+
out.push(node);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (DROPPED.has(node.name))
|
|
54
|
+
continue;
|
|
55
|
+
const children = keep(node.children);
|
|
56
|
+
if (!KEPT.has(node.name)) {
|
|
57
|
+
out.push(...children);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const attributes = [];
|
|
61
|
+
for (const { name, value, namespace } of node.attributes) {
|
|
62
|
+
// a namespaced attribute (xlink:href, xml:lang) is no attribute the message's HTML keeps
|
|
63
|
+
if (namespace || !ATTRIBUTES.has(name))
|
|
64
|
+
continue;
|
|
65
|
+
if (name === 'href' && !['http:', 'https:', 'mailto:'].includes(scheme(value)))
|
|
66
|
+
continue;
|
|
67
|
+
if (name === 'src' && !(node.name === 'img' && scheme(value) === 'data:'))
|
|
68
|
+
continue;
|
|
69
|
+
if (name === 'style') {
|
|
70
|
+
const kept = style(value);
|
|
71
|
+
if (kept)
|
|
72
|
+
attributes.push({ name, value: kept });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
attributes.push({ name, value });
|
|
76
|
+
}
|
|
77
|
+
if (node.name === 'a')
|
|
78
|
+
attributes.push({ name: 'target', value: '_blank' }, { name: 'rel', value: 'noreferrer noopener' });
|
|
79
|
+
out.push({ type: 'element', name: node.name, namespace: node.namespace, attributes, children });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** A received message's HTML as Gmail shows it (ADR 0032 §4): sanitized on the server and shown inline in its body. */
|
|
84
|
+
async function sanitized(source) {
|
|
85
|
+
return html.serialize(keep(await html.parseFragment(source)));
|
|
86
|
+
}
|
|
87
|
+
/** A plain-text body with its addresses followable, as Gmail links them. */
|
|
88
|
+
const linked = (text) => esc(text).replace(/https?:\/\/[^\s<>"']+/g, (url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`);
|
|
89
|
+
const when = (iso) => {
|
|
90
|
+
const d = new Date(String(iso));
|
|
91
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'UTC' });
|
|
92
|
+
};
|
|
93
|
+
// the inbox is Gmail's, at its own host, however the request reached the twin: a person's browser reaches it there
|
|
94
|
+
// through the World's DNS, so the links it writes carry that host (ADR 0036 §9)
|
|
95
|
+
const MAIL = 'https://mail.google.com';
|
|
96
|
+
function page(title, email, body, status = 200) {
|
|
97
|
+
return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>body{margin:0;font:14px/1.45 Roboto,Arial,sans-serif;background:#f6f8fc;color:#1f1f1f}header{display:flex;justify-content:space-between;align-items:center;padding:12px 20px}header .brand{font-size:22px;color:#444746}header .who{color:#444746}nav{position:absolute;width:220px;padding:8px 12px}nav a{display:block;padding:6px 16px;border-radius:16px;background:#d3e3fd;color:#001d35;font-weight:600;text-decoration:none}main{margin-left:244px;margin-right:16px;background:#fff;border-radius:16px;min-height:70vh;padding:8px 0}.row{display:grid;grid-template-columns:200px 1fr 120px;gap:12px;padding:10px 20px;border-bottom:1px solid #f1f3f4;color:inherit;text-decoration:none}.row.unread{font-weight:700}.row .date{text-align:right;color:#5e5e5e}.msg{padding:12px 24px}.msg h2.hP{font-size:22px;font-weight:400;margin:8px 0 16px}.meta{color:#5e5e5e;margin-bottom:16px}.body{border-top:1px solid #f1f3f4;padding-top:16px;overflow-wrap:anywhere;${BODY}}.body pre{white-space:pre-wrap;font:inherit}.files{margin-top:24px;display:flex;gap:12px;flex-wrap:wrap}.files a{border:1px solid #dadce0;border-radius:8px;padding:10px 14px;color:#1f1f1f;text-decoration:none}.empty{padding:24px}</style></head><body><header><span class="brand">Gmail</span><span class="who">${esc(email)}</span></header><nav><a href="${MAIL}/mail/u/0/" aria-current="page">Inbox</a></nav><main>${body}</main></body></html>`, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; script-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'" } });
|
|
98
|
+
}
|
|
99
|
+
export async function screen(ctx) {
|
|
100
|
+
const request = ctx.call.request;
|
|
101
|
+
if (!['GET', 'HEAD'].includes(request.method))
|
|
102
|
+
return new Response('Method Not Allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
103
|
+
const url = new URL(request.url);
|
|
104
|
+
const who = signedInAccount(ctx);
|
|
105
|
+
if (!who) {
|
|
106
|
+
// to Google's sign-in, which returns the person to the page they asked for
|
|
107
|
+
const back = `${MAIL}${url.pathname}${url.search}`;
|
|
108
|
+
return new Response(null, { status: 302, headers: { location: `https://accounts.google.com/ServiceLogin?${new URLSearchParams({ service: 'mail', continue: back })}` } });
|
|
109
|
+
}
|
|
110
|
+
const mine = ctx.rowsRaw(MESSAGE).filter((m) => m.mailbox === who && m.deleted !== true)
|
|
111
|
+
.sort((a, b) => String(b.receivedAt).localeCompare(String(a.receivedAt)) || String(b.id).localeCompare(String(a.id)));
|
|
112
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
113
|
+
const opened = /^\/mail\/u\/0\/inbox\/([^/]+)(?:\/attachments\/(\d+))?$/.exec(path);
|
|
114
|
+
if (!opened) {
|
|
115
|
+
if (path !== '/mail' && path !== '/mail/u/0')
|
|
116
|
+
return page('Gmail', who, '<p class="empty" role="alert">Temporary Error (404)</p>', 404);
|
|
117
|
+
const rows = mine.map((m) => `<a class="row${m.labelIds?.includes('UNREAD') ? ' unread' : ''}" href="${MAIL}/mail/u/0/inbox/${encodeURIComponent(String(m.id))}"><span class="from">${esc(m.from || m.envelope?.from)}</span><span class="subject">${esc(m.subject || '(no subject)')}</span><span class="date">${esc(when(m.receivedAt))}</span></a>`).join('');
|
|
118
|
+
return page(`Inbox - ${who} - Gmail`, who, rows || '<p class="empty">Your Primary tab is empty.</p>');
|
|
119
|
+
}
|
|
120
|
+
let id = '';
|
|
121
|
+
try {
|
|
122
|
+
id = decodeURIComponent(opened[1]);
|
|
123
|
+
}
|
|
124
|
+
catch { /* no such message */ }
|
|
125
|
+
const m = mine.find((x) => x.id === id);
|
|
126
|
+
if (!m)
|
|
127
|
+
return page('Gmail', who, '<p class="empty" role="alert">That message could not be found.</p>', 404);
|
|
128
|
+
const files = m.attachments ?? [];
|
|
129
|
+
if (opened[2] !== undefined) {
|
|
130
|
+
const index = Number(opened[2]);
|
|
131
|
+
const key = m._attachments?.[index];
|
|
132
|
+
const meta = files[index];
|
|
133
|
+
const bytes = key === undefined ? undefined : await ctx.blobs.get(key);
|
|
134
|
+
if (!meta || !bytes)
|
|
135
|
+
return page('Gmail', who, '<p class="empty" role="alert">That attachment could not be found.</p>', 404);
|
|
136
|
+
const name = String(meta.filename ?? `attachment-${index}`).replace(/["\r\n\\]/g, '');
|
|
137
|
+
return new Response(new Uint8Array(bytes), { headers: { 'content-type': String(meta.contentType ?? 'application/octet-stream'), 'content-disposition': `attachment; filename="${name}"` } });
|
|
138
|
+
}
|
|
139
|
+
const body = typeof m.html === 'string' && m.html ? await sanitized(m.html) : `<pre>${linked(String(m.text ?? ''))}</pre>`;
|
|
140
|
+
const list = files.map((f, i) => `<a href="${MAIL}/mail/u/0/inbox/${encodeURIComponent(String(m.id))}/attachments/${i}" download="${esc(f.filename ?? '')}">${esc(f.filename ?? `attachment ${i + 1}`)} <small>Download</small></a>`).join('');
|
|
141
|
+
return page(`${String(m.subject ?? '(no subject)')} - ${who} - Gmail`, who, `<article class="msg"><h2 class="hP">${esc(m.subject || '(no subject)')}</h2><div class="meta"><strong>${esc(m.from)}</strong> to ${esc((m.to ?? []).join(', ') || who)} · ${esc(when(m.receivedAt))}</div><div class="body a3s">${body}</div>${list ? `<div class="files" aria-label="Attachments">${list}</div>` : ''}</article>`);
|
|
142
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// GMAIL'S RECEIVING SERVER'S FRONT: each command a connection sends, run as RFC 5321 orders it and Gmail's MX answers
|
|
2
|
+
// it. The byte-stream door (../stream.ts) frames a connection's bytes and posts each command (or a message's whole data)
|
|
3
|
+
// here with the session it carries; the answer is the reply lines, the moved session, and what the stream reads next.
|
|
4
|
+
// Every other request is the webmail's (the screens).
|
|
5
|
+
//
|
|
6
|
+
// The server takes mail from any sender, as an MX does, and refuses a recipient it does not hold: an address at another
|
|
7
|
+
// domain (it relays for no one) or a gmail.com address that is no Google account (550 5.1.1). It asks no authentication
|
|
8
|
+
// and has no certificate here, so it advertises neither AUTH nor STARTTLS (ADR 0032 §4).
|
|
9
|
+
import { smtpServer } from '@volter/world-core';
|
|
10
|
+
import { accountOf, BRIDGE, BYE, deliver, GO_AHEAD, GREETING, HOST, MAIL_DOMAIN, MAX_SIZE, NO_ACCOUNT, NO_TLS, NOT_OURS, OK, OK_MAIL, OK_RCPT, SYNTAX, TOO_BIG, UNRECOGNIZED, unsupported, } from "./shared.js";
|
|
11
|
+
const { smtpCleared, smtpCommand, smtpDomain: domainOf, smtpEhlo, smtpFront, smtpMailFrom, smtpOrder, smtpRcptTo } = smtpServer;
|
|
12
|
+
const cmd = (session, ...replies) => ({ replies, session, mode: 'command' });
|
|
13
|
+
/** The machine's word on a command from where the session stands: a refusal's reply, or undefined to go on. */
|
|
14
|
+
const order = (ctx, s, op, to) => smtpOrder(ctx, 'Session', s.stage, op, to);
|
|
15
|
+
/** A transaction's state cleared, the greeting kept (RSET, a new EHLO, the end of DATA). */
|
|
16
|
+
const cleared = (s, stage) => smtpCleared(s, stage, {});
|
|
17
|
+
function command(ctx, s, line) {
|
|
18
|
+
const { verb, arg } = smtpCommand(line);
|
|
19
|
+
switch (verb) {
|
|
20
|
+
default: return cmd(s, UNRECOGNIZED);
|
|
21
|
+
case 'EHLO':
|
|
22
|
+
case 'HELO': {
|
|
23
|
+
if (!arg.trim())
|
|
24
|
+
return cmd(s, `501 5.5.4 Empty ${verb} argument not allowed, closing connection. - gsmtp`);
|
|
25
|
+
order(ctx, s, verb, verb.toLowerCase());
|
|
26
|
+
const next = cleared(s, verb.toLowerCase());
|
|
27
|
+
if (verb === 'HELO')
|
|
28
|
+
return cmd(next, `250 ${HOST} at your service`);
|
|
29
|
+
return { replies: smtpEhlo([`${HOST} at your service`, `SIZE ${MAX_SIZE}`, '8BITMIME', 'ENHANCEDSTATUSCODES', 'PIPELINING', 'SMTPUTF8']), session: next, mode: 'command' };
|
|
30
|
+
}
|
|
31
|
+
// source: spec:4.1.1.2 "This command clears the reverse-path buffer, the forward-path buffer, and the mail data buffer"
|
|
32
|
+
case 'MAIL': {
|
|
33
|
+
const refused = order(ctx, s, 'MAIL', 'mail');
|
|
34
|
+
if (refused)
|
|
35
|
+
return cmd(s, refused);
|
|
36
|
+
const mail = smtpMailFrom(arg);
|
|
37
|
+
if ('refused' in mail)
|
|
38
|
+
return cmd(s, 'parameter' in mail ? unsupported(mail.parameter) : SYNTAX);
|
|
39
|
+
if (mail.size !== undefined && mail.size > MAX_SIZE)
|
|
40
|
+
return cmd(s, TOO_BIG);
|
|
41
|
+
return cmd({ ...s, stage: 'mail', from: mail.from.toLowerCase(), rcpt: [], ...(mail.smtputf8 ? { smtputf8: true } : {}) }, OK_MAIL);
|
|
42
|
+
}
|
|
43
|
+
// source: spec:4.1.1.3 "This command is used to identify an individual recipient of the mail data; multiple recipients are specified by multiple uses of this command."
|
|
44
|
+
case 'RCPT': {
|
|
45
|
+
const refused = order(ctx, s, 'RCPT', 'rcpt');
|
|
46
|
+
if (refused)
|
|
47
|
+
return cmd(s, refused);
|
|
48
|
+
const rcpt = smtpRcptTo(arg);
|
|
49
|
+
if ('refused' in rcpt)
|
|
50
|
+
return cmd(s, 'parameter' in rcpt ? unsupported(rcpt.parameter) : SYNTAX);
|
|
51
|
+
const to = rcpt.to.toLowerCase();
|
|
52
|
+
if (domainOf(to) !== MAIL_DOMAIN)
|
|
53
|
+
return cmd(s, NOT_OURS);
|
|
54
|
+
if (!accountOf(ctx, to))
|
|
55
|
+
return cmd(s, NO_ACCOUNT);
|
|
56
|
+
return cmd({ ...s, stage: 'rcpt', rcpt: [...s.rcpt, to] }, OK_RCPT);
|
|
57
|
+
}
|
|
58
|
+
// source: spec:4.1.1.4 "The receiver normally sends a 354 response to DATA"
|
|
59
|
+
case 'DATA': {
|
|
60
|
+
const refused = order(ctx, s, 'DATA', 'data');
|
|
61
|
+
if (refused)
|
|
62
|
+
return cmd(s, refused);
|
|
63
|
+
return { replies: [GO_AHEAD], session: { ...s, stage: 'data' }, mode: 'data' };
|
|
64
|
+
}
|
|
65
|
+
// source: spec:4.1.1.5 "This command specifies that the current mail transaction will be aborted."
|
|
66
|
+
case 'RSET': {
|
|
67
|
+
const back = s.greeted ?? 'fresh';
|
|
68
|
+
if (s.stage === 'mail' || s.stage === 'rcpt')
|
|
69
|
+
order(ctx, s, 'RSET', back);
|
|
70
|
+
return cmd(cleared(s, s.stage === 'mail' || s.stage === 'rcpt' ? back : s.stage), OK);
|
|
71
|
+
}
|
|
72
|
+
// source: spec:4.1.1.9 "This command does not affect any parameters or previously entered commands."
|
|
73
|
+
case 'NOOP': return cmd(s, OK);
|
|
74
|
+
// source: spec:4.1.1.10 "The QUIT command may be issued at any time."
|
|
75
|
+
case 'QUIT':
|
|
76
|
+
order(ctx, s, 'QUIT', 'closed');
|
|
77
|
+
return { replies: [BYE], session: { ...s, stage: 'closed' }, mode: 'command', close: true };
|
|
78
|
+
case 'STARTTLS': return cmd(s, NO_TLS);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The end of a message's data: its size held against the limit, the message kept in each recipient's mailbox. */
|
|
82
|
+
async function endOfData(ctx, s, data) {
|
|
83
|
+
const back = s.greeted ?? 'ehlo';
|
|
84
|
+
order(ctx, s, 'DATA', back);
|
|
85
|
+
const next = cleared(s, back);
|
|
86
|
+
if (data.length > MAX_SIZE)
|
|
87
|
+
return cmd(next, TOO_BIG);
|
|
88
|
+
await deliver(ctx, { from: s.from ?? '', to: s.rcpt, data, smtputf8: s.smtputf8 === true });
|
|
89
|
+
return cmd(next, OK);
|
|
90
|
+
}
|
|
91
|
+
export async function around(ctx, next) {
|
|
92
|
+
return smtpFront(ctx, next, BRIDGE, {
|
|
93
|
+
open: () => ({ replies: [GREETING], session: BRIDGE.fresh(), mode: 'command' }),
|
|
94
|
+
command: (s, line) => command(ctx, s, line),
|
|
95
|
+
data: (s, data) => endOfData(ctx, s, data),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const gmailSemantics = {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type HandlerContext, type smtpServer } from '@volter/world-core';
|
|
2
|
+
export type Row = Record<string, unknown>;
|
|
3
|
+
export declare const MESSAGE = "message";
|
|
4
|
+
/** The domain whose accounts this server receives mail for: the descriptor's host rule names it, so the kernel's
|
|
5
|
+
* ctx.mail hands its mail here. */
|
|
6
|
+
export declare const MAIL_DOMAIN = "gmail.com";
|
|
7
|
+
/** A session as its connection carries it between commands. */
|
|
8
|
+
export type Session = {
|
|
9
|
+
stage: string;
|
|
10
|
+
from?: string;
|
|
11
|
+
rcpt: string[];
|
|
12
|
+
smtputf8?: boolean;
|
|
13
|
+
greeted?: 'helo' | 'ehlo';
|
|
14
|
+
};
|
|
15
|
+
/** What the front answers the stream: the reply lines, the session, and whether the stream reads a message next. */
|
|
16
|
+
export type WireAnswer = smtpServer.SmtpAnswer<Session>;
|
|
17
|
+
export declare const HOST = "mx.google.com";
|
|
18
|
+
export declare const GREETING = "220 mx.google.com ESMTP - gsmtp";
|
|
19
|
+
export declare const MAX_SIZE = 157286400;
|
|
20
|
+
export declare const OK_MAIL = "250 2.1.0 OK - gsmtp";
|
|
21
|
+
export declare const OK_RCPT = "250 2.1.5 OK - gsmtp";
|
|
22
|
+
export declare const OK = "250 2.0.0 OK - gsmtp";
|
|
23
|
+
export declare const GO_AHEAD = "354 Go ahead - gsmtp";
|
|
24
|
+
export declare const BYE = "221 2.0.0 closing connection - gsmtp";
|
|
25
|
+
export declare const UNRECOGNIZED = "502 5.5.1 Unrecognized command. - gsmtp";
|
|
26
|
+
export declare const NO_ACCOUNT = "550 5.1.1 The email account that you tried to reach does not exist. - gsmtp";
|
|
27
|
+
export declare const NOT_OURS = "550 5.7.1 Relaying denied. - gsmtp";
|
|
28
|
+
export declare const TOO_BIG = "552 5.3.4 Your message exceeded Google's message size limits. - gsmtp";
|
|
29
|
+
export declare const NO_TLS = "454 4.7.0 TLS not available - gsmtp";
|
|
30
|
+
export declare const unsupported: (arg: string) => string;
|
|
31
|
+
export declare const SYNTAX = "501 5.5.2 Syntax error. - gsmtp";
|
|
32
|
+
/** The bridge between a connection and the server's front (world-core's SMTP server machine): the header the stream
|
|
33
|
+
* signs its requests with, a key made once per process so a request from outside it (the webmail's port) cannot speak
|
|
34
|
+
* for a connection, a fresh session, and the server's local-error reply. */
|
|
35
|
+
export declare const BRIDGE: smtpServer.SmtpBridge<Session>;
|
|
36
|
+
type Reader = Pick<HandlerContext, 'ownerRow'>;
|
|
37
|
+
/** The mailbox an address names: the address of the Google account whose sign-up recorded its person (googleoauth's
|
|
38
|
+
* `_person`, keyed `person:<email>`, with the account's `sub`, whose `account` stands); a `+tag` reaches the same
|
|
39
|
+
* mailbox, as Gmail delivers it. Where the
|
|
40
|
+
* documentation stops: Gmail also ignores dots in the local part; the twin matches the address as signed up. */
|
|
41
|
+
export declare function accountOf(ctx: Reader, address: string): string | undefined;
|
|
42
|
+
/** A message accepted: kept once in each recipient's mailbox, with its envelope, its bytes and what they parse to. */
|
|
43
|
+
export declare function deliver(ctx: HandlerContext, e: {
|
|
44
|
+
from: string;
|
|
45
|
+
to: string[];
|
|
46
|
+
data: string;
|
|
47
|
+
smtputf8: boolean;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
/** The mailbox of the Google account a request's browser is signed in to: its `SID` cookie names a session googleoauth
|
|
50
|
+
* recorded (`_web_session`, keyed `websession:<token>`), whose person is the account's. */
|
|
51
|
+
export declare function signedInAccount(ctx: HandlerContext): string | undefined;
|
|
52
|
+
export {};
|