@pellux/goodvibes-tui 0.24.1 → 0.26.0
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/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared email runtime: credential-backed settings resolution, the IMAP/SMTP
|
|
3
|
+
// connector lifecycle helpers, and the encrypt-at-rest draft store. Built once
|
|
4
|
+
// by index.ts and threaded into the read/write handler builders so the surface
|
|
5
|
+
// has a single owner for its stateful resources (lazy store, AES key).
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import type { HandlerContext, HandlerLogger } from '../context.ts';
|
|
9
|
+
import type { DaemonCredentialStore, AtRestCipher } from '../credentials.ts';
|
|
10
|
+
import { createAtRestCipher } from '../credentials.ts';
|
|
11
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
12
|
+
import {
|
|
13
|
+
resolveEmailSettings,
|
|
14
|
+
defaultImapFactory,
|
|
15
|
+
defaultSmtpFactory,
|
|
16
|
+
type EmailMethodsOptions,
|
|
17
|
+
type ImapClient,
|
|
18
|
+
type ImapFactory,
|
|
19
|
+
type SmtpClient,
|
|
20
|
+
type SmtpFactory,
|
|
21
|
+
} from './config.ts';
|
|
22
|
+
|
|
23
|
+
const DRAFT_STORE_FILE = 'email-drafts.sqlite';
|
|
24
|
+
|
|
25
|
+
const DRAFT_SCHEMA: string[] = [
|
|
26
|
+
`CREATE TABLE IF NOT EXISTS email_drafts (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
surface TEXT NOT NULL,
|
|
29
|
+
recipient TEXT,
|
|
30
|
+
subject TEXT,
|
|
31
|
+
body_ciphertext TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL,
|
|
33
|
+
created_at TEXT NOT NULL,
|
|
34
|
+
updated_at TEXT NOT NULL,
|
|
35
|
+
metadata TEXT
|
|
36
|
+
);`,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** A persisted draft. The plaintext body is NEVER stored — only its ciphertext. */
|
|
40
|
+
export interface DraftPersistInput {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly to: string;
|
|
43
|
+
readonly subject: string;
|
|
44
|
+
readonly plaintextBody: string;
|
|
45
|
+
readonly status: string;
|
|
46
|
+
readonly createdAt: string;
|
|
47
|
+
readonly updatedAt: string;
|
|
48
|
+
readonly metadata?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Owner of the email surface's stateful resources. Read and write handler
|
|
53
|
+
* builders receive this instead of reaching into the catalog/context directly.
|
|
54
|
+
*/
|
|
55
|
+
export interface EmailRuntime {
|
|
56
|
+
readonly logger: HandlerLogger;
|
|
57
|
+
/** Run `fn` with a connected IMAP client; always closes it afterward. */
|
|
58
|
+
withImap<T>(fn: (imap: ImapClient) => Promise<T>): Promise<T>;
|
|
59
|
+
/** Open (or reuse) a connected SMTP client. Caller closes it. */
|
|
60
|
+
openSmtp(): Promise<SmtpClient>;
|
|
61
|
+
/** Resolve the SMTP From / Message-ID domain context without exposing secrets. */
|
|
62
|
+
smtpFrom(): Promise<string>;
|
|
63
|
+
/** Encrypt + persist a draft record (body stored only as ciphertext). */
|
|
64
|
+
persistDraft(input: DraftPersistInput): Promise<void>;
|
|
65
|
+
/** Tear down lazily-created resources (draft store handle). */
|
|
66
|
+
dispose(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createEmailRuntime(
|
|
70
|
+
ctx: HandlerContext,
|
|
71
|
+
options: EmailMethodsOptions = {},
|
|
72
|
+
): EmailRuntime {
|
|
73
|
+
const credentials: DaemonCredentialStore = ctx.credentials;
|
|
74
|
+
const cipher: AtRestCipher = createAtRestCipher(credentials);
|
|
75
|
+
const imapFactory: ImapFactory = options.imapFactory ?? defaultImapFactory;
|
|
76
|
+
const smtpFactory: SmtpFactory = options.smtpFactory ?? defaultSmtpFactory;
|
|
77
|
+
const workingDirectory = options.workingDirectory ?? ctx.workingDirectory;
|
|
78
|
+
|
|
79
|
+
let draftStorePromise: Promise<HandlerSqliteStore> | null = null;
|
|
80
|
+
const getDraftStore = (): Promise<HandlerSqliteStore> => {
|
|
81
|
+
if (!draftStorePromise) {
|
|
82
|
+
const store = new HandlerSqliteStore({
|
|
83
|
+
workingDirectory,
|
|
84
|
+
fileName: DRAFT_STORE_FILE,
|
|
85
|
+
schema: DRAFT_SCHEMA,
|
|
86
|
+
});
|
|
87
|
+
draftStorePromise = store.init().then(() => store);
|
|
88
|
+
}
|
|
89
|
+
return draftStorePromise;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
logger: ctx.logger,
|
|
94
|
+
async withImap<T>(fn: (imap: ImapClient) => Promise<T>): Promise<T> {
|
|
95
|
+
const { imap: settings } = await resolveEmailSettings(ctx.configManager, credentials);
|
|
96
|
+
const imap = await imapFactory(settings);
|
|
97
|
+
try {
|
|
98
|
+
return await fn(imap);
|
|
99
|
+
} finally {
|
|
100
|
+
await imap.close();
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async openSmtp(): Promise<SmtpClient> {
|
|
104
|
+
const { smtp: settings } = await resolveEmailSettings(ctx.configManager, credentials);
|
|
105
|
+
return smtpFactory(settings);
|
|
106
|
+
},
|
|
107
|
+
async smtpFrom(): Promise<string> {
|
|
108
|
+
const { smtp } = await resolveEmailSettings(ctx.configManager, credentials);
|
|
109
|
+
return smtp.from;
|
|
110
|
+
},
|
|
111
|
+
async persistDraft(input: DraftPersistInput): Promise<void> {
|
|
112
|
+
const bodyCiphertext = await cipher.encrypt(input.plaintextBody);
|
|
113
|
+
const store = await getDraftStore();
|
|
114
|
+
store.run(
|
|
115
|
+
`INSERT OR REPLACE INTO email_drafts
|
|
116
|
+
(id, surface, recipient, subject, body_ciphertext, status,
|
|
117
|
+
created_at, updated_at, metadata)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
119
|
+
[
|
|
120
|
+
input.id,
|
|
121
|
+
'email',
|
|
122
|
+
input.to,
|
|
123
|
+
input.subject,
|
|
124
|
+
bodyCiphertext,
|
|
125
|
+
input.status,
|
|
126
|
+
input.createdAt,
|
|
127
|
+
input.updatedAt,
|
|
128
|
+
input.metadata ? JSON.stringify(input.metadata) : null,
|
|
129
|
+
],
|
|
130
|
+
);
|
|
131
|
+
await store.save();
|
|
132
|
+
},
|
|
133
|
+
dispose(): void {
|
|
134
|
+
if (draftStorePromise) {
|
|
135
|
+
void draftStorePromise.then((store) => store.close()).catch(() => undefined);
|
|
136
|
+
draftStorePromise = null;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-owned SMTP connector.
|
|
3
|
+
//
|
|
4
|
+
// A dependency-free SMTP client built on node:tls / node:net (Bun-compatible).
|
|
5
|
+
// Supports implicit TLS (port 465), STARTTLS upgrade (port 587/25), and
|
|
6
|
+
// AUTH LOGIN / AUTH PLAIN. The daemon owns this connector outright — it does
|
|
7
|
+
// NOT import any agent code. Credentials are supplied by the caller (resolved
|
|
8
|
+
// from the daemon credential store) and are never logged.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
12
|
+
import type { TLSSocket } from 'node:tls';
|
|
13
|
+
import { Socket } from 'node:net';
|
|
14
|
+
import { once } from 'node:events';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export interface SmtpConnectionSettings {
|
|
18
|
+
readonly host: string;
|
|
19
|
+
readonly port: number;
|
|
20
|
+
readonly user: string;
|
|
21
|
+
readonly password: string;
|
|
22
|
+
/** Implicit TLS (port 465). When false, STARTTLS is attempted on plain socket. */
|
|
23
|
+
readonly secure: boolean;
|
|
24
|
+
/** Envelope + From header address. */
|
|
25
|
+
readonly from: string;
|
|
26
|
+
/** EHLO domain. Defaults to the host portion of `from` or 'localhost'. */
|
|
27
|
+
readonly ehloDomain?: string;
|
|
28
|
+
/** Command timeout in ms. Defaults to 30000. */
|
|
29
|
+
readonly timeoutMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Permit sending over a plaintext channel when `secure` is false and the
|
|
32
|
+
* server does not advertise STARTTLS (trusted local relays only). Defaults to
|
|
33
|
+
* false: an un-upgradable plaintext connection is rejected to avoid sending
|
|
34
|
+
* credentials in the clear.
|
|
35
|
+
*/
|
|
36
|
+
readonly allowPlaintext?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SmtpMessage {
|
|
40
|
+
readonly to: string;
|
|
41
|
+
readonly subject: string;
|
|
42
|
+
readonly body: string;
|
|
43
|
+
readonly inReplyTo?: string;
|
|
44
|
+
readonly references?: string;
|
|
45
|
+
/** Optional explicit Message-ID; one is generated when omitted. */
|
|
46
|
+
readonly messageId?: string;
|
|
47
|
+
/** Optional explicit Date; current time used when omitted. */
|
|
48
|
+
readonly date?: Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SmtpSendResult {
|
|
52
|
+
readonly messageId: string;
|
|
53
|
+
readonly sentAt: string; // ISO-8601
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class SmtpError extends Error {
|
|
57
|
+
readonly code: string;
|
|
58
|
+
constructor(message: string, code = 'SMTP_ERROR') {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'SmtpError';
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CRLF = '\r\n';
|
|
66
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
67
|
+
|
|
68
|
+
type AnySocket = Socket | TLSSocket;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the TLS SNI option for a host. Node forbids setting `servername` to an
|
|
72
|
+
* IP literal (ERR_INVALID_ARG_VALUE), so SNI is omitted for IPv4/IPv6 hosts and
|
|
73
|
+
* supplied only for DNS names — which is exactly the case where SNI matters.
|
|
74
|
+
*/
|
|
75
|
+
function sniOptions(host: string): { servername?: string } {
|
|
76
|
+
return isIpLiteral(host) ? {} : { servername: host };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when `host` is a bare IPv4 or IPv6 literal (not a DNS hostname). */
|
|
80
|
+
function isIpLiteral(host: string): boolean {
|
|
81
|
+
// IPv6 contains a colon; IPv4 is four dot-separated decimal octets.
|
|
82
|
+
if (host.includes(':')) return true;
|
|
83
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class SmtpConnector {
|
|
87
|
+
private socket: AnySocket | null = null;
|
|
88
|
+
private buffer = '';
|
|
89
|
+
private dataWaiters: Array<() => void> = [];
|
|
90
|
+
private closed = false;
|
|
91
|
+
/**
|
|
92
|
+
* When the connection is torn down by a timeout (or other socket error), the
|
|
93
|
+
* triggering SmtpError is recorded here so that readers blocked in readReply()
|
|
94
|
+
* surface the real cause (e.g. SMTP_TIMEOUT) instead of a generic SMTP_CLOSED.
|
|
95
|
+
*/
|
|
96
|
+
private closeReason: SmtpError | null = null;
|
|
97
|
+
private capabilities = new Set<string>();
|
|
98
|
+
private readonly settings: SmtpConnectionSettings;
|
|
99
|
+
private readonly timeoutMs: number;
|
|
100
|
+
|
|
101
|
+
constructor(settings: SmtpConnectionSettings) {
|
|
102
|
+
if (!settings.host) throw new SmtpError('SMTP host is required', 'SMTP_CONFIG');
|
|
103
|
+
if (!settings.user) throw new SmtpError('SMTP user is required', 'SMTP_CONFIG');
|
|
104
|
+
if (!settings.password) throw new SmtpError('SMTP password is required', 'SMTP_CONFIG');
|
|
105
|
+
if (!settings.from) throw new SmtpError('SMTP from address is required', 'SMTP_CONFIG');
|
|
106
|
+
this.settings = settings;
|
|
107
|
+
this.timeoutMs = settings.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// Connection lifecycle
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
async connect(): Promise<void> {
|
|
115
|
+
const { host, port, secure } = this.settings;
|
|
116
|
+
const socket: AnySocket = secure
|
|
117
|
+
? (tlsConnect({ host, port, ...sniOptions(host) }) as TLSSocket)
|
|
118
|
+
: new Socket();
|
|
119
|
+
this.attach(socket);
|
|
120
|
+
if (secure) {
|
|
121
|
+
await this.waitForEvent(socket, 'secureConnect');
|
|
122
|
+
} else {
|
|
123
|
+
(socket as Socket).connect({ host, port });
|
|
124
|
+
await this.waitForEvent(socket, 'connect');
|
|
125
|
+
}
|
|
126
|
+
// Greeting (220).
|
|
127
|
+
await this.expect([220]);
|
|
128
|
+
await this.ehlo();
|
|
129
|
+
if (!secure) {
|
|
130
|
+
await this.startTls();
|
|
131
|
+
}
|
|
132
|
+
await this.authenticate();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private attach(socket: AnySocket): void {
|
|
136
|
+
socket.setEncoding('utf-8');
|
|
137
|
+
socket.setTimeout(this.timeoutMs);
|
|
138
|
+
this.socket = socket;
|
|
139
|
+
this.buffer = '';
|
|
140
|
+
socket.on('data', (chunk: string | Buffer) => {
|
|
141
|
+
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
|
142
|
+
this.flushWaiters();
|
|
143
|
+
});
|
|
144
|
+
socket.on('timeout', () => {
|
|
145
|
+
// Node's socket-level idle timeout fires no callback on its own; wiring
|
|
146
|
+
// this handler turns the setTimeout() above into real behavior. Destroying
|
|
147
|
+
// with an SmtpError routes through the 'error' handler, which records the
|
|
148
|
+
// close reason so blocked readers see SMTP_TIMEOUT.
|
|
149
|
+
socket.destroy(new SmtpError('SMTP socket timed out', 'SMTP_TIMEOUT'));
|
|
150
|
+
});
|
|
151
|
+
socket.on('close', () => { this.closed = true; this.flushWaiters(); });
|
|
152
|
+
socket.on('error', (err: Error) => {
|
|
153
|
+
this.closed = true;
|
|
154
|
+
if (!this.closeReason && err instanceof SmtpError) this.closeReason = err;
|
|
155
|
+
this.flushWaiters();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async waitForEvent(socket: AnySocket, eventName: string): Promise<void> {
|
|
160
|
+
const timer = this.armTimeout(`Timed out waiting for ${eventName}`);
|
|
161
|
+
try {
|
|
162
|
+
await once(socket, eventName);
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private armTimeout(message: string): NodeJS.Timeout {
|
|
169
|
+
return setTimeout(() => {
|
|
170
|
+
this.closed = true;
|
|
171
|
+
const reason = new SmtpError(message, 'SMTP_TIMEOUT');
|
|
172
|
+
// Record the reason before destroy() so the synchronous 'error' handler and
|
|
173
|
+
// any reader re-evaluating `this.closed` observe SMTP_TIMEOUT, not SMTP_CLOSED.
|
|
174
|
+
if (!this.closeReason) this.closeReason = reason;
|
|
175
|
+
if (this.socket) this.socket.destroy(reason);
|
|
176
|
+
this.flushWaiters();
|
|
177
|
+
}, this.timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async close(): Promise<void> {
|
|
181
|
+
if (!this.closed && this.socket) {
|
|
182
|
+
try {
|
|
183
|
+
await this.command('QUIT', [221]);
|
|
184
|
+
} catch {
|
|
185
|
+
// best-effort
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (this.socket) {
|
|
189
|
+
this.socket.removeAllListeners('data');
|
|
190
|
+
this.socket.destroy();
|
|
191
|
+
}
|
|
192
|
+
this.socket = null;
|
|
193
|
+
this.closed = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
// Wire I/O
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
private write(line: string): void {
|
|
201
|
+
if (!this.socket || this.closed) {
|
|
202
|
+
throw new SmtpError('SMTP socket is not connected', 'SMTP_CLOSED');
|
|
203
|
+
}
|
|
204
|
+
this.socket.write(line);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private flushWaiters(): void {
|
|
208
|
+
const waiters = this.dataWaiters;
|
|
209
|
+
this.dataWaiters = [];
|
|
210
|
+
for (const resolve of waiters) resolve();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private waitForData(): Promise<void> {
|
|
214
|
+
if (this.closed) return Promise.resolve();
|
|
215
|
+
return new Promise<void>((resolve) => { this.dataWaiters.push(resolve); });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Read a complete multi-line SMTP reply (handles `250-` continuations). */
|
|
219
|
+
private async readReply(): Promise<{ code: number; lines: string[] }> {
|
|
220
|
+
const timer = this.armTimeout('Timed out waiting for SMTP reply');
|
|
221
|
+
try {
|
|
222
|
+
for (;;) {
|
|
223
|
+
const complete = extractCompleteReply(this.buffer);
|
|
224
|
+
if (complete) {
|
|
225
|
+
this.buffer = this.buffer.slice(complete.consumed);
|
|
226
|
+
return { code: complete.code, lines: complete.lines };
|
|
227
|
+
}
|
|
228
|
+
if (this.closed) {
|
|
229
|
+
throw this.closeReason ?? new SmtpError('Connection closed during reply', 'SMTP_CLOSED');
|
|
230
|
+
}
|
|
231
|
+
await this.waitForData();
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async expect(codes: number[]): Promise<{ code: number; lines: string[] }> {
|
|
239
|
+
const reply = await this.readReply();
|
|
240
|
+
if (!codes.includes(reply.code)) {
|
|
241
|
+
throw new SmtpError(
|
|
242
|
+
`Unexpected SMTP reply ${reply.code}: ${reply.lines.join(' ')}`,
|
|
243
|
+
`SMTP_${reply.code}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return reply;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async command(line: string, expectCodes: number[]): Promise<{ code: number; lines: string[] }> {
|
|
250
|
+
this.write(line + CRLF);
|
|
251
|
+
return this.expect(expectCodes);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
// Handshake
|
|
256
|
+
// -------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
private ehloName(): string {
|
|
259
|
+
if (this.settings.ehloDomain) return this.settings.ehloDomain;
|
|
260
|
+
const at = this.settings.from.indexOf('@');
|
|
261
|
+
if (at >= 0) {
|
|
262
|
+
const domain = this.settings.from.slice(at + 1).replace(/>.*/, '').trim();
|
|
263
|
+
if (domain) return domain;
|
|
264
|
+
}
|
|
265
|
+
return 'localhost';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async ehlo(): Promise<void> {
|
|
269
|
+
const reply = await this.command(`EHLO ${this.ehloName()}`, [250]);
|
|
270
|
+
this.capabilities = new Set(
|
|
271
|
+
reply.lines.map((l) => l.replace(/^\d{3}[ -]/, '').trim().toUpperCase()),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async startTls(): Promise<void> {
|
|
276
|
+
if (!this.hasCapability('STARTTLS')) {
|
|
277
|
+
if (this.settings.allowPlaintext) return; // Trusted local relay; stay plaintext.
|
|
278
|
+
throw new SmtpError('SMTP server does not advertise STARTTLS', 'SMTP_NO_STARTTLS');
|
|
279
|
+
}
|
|
280
|
+
await this.command('STARTTLS', [220]);
|
|
281
|
+
const plain = this.socket as Socket;
|
|
282
|
+
// Remove ALL listeners attached by attach() (data, close, error) before the
|
|
283
|
+
// upgrade. The plain socket becomes the underlying socket of the TLSSocket;
|
|
284
|
+
// any stale 'close'/'error' handlers left here could fire post-upgrade and
|
|
285
|
+
// erroneously set this.closed = true or double-flush waiters, corrupting
|
|
286
|
+
// connection state mid-session.
|
|
287
|
+
plain.removeAllListeners();
|
|
288
|
+
const upgraded = tlsConnect({ socket: plain, ...sniOptions(this.settings.host) }) as TLSSocket;
|
|
289
|
+
this.attach(upgraded);
|
|
290
|
+
await this.waitForEvent(upgraded, 'secureConnect');
|
|
291
|
+
// Re-issue EHLO over the secure channel.
|
|
292
|
+
await this.ehlo();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private hasCapability(name: string): boolean {
|
|
296
|
+
const upper = name.toUpperCase();
|
|
297
|
+
for (const cap of this.capabilities) {
|
|
298
|
+
if (cap === upper || cap.startsWith(`${upper} `)) return true;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async authenticate(): Promise<void> {
|
|
304
|
+
const { user, password } = this.settings;
|
|
305
|
+
if (this.authMechanism('PLAIN')) {
|
|
306
|
+
const token = Buffer.from(`${user}${password}`, 'utf-8').toString('base64');
|
|
307
|
+
await this.command(`AUTH PLAIN ${token}`, [235]);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (this.authMechanism('LOGIN')) {
|
|
311
|
+
await this.command('AUTH LOGIN', [334]);
|
|
312
|
+
await this.command(Buffer.from(user, 'utf-8').toString('base64'), [334]);
|
|
313
|
+
await this.command(Buffer.from(password, 'utf-8').toString('base64'), [235]);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw new SmtpError('No supported SMTP AUTH mechanism (PLAIN/LOGIN)', 'SMTP_NO_AUTH');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private authMechanism(name: string): boolean {
|
|
320
|
+
const upper = name.toUpperCase();
|
|
321
|
+
for (const cap of this.capabilities) {
|
|
322
|
+
if (!cap.toUpperCase().startsWith('AUTH ')) continue;
|
|
323
|
+
// Capability is a space-delimited list of mechanisms, e.g. "AUTH PLAIN LOGIN CRAM-MD5".
|
|
324
|
+
// Require exact token membership so a mechanism that merely contains the
|
|
325
|
+
// substring (e.g. "XPLAIN" vs "PLAIN") does not falsely match.
|
|
326
|
+
const mechanisms = cap.slice('AUTH '.length).toUpperCase().split(/\s+/).filter(Boolean);
|
|
327
|
+
if (mechanisms.includes(upper)) return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -------------------------------------------------------------------------
|
|
333
|
+
// Send
|
|
334
|
+
// -------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
async send(message: SmtpMessage): Promise<SmtpSendResult> {
|
|
337
|
+
const recipients = parseRecipients(message.to);
|
|
338
|
+
if (recipients.length === 0) {
|
|
339
|
+
throw new SmtpError('At least one recipient is required', 'SMTP_NO_RCPT');
|
|
340
|
+
}
|
|
341
|
+
const date = message.date ?? new Date();
|
|
342
|
+
const messageId = message.messageId ?? generateMessageId(this.settings.from);
|
|
343
|
+
const raw = buildRfc5322Message({
|
|
344
|
+
from: this.settings.from,
|
|
345
|
+
to: message.to,
|
|
346
|
+
subject: message.subject,
|
|
347
|
+
body: message.body,
|
|
348
|
+
inReplyTo: message.inReplyTo,
|
|
349
|
+
references: message.references,
|
|
350
|
+
messageId,
|
|
351
|
+
date,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await this.command(`MAIL FROM:<${extractAddress(this.settings.from)}>`, [250]);
|
|
355
|
+
for (const rcpt of recipients) {
|
|
356
|
+
await this.command(`RCPT TO:<${rcpt}>`, [250, 251]);
|
|
357
|
+
}
|
|
358
|
+
await this.command('DATA', [354]);
|
|
359
|
+
// Dot-stuff and terminate with <CRLF>.<CRLF>.
|
|
360
|
+
const stuffed = dotStuff(raw.replace(/\r?\n/g, CRLF));
|
|
361
|
+
this.write(stuffed + CRLF + '.' + CRLF);
|
|
362
|
+
await this.expect([250]);
|
|
363
|
+
|
|
364
|
+
return { messageId, sentAt: date.toISOString() };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Pure helpers (exported for unit testing)
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
/** Parse a complete SMTP reply from a buffer, if one is present. */
|
|
373
|
+
export function extractCompleteReply(
|
|
374
|
+
buffer: string,
|
|
375
|
+
): { code: number; lines: string[]; consumed: number } | null {
|
|
376
|
+
const lines: string[] = [];
|
|
377
|
+
let consumed = 0;
|
|
378
|
+
let cursor = 0;
|
|
379
|
+
for (;;) {
|
|
380
|
+
const idx = buffer.indexOf(CRLF, cursor);
|
|
381
|
+
if (idx < 0) return null;
|
|
382
|
+
const line = buffer.slice(cursor, idx);
|
|
383
|
+
lines.push(line);
|
|
384
|
+
cursor = idx + CRLF.length;
|
|
385
|
+
// Final line of a reply has a space at position 3 (e.g. '250 OK');
|
|
386
|
+
// continuation lines have a hyphen ('250-...').
|
|
387
|
+
if (/^\d{3} /.test(line)) {
|
|
388
|
+
consumed = cursor;
|
|
389
|
+
const code = Number(line.slice(0, 3));
|
|
390
|
+
return { code, lines, consumed };
|
|
391
|
+
}
|
|
392
|
+
if (!/^\d{3}-/.test(line)) {
|
|
393
|
+
// Malformed line — treat as terminal to avoid a hang.
|
|
394
|
+
consumed = cursor;
|
|
395
|
+
const code = Number(line.slice(0, 3)) || 0;
|
|
396
|
+
return { code, lines, consumed };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Extract a bare email address from a possibly display-named header value. */
|
|
402
|
+
export function extractAddress(value: string): string {
|
|
403
|
+
const angle = /<([^>]+)>/.exec(value);
|
|
404
|
+
if (angle) return angle[1].trim();
|
|
405
|
+
return value.trim();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Parse a comma-separated recipient list into bare addresses. */
|
|
409
|
+
export function parseRecipients(value: string): string[] {
|
|
410
|
+
return value
|
|
411
|
+
.split(',')
|
|
412
|
+
.map((part) => extractAddress(part))
|
|
413
|
+
.filter((addr) => addr.length > 0 && addr.includes('@'));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Dot-stuff a message body: lines beginning with '.' get an extra leading '.'. */
|
|
417
|
+
export function dotStuff(message: string): string {
|
|
418
|
+
return message.replace(/(^|\r\n)\./g, '$1..');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function generateMessageId(from: string): string {
|
|
422
|
+
const domain = (() => {
|
|
423
|
+
const at = extractAddress(from).indexOf('@');
|
|
424
|
+
return at >= 0 ? extractAddress(from).slice(at + 1) : 'localhost';
|
|
425
|
+
})();
|
|
426
|
+
const random = randomBytes(16).toString('hex');
|
|
427
|
+
return `<${Date.now()}.${random}@${domain}>`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface Rfc5322Parts {
|
|
431
|
+
readonly from: string;
|
|
432
|
+
readonly to: string;
|
|
433
|
+
readonly subject: string;
|
|
434
|
+
readonly body: string;
|
|
435
|
+
readonly messageId: string;
|
|
436
|
+
readonly date: Date;
|
|
437
|
+
readonly inReplyTo?: string;
|
|
438
|
+
readonly references?: string;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Build an RFC 5322 message with a UTF-8 quoted-printable text/plain body. */
|
|
442
|
+
export function buildRfc5322Message(parts: Rfc5322Parts): string {
|
|
443
|
+
const headers: string[] = [];
|
|
444
|
+
headers.push(`From: ${parts.from}`);
|
|
445
|
+
headers.push(`To: ${parts.to}`);
|
|
446
|
+
headers.push(`Subject: ${encodeHeaderValue(parts.subject)}`);
|
|
447
|
+
headers.push(`Date: ${formatRfc2822Date(parts.date)}`);
|
|
448
|
+
headers.push(`Message-ID: ${parts.messageId}`);
|
|
449
|
+
if (parts.inReplyTo) headers.push(`In-Reply-To: ${parts.inReplyTo}`);
|
|
450
|
+
if (parts.references) headers.push(`References: ${parts.references}`);
|
|
451
|
+
headers.push('MIME-Version: 1.0');
|
|
452
|
+
headers.push('Content-Type: text/plain; charset=utf-8');
|
|
453
|
+
headers.push('Content-Transfer-Encoding: quoted-printable');
|
|
454
|
+
const body = encodeQuotedPrintable(parts.body);
|
|
455
|
+
return headers.join(CRLF) + CRLF + CRLF + body;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** RFC 2047 encode a header value when it contains non-ASCII characters. */
|
|
459
|
+
export function encodeHeaderValue(value: string): string {
|
|
460
|
+
// eslint-disable-next-line no-control-regex
|
|
461
|
+
if (/^[\x00-\x7F]*$/.test(value)) return value;
|
|
462
|
+
const encoded = Buffer.from(value, 'utf-8').toString('base64');
|
|
463
|
+
return `=?UTF-8?B?${encoded}?=`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function formatRfc2822Date(date: Date): string {
|
|
467
|
+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
468
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
469
|
+
const pad = (n: number): string => String(n).padStart(2, '0');
|
|
470
|
+
const day = days[date.getUTCDay()];
|
|
471
|
+
const dd = pad(date.getUTCDate());
|
|
472
|
+
const mon = months[date.getUTCMonth()];
|
|
473
|
+
const yyyy = date.getUTCFullYear();
|
|
474
|
+
const hh = pad(date.getUTCHours());
|
|
475
|
+
const mm = pad(date.getUTCMinutes());
|
|
476
|
+
const ss = pad(date.getUTCSeconds());
|
|
477
|
+
return `${day}, ${dd} ${mon} ${yyyy} ${hh}:${mm}:${ss} +0000`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Quoted-printable encode a UTF-8 body (RFC 2045).
|
|
482
|
+
*
|
|
483
|
+
* Soft-wraps at 76 columns: a content line carries at most 75 characters
|
|
484
|
+
* followed by the `=` soft-break, so the emitted line never exceeds 76 chars
|
|
485
|
+
* (RFC 2045 §6.7 rule 5). An escape sequence (`=XX`) is treated as one
|
|
486
|
+
* indivisible chunk so a soft break can never split it.
|
|
487
|
+
*
|
|
488
|
+
* Trailing whitespace handling (RFC 2045 §6.7 rule 3): a space (0x20) or tab
|
|
489
|
+
* (0x09) that immediately precedes a hard line break, or that ends the body,
|
|
490
|
+
* MUST be encoded (`=20` / `=09`) — otherwise an intermediate MTA is permitted
|
|
491
|
+
* to strip it and corrupt the message. We defer emitting a pending whitespace
|
|
492
|
+
* byte until we know what follows: a real character flushes it literally, while
|
|
493
|
+
* a line break or end-of-input forces its encoded form.
|
|
494
|
+
*/
|
|
495
|
+
export function encodeQuotedPrintable(input: string): string {
|
|
496
|
+
const bytes = Buffer.from(input, 'utf-8');
|
|
497
|
+
let out = '';
|
|
498
|
+
let lineLen = 0;
|
|
499
|
+
// A space/tab byte awaiting disposition: emitted literally if a normal
|
|
500
|
+
// character follows, or encoded (=20/=09) if a line break / EOF follows.
|
|
501
|
+
let pendingWs: number | null = null;
|
|
502
|
+
const append = (chunk: string): void => {
|
|
503
|
+
// The soft break itself occupies a column, so a content line may hold at
|
|
504
|
+
// most 75 chars before the trailing '='. Never split a chunk (an escape
|
|
505
|
+
// sequence must stay whole), so wrap *before* appending an oversized chunk.
|
|
506
|
+
if (lineLen + chunk.length > 75) {
|
|
507
|
+
out += '=\r\n';
|
|
508
|
+
lineLen = 0;
|
|
509
|
+
}
|
|
510
|
+
out += chunk;
|
|
511
|
+
lineLen += chunk.length;
|
|
512
|
+
};
|
|
513
|
+
const encodeByte = (byte: number): string =>
|
|
514
|
+
'=' + byte.toString(16).toUpperCase().padStart(2, '0');
|
|
515
|
+
// Flush a deferred whitespace byte literally (a normal character follows it).
|
|
516
|
+
const flushPendingLiteral = (): void => {
|
|
517
|
+
if (pendingWs !== null) {
|
|
518
|
+
append(String.fromCharCode(pendingWs));
|
|
519
|
+
pendingWs = null;
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
523
|
+
const byte = bytes[i];
|
|
524
|
+
if (byte === 0x0d) continue; // CR handled with LF
|
|
525
|
+
if (byte === 0x0a) {
|
|
526
|
+
// Whitespace immediately before a hard break must be encoded, not literal.
|
|
527
|
+
if (pendingWs !== null) {
|
|
528
|
+
append(encodeByte(pendingWs));
|
|
529
|
+
pendingWs = null;
|
|
530
|
+
}
|
|
531
|
+
out += '\r\n';
|
|
532
|
+
lineLen = 0;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (byte === 0x20 || byte === 0x09) {
|
|
536
|
+
// A space/tab can be literal mid-line, so defer it. Two whitespace bytes
|
|
537
|
+
// in a row means the first is interior (safe to emit literally now).
|
|
538
|
+
flushPendingLiteral();
|
|
539
|
+
pendingWs = byte;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
// A normal byte follows the deferred whitespace: emit that whitespace as-is.
|
|
543
|
+
flushPendingLiteral();
|
|
544
|
+
const printable = byte >= 0x20 && byte <= 0x7e && byte !== 0x3d; // not '='
|
|
545
|
+
if (printable) {
|
|
546
|
+
append(String.fromCharCode(byte));
|
|
547
|
+
} else {
|
|
548
|
+
append(encodeByte(byte));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Whitespace at the very end of the body must be encoded too (rule 3).
|
|
552
|
+
if (pendingWs !== null) {
|
|
553
|
+
append(encodeByte(pendingWs));
|
|
554
|
+
pendingWs = null;
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|