@pellux/goodvibes-tui 0.24.1 → 0.25.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 +6 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/daemon/calendar/caldav-client.ts +657 -0
- package/src/daemon/calendar/ics.ts +556 -0
- package/src/daemon/calendar/index.ts +52 -0
- package/src/daemon/calendar/register.ts +527 -0
- package/src/daemon/channels/drafts/draft-store.ts +363 -0
- package/src/daemon/channels/drafts/index.ts +22 -0
- package/src/daemon/channels/drafts/register.ts +449 -0
- package/src/daemon/channels/inbox/cursor-store.ts +298 -0
- package/src/daemon/channels/inbox/index.ts +58 -0
- package/src/daemon/channels/inbox/mapping.ts +190 -0
- package/src/daemon/channels/inbox/poller.ts +155 -0
- package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
- package/src/daemon/channels/inbox/providers/discord.ts +253 -0
- package/src/daemon/channels/inbox/providers/email.ts +151 -0
- package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
- package/src/daemon/channels/inbox/providers/slack.ts +264 -0
- package/src/daemon/channels/inbox/register.ts +247 -0
- package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
- package/src/daemon/channels/routing/index.ts +39 -0
- package/src/daemon/channels/routing/register.ts +296 -0
- package/src/daemon/channels/routing/route-store.ts +278 -0
- package/src/daemon/channels/routing/routing-resolver.ts +75 -0
- package/src/daemon/email/imap-connector.ts +441 -0
- package/src/daemon/email/imap-parsing.ts +499 -0
- package/src/daemon/email/index.ts +68 -0
- package/src/daemon/email/register.ts +715 -0
- package/src/daemon/email/smtp-connector.ts +557 -0
- package/src/daemon/operator/credential-store.ts +129 -0
- package/src/daemon/operator/index.ts +43 -0
- package/src/daemon/operator/register-helper.ts +150 -0
- package/src/daemon/operator/sqlite-store.ts +124 -0
- package/src/daemon/operator/surfaces.ts +137 -0
- package/src/daemon/operator/types.ts +207 -0
- package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
- package/src/daemon/remote/backends/docker.ts +80 -0
- package/src/daemon/remote/backends/index.ts +34 -0
- package/src/daemon/remote/backends/local-process.ts +113 -0
- package/src/daemon/remote/backends/process-runner.ts +151 -0
- package/src/daemon/remote/backends/ssh.ts +120 -0
- package/src/daemon/remote/backends/types.ts +71 -0
- package/src/daemon/remote/dispatcher.ts +160 -0
- package/src/daemon/remote/index.ts +74 -0
- package/src/daemon/remote/peer-registry.ts +321 -0
- package/src/daemon/remote/register.ts +411 -0
- package/src/daemon/triage/index.ts +59 -0
- package/src/daemon/triage/integration.ts +179 -0
- package/src/daemon/triage/pipeline.ts +285 -0
- package/src/daemon/triage/register.ts +231 -0
- package/src/daemon/triage/scorer.ts +287 -0
- package/src/daemon/triage/tagger.ts +777 -0
- package/src/runtime/services.ts +35 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Email (IMAP) inbound adapter.
|
|
3
|
+
//
|
|
4
|
+
// Uses the dependency-free ImapClient (node:tls) to pull recent INBOX messages.
|
|
5
|
+
// Connection params resolve through the daemon credential store (env/secrets
|
|
6
|
+
// backend) so nothing sensitive is read from plaintext config:
|
|
7
|
+
// surfaces.email.imapHost (e.g. imap.fastmail.com)
|
|
8
|
+
// surfaces.email.imapPort (default 993)
|
|
9
|
+
// surfaces.email.imapUser
|
|
10
|
+
// surfaces.email.imapPassword (app password / token)
|
|
11
|
+
//
|
|
12
|
+
// Any missing required field => state 'unavailable' WITH an explanatory error.
|
|
13
|
+
// Cadence: 60s (email tier).
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
AdapterContext,
|
|
18
|
+
InboundChannelItem,
|
|
19
|
+
InboundProviderAdapter,
|
|
20
|
+
ProviderPollOptions,
|
|
21
|
+
ProviderPollResult,
|
|
22
|
+
} from '../provider-adapter.ts';
|
|
23
|
+
import { POLL_CADENCE_MS } from '../provider-adapter.ts';
|
|
24
|
+
import { digestSender, toBodyPreview, toSubjectPreview } from '../mapping.ts';
|
|
25
|
+
import { resolveRouteId } from './route-util.ts';
|
|
26
|
+
import { ImapClient } from './imap-client.ts';
|
|
27
|
+
import type { ImapConfig, ImapEnvelope } from './imap-client.ts';
|
|
28
|
+
|
|
29
|
+
export const EMAIL_PROVIDER_ID = 'email';
|
|
30
|
+
export const EMAIL_HOST_KEY = 'surfaces.email.imapHost';
|
|
31
|
+
export const EMAIL_PORT_KEY = 'surfaces.email.imapPort';
|
|
32
|
+
export const EMAIL_USER_KEY = 'surfaces.email.imapUser';
|
|
33
|
+
export const EMAIL_PASSWORD_KEY = 'surfaces.email.imapPassword';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Injectable client factory so tests can substitute a fake IMAP client without
|
|
37
|
+
* opening a TLS socket. Production default constructs the real ImapClient.
|
|
38
|
+
*/
|
|
39
|
+
export interface ImapLike {
|
|
40
|
+
connect(): Promise<void>;
|
|
41
|
+
login(): Promise<void>;
|
|
42
|
+
select(mailbox?: string): Promise<void>;
|
|
43
|
+
searchUids(since?: number): Promise<number[]>;
|
|
44
|
+
fetchEnvelopes(uids: readonly number[]): Promise<ImapEnvelope[]>;
|
|
45
|
+
logout(): Promise<void>;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ImapClientFactory = (cfg: ImapConfig) => ImapLike;
|
|
50
|
+
|
|
51
|
+
const defaultFactory: ImapClientFactory = (cfg) => new ImapClient(cfg);
|
|
52
|
+
|
|
53
|
+
export function createEmailAdapter(
|
|
54
|
+
ctx: AdapterContext,
|
|
55
|
+
factory: ImapClientFactory = defaultFactory,
|
|
56
|
+
): InboundProviderAdapter {
|
|
57
|
+
return {
|
|
58
|
+
id: EMAIL_PROVIDER_ID,
|
|
59
|
+
pollIntervalMs: POLL_CADENCE_MS.email,
|
|
60
|
+
async poll(opts: ProviderPollOptions): Promise<ProviderPollResult> {
|
|
61
|
+
let host: string | null;
|
|
62
|
+
let user: string | null;
|
|
63
|
+
let password: string | null;
|
|
64
|
+
let portRaw: string | null;
|
|
65
|
+
try {
|
|
66
|
+
[host, portRaw, user, password] = await Promise.all([
|
|
67
|
+
ctx.credentials.resolveConfigSecret(EMAIL_HOST_KEY),
|
|
68
|
+
ctx.credentials.resolveConfigSecret(EMAIL_PORT_KEY),
|
|
69
|
+
ctx.credentials.resolveConfigSecret(EMAIL_USER_KEY),
|
|
70
|
+
ctx.credentials.resolveConfigSecret(EMAIL_PASSWORD_KEY),
|
|
71
|
+
]);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return unavailable(`credential lookup failed: ${errMsg(error)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const missing: string[] = [];
|
|
77
|
+
if (!host) missing.push('imapHost');
|
|
78
|
+
if (!user) missing.push('imapUser');
|
|
79
|
+
if (!password) missing.push('imapPassword');
|
|
80
|
+
if (missing.length > 0) {
|
|
81
|
+
return unavailable(`missing email IMAP credentials: ${missing.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
const port = portRaw ? Number.parseInt(portRaw, 10) : 993;
|
|
84
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
85
|
+
return unavailable(`invalid surfaces.email.imapPort: ${portRaw}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const client = factory({
|
|
89
|
+
host: host!,
|
|
90
|
+
port,
|
|
91
|
+
user: user!,
|
|
92
|
+
password: password!,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await client.connect();
|
|
97
|
+
await client.login();
|
|
98
|
+
await client.select('INBOX');
|
|
99
|
+
const uids = await client.searchUids(opts.since);
|
|
100
|
+
// Newest UIDs first, capped at limit.
|
|
101
|
+
const selected = uids.sort((a, b) => b - a).slice(0, opts.limit);
|
|
102
|
+
const envelopes = await client.fetchEnvelopes(selected);
|
|
103
|
+
const items: InboundChannelItem[] = [];
|
|
104
|
+
for (const env of envelopes) {
|
|
105
|
+
const receivedAt = env.date > 0 ? env.date : Date.now();
|
|
106
|
+
if (opts.since && receivedAt <= opts.since) continue;
|
|
107
|
+
const fromDigest = digestSender(`email:${normalizeAddress(env.from)}`);
|
|
108
|
+
const kind = 'dm';
|
|
109
|
+
const item: InboundChannelItem = {
|
|
110
|
+
id: `email:${user}:${env.uid}`,
|
|
111
|
+
provider: EMAIL_PROVIDER_ID,
|
|
112
|
+
kind,
|
|
113
|
+
fromDigest,
|
|
114
|
+
subjectPreview: toSubjectPreview(env.subject),
|
|
115
|
+
bodyPreview: toBodyPreview(env.bodyPreview),
|
|
116
|
+
receivedAt,
|
|
117
|
+
unread: !env.seen,
|
|
118
|
+
};
|
|
119
|
+
const routeId = await resolveRouteId(ctx, EMAIL_PROVIDER_ID, fromDigest, kind);
|
|
120
|
+
if (routeId) item.routeId = routeId;
|
|
121
|
+
items.push(item);
|
|
122
|
+
}
|
|
123
|
+
return { items, state: items.length > 0 ? 'ready' : 'empty' };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return unavailable(errMsg(error));
|
|
126
|
+
} finally {
|
|
127
|
+
try {
|
|
128
|
+
await client.logout();
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
client.close();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Extract a bare address from a `Name <addr@host>` From header for digesting. */
|
|
139
|
+
function normalizeAddress(from: string): string {
|
|
140
|
+
const angle = /<([^>]+)>/.exec(from);
|
|
141
|
+
const addr = (angle ? angle[1]! : from).trim().toLowerCase();
|
|
142
|
+
return addr.length > 0 ? addr : from.trim().toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unavailable(error: string): ProviderPollResult {
|
|
146
|
+
return { items: [], state: 'unavailable', error };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function errMsg(error: unknown): string {
|
|
150
|
+
return error instanceof Error ? error.message : String(error);
|
|
151
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Minimal, dependency-free IMAPS client (RFC 3501 subset) over node:tls.
|
|
3
|
+
//
|
|
4
|
+
// Implements exactly what the inbound poller needs:
|
|
5
|
+
// LOGIN, SELECT, UID SEARCH (SINCE / ALL), UID FETCH (ENVELOPE + body peek),
|
|
6
|
+
// LOGOUT. No external npm dependency — uses node:tls (Bun-compatible).
|
|
7
|
+
//
|
|
8
|
+
// This is intentionally conservative: line-buffered tagged-command protocol,
|
|
9
|
+
// per-command timeout, and a hard cap on response size to avoid unbounded
|
|
10
|
+
// memory growth from a hostile/large mailbox.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
14
|
+
import type { TLSSocket } from 'node:tls';
|
|
15
|
+
|
|
16
|
+
export interface ImapConfig {
|
|
17
|
+
host: string;
|
|
18
|
+
port: number; // 993 for IMAPS
|
|
19
|
+
user: string;
|
|
20
|
+
password: string;
|
|
21
|
+
/** Per-command timeout in ms. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Hard cap on bytes buffered per command (defense against huge fetches). */
|
|
24
|
+
maxResponseBytes?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ImapEnvelope {
|
|
28
|
+
uid: number;
|
|
29
|
+
from: string; // raw From header value (digested by the adapter)
|
|
30
|
+
subject: string;
|
|
31
|
+
date: number; // Unix ms (0 when unparseable)
|
|
32
|
+
seen: boolean;
|
|
33
|
+
bodyPreview: string; // first text fragment, raw (sanitized by the adapter)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
37
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
export class ImapClient {
|
|
40
|
+
private socket: TLSSocket | null = null;
|
|
41
|
+
private tagCounter = 0;
|
|
42
|
+
private buffer = '';
|
|
43
|
+
private readonly cfg: Required<ImapConfig>;
|
|
44
|
+
|
|
45
|
+
constructor(cfg: ImapConfig) {
|
|
46
|
+
this.cfg = {
|
|
47
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
48
|
+
maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES,
|
|
49
|
+
...cfg,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async connect(): Promise<void> {
|
|
54
|
+
await new Promise<void>((resolve, reject) => {
|
|
55
|
+
const socket = tlsConnect(
|
|
56
|
+
{ host: this.cfg.host, port: this.cfg.port, servername: this.cfg.host },
|
|
57
|
+
() => {
|
|
58
|
+
resolve();
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
socket.setEncoding('utf-8');
|
|
62
|
+
socket.once('error', reject);
|
|
63
|
+
this.socket = socket;
|
|
64
|
+
});
|
|
65
|
+
// Consume the server greeting (untagged * OK ...).
|
|
66
|
+
await this.readUntil((chunk) => /\r?\n/.test(chunk), 'greeting');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async login(): Promise<void> {
|
|
70
|
+
const user = quote(this.cfg.user);
|
|
71
|
+
const pass = quote(this.cfg.password);
|
|
72
|
+
await this.command(`LOGIN ${user} ${pass}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** SELECT a mailbox (default INBOX). Returns the reported message count. */
|
|
76
|
+
async select(mailbox = 'INBOX'): Promise<void> {
|
|
77
|
+
await this.command(`SELECT ${quote(mailbox)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** UID SEARCH; returns matching UIDs. `since` filters by internal date. */
|
|
81
|
+
async searchUids(since?: number): Promise<number[]> {
|
|
82
|
+
const criteria = since ? `SINCE ${imapDate(since)}` : 'ALL';
|
|
83
|
+
const lines = await this.command(`UID SEARCH ${criteria}`);
|
|
84
|
+
const uids: number[] = [];
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const match = /^\* SEARCH(.*)$/i.exec(line.trim());
|
|
87
|
+
if (match) {
|
|
88
|
+
for (const tok of match[1]!.trim().split(/\s+/)) {
|
|
89
|
+
const n = Number.parseInt(tok, 10);
|
|
90
|
+
if (Number.isFinite(n)) uids.push(n);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return uids;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* UID FETCH envelope + flags + a small text body peek for the given uids.
|
|
99
|
+
* Returns one ImapEnvelope per uid that parsed successfully.
|
|
100
|
+
*/
|
|
101
|
+
async fetchEnvelopes(uids: readonly number[]): Promise<ImapEnvelope[]> {
|
|
102
|
+
if (uids.length === 0) return [];
|
|
103
|
+
const set = uids.join(',');
|
|
104
|
+
// BODY.PEEK[HEADER.FIELDS (...)] avoids setting \Seen; TEXT peek for preview.
|
|
105
|
+
const lines = await this.command(
|
|
106
|
+
`UID FETCH ${set} (UID FLAGS INTERNALDATE `
|
|
107
|
+
+ `BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] `
|
|
108
|
+
+ `BODY.PEEK[TEXT]<0.600>)`,
|
|
109
|
+
);
|
|
110
|
+
return parseFetchResponse(lines.join('\r\n'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async logout(): Promise<void> {
|
|
114
|
+
if (!this.socket) return;
|
|
115
|
+
try {
|
|
116
|
+
await this.command('LOGOUT');
|
|
117
|
+
} catch {
|
|
118
|
+
// ignore logout failures
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(): void {
|
|
123
|
+
if (this.socket) {
|
|
124
|
+
this.socket.destroy();
|
|
125
|
+
this.socket = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
// Protocol plumbing
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
private nextTag(): string {
|
|
134
|
+
this.tagCounter += 1;
|
|
135
|
+
return `A${this.tagCounter.toString().padStart(4, '0')}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private requireSocket(): TLSSocket {
|
|
139
|
+
if (!this.socket) throw new Error('IMAP socket not connected');
|
|
140
|
+
return this.socket;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Send a tagged command and collect all response lines up to the tagged OK. */
|
|
144
|
+
private async command(text: string): Promise<string[]> {
|
|
145
|
+
const tag = this.nextTag();
|
|
146
|
+
const socket = this.requireSocket();
|
|
147
|
+
socket.write(`${tag} ${text}\r\n`);
|
|
148
|
+
const taggedOk = new RegExp(`^${tag} (OK|NO|BAD)\\b`, 'm');
|
|
149
|
+
const raw = await this.readUntil((buf) => taggedOk.test(buf), text);
|
|
150
|
+
const lines = raw.split(/\r?\n/);
|
|
151
|
+
const statusLine = lines.find((l) => new RegExp(`^${tag} `).test(l)) ?? '';
|
|
152
|
+
const status = /^A\d+ (OK|NO|BAD)/.exec(statusLine)?.[1];
|
|
153
|
+
if (status !== 'OK') {
|
|
154
|
+
throw new Error(`IMAP command failed: ${redactCommand(text)} -> ${statusLine.trim()}`);
|
|
155
|
+
}
|
|
156
|
+
return lines.filter((l) => l.startsWith('*'));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read from the socket until `predicate(buffer)` is true or timeout. */
|
|
160
|
+
private readUntil(predicate: (buf: string) => boolean, label: string): Promise<string> {
|
|
161
|
+
const socket = this.requireSocket();
|
|
162
|
+
return new Promise<string>((resolve, reject) => {
|
|
163
|
+
const onData = (chunk: string): void => {
|
|
164
|
+
this.buffer += chunk;
|
|
165
|
+
if (this.buffer.length > this.cfg.maxResponseBytes) {
|
|
166
|
+
cleanup();
|
|
167
|
+
reject(new Error(`IMAP response exceeded ${this.cfg.maxResponseBytes} bytes (${label})`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (predicate(this.buffer)) {
|
|
171
|
+
const out = this.buffer;
|
|
172
|
+
this.buffer = '';
|
|
173
|
+
cleanup();
|
|
174
|
+
resolve(out);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const onError = (err: Error): void => {
|
|
178
|
+
cleanup();
|
|
179
|
+
reject(err);
|
|
180
|
+
};
|
|
181
|
+
const onClose = (): void => {
|
|
182
|
+
cleanup();
|
|
183
|
+
reject(new Error(`IMAP connection closed during ${label}`));
|
|
184
|
+
};
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
cleanup();
|
|
187
|
+
reject(new Error(`IMAP timeout after ${this.cfg.timeoutMs}ms (${label})`));
|
|
188
|
+
}, this.cfg.timeoutMs);
|
|
189
|
+
const cleanup = (): void => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
socket.off('data', onData);
|
|
192
|
+
socket.off('error', onError);
|
|
193
|
+
socket.off('close', onClose);
|
|
194
|
+
};
|
|
195
|
+
socket.on('data', onData);
|
|
196
|
+
socket.on('error', onError);
|
|
197
|
+
socket.on('close', onClose);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Pure parsers (exported for unit testing)
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** Quote an IMAP astring, escaping backslashes and double quotes. */
|
|
207
|
+
function quote(value: string): string {
|
|
208
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Never echo a LOGIN password in an error message. */
|
|
212
|
+
function redactCommand(text: string): string {
|
|
213
|
+
return /^LOGIN\b/i.test(text) ? 'LOGIN <redacted>' : text;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Format a Unix-ms timestamp as an IMAP date (dd-Mon-yyyy). */
|
|
217
|
+
export function imapDate(ms: number): string {
|
|
218
|
+
const d = new Date(ms);
|
|
219
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
220
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
221
|
+
return `${day}-${months[d.getUTCMonth()]}-${d.getUTCFullYear()}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const HEADER_FROM_RE = /^From:\s*(.*)$/im;
|
|
225
|
+
const HEADER_SUBJECT_RE = /^Subject:\s*(.*)$/im;
|
|
226
|
+
const HEADER_DATE_RE = /^Date:\s*(.*)$/im;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parse a UID FETCH response block into envelopes. Robust to interleaving and
|
|
230
|
+
* partial fields; entries that lack a UID are skipped.
|
|
231
|
+
*/
|
|
232
|
+
export function parseFetchResponse(raw: string): ImapEnvelope[] {
|
|
233
|
+
const envelopes: ImapEnvelope[] = [];
|
|
234
|
+
// Split on each "* <n> FETCH (" boundary.
|
|
235
|
+
const blocks = raw.split(/\* \d+ FETCH \(/i).slice(1);
|
|
236
|
+
for (const block of blocks) {
|
|
237
|
+
const uidMatch = /UID (\d+)/i.exec(block);
|
|
238
|
+
if (!uidMatch) continue;
|
|
239
|
+
const uid = Number.parseInt(uidMatch[1]!, 10);
|
|
240
|
+
const seen = /FLAGS \([^)]*\\Seen/i.test(block);
|
|
241
|
+
|
|
242
|
+
// Header literal: BODY[HEADER.FIELDS (...)] {n}\r\n<header bytes>
|
|
243
|
+
const headerText = extractLiteral(block, /BODY\[HEADER\.FIELDS[^\]]*\]/i);
|
|
244
|
+
const bodyText = extractLiteral(block, /BODY\[TEXT\](?:<\d+(?:\.\d+)?>)?/i);
|
|
245
|
+
|
|
246
|
+
const from = HEADER_FROM_RE.exec(headerText)?.[1]?.trim() ?? '';
|
|
247
|
+
const subject = decodeHeader(HEADER_SUBJECT_RE.exec(headerText)?.[1]?.trim() ?? '');
|
|
248
|
+
const dateRaw = HEADER_DATE_RE.exec(headerText)?.[1]?.trim() ?? '';
|
|
249
|
+
const parsedDate = dateRaw ? Date.parse(dateRaw) : NaN;
|
|
250
|
+
|
|
251
|
+
envelopes.push({
|
|
252
|
+
uid,
|
|
253
|
+
from,
|
|
254
|
+
subject,
|
|
255
|
+
date: Number.isFinite(parsedDate) ? parsedDate : 0,
|
|
256
|
+
seen,
|
|
257
|
+
bodyPreview: bodyText,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return envelopes;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Extract a literal `{n}\r\n<bytes>` that follows a section header matched by
|
|
265
|
+
* `sectionRe`. Returns the literal content (n bytes) or ''.
|
|
266
|
+
*/
|
|
267
|
+
function extractLiteral(block: string, sectionRe: RegExp): string {
|
|
268
|
+
const sectionMatch = sectionRe.exec(block);
|
|
269
|
+
if (!sectionMatch) return '';
|
|
270
|
+
const after = block.slice(sectionMatch.index + sectionMatch[0].length);
|
|
271
|
+
const litMatch = /^\s*\{(\d+)\}\r?\n/.exec(after);
|
|
272
|
+
if (!litMatch) {
|
|
273
|
+
// Quoted-string form: BODY[...] "value"
|
|
274
|
+
const q = /^\s*"((?:[^"\\]|\\.)*)"/.exec(after);
|
|
275
|
+
return q ? q[1]!.replace(/\\"/g, '"') : '';
|
|
276
|
+
}
|
|
277
|
+
const n = Number.parseInt(litMatch[1]!, 10);
|
|
278
|
+
const start = litMatch.index + litMatch[0].length;
|
|
279
|
+
return after.slice(start, start + n);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Decode RFC 2047 encoded-word subjects (UTF-8 B/Q) best-effort. */
|
|
283
|
+
export function decodeHeader(value: string): string {
|
|
284
|
+
return value.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_m, _charset, enc, text) => {
|
|
285
|
+
try {
|
|
286
|
+
if (enc.toUpperCase() === 'B') {
|
|
287
|
+
return Buffer.from(text, 'base64').toString('utf-8');
|
|
288
|
+
}
|
|
289
|
+
// Q-encoding: _ -> space, =XX -> byte
|
|
290
|
+
const replaced = String(text)
|
|
291
|
+
.replace(/_/g, ' ')
|
|
292
|
+
.replace(/=([0-9A-Fa-f]{2})/g, (_s: string, hex: string) =>
|
|
293
|
+
String.fromCharCode(Number.parseInt(hex, 16)),
|
|
294
|
+
);
|
|
295
|
+
return replaced;
|
|
296
|
+
} catch {
|
|
297
|
+
return text;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Shared best-effort route resolution wrapper used by all adapters.
|
|
2
|
+
// Swallows resolver failures (routing is an optional, concurrent surface) so a
|
|
3
|
+
// route lookup can never crash a provider poll.
|
|
4
|
+
|
|
5
|
+
import type { AdapterContext, InboundChannelItem } from '../provider-adapter.ts';
|
|
6
|
+
|
|
7
|
+
export async function resolveRouteId(
|
|
8
|
+
ctx: AdapterContext,
|
|
9
|
+
provider: string,
|
|
10
|
+
fromDigest: string,
|
|
11
|
+
kind: InboundChannelItem['kind'],
|
|
12
|
+
): Promise<string | undefined> {
|
|
13
|
+
if (!ctx.resolveRouteId) return undefined;
|
|
14
|
+
try {
|
|
15
|
+
return (await ctx.resolveRouteId({ provider, fromDigest, kind })) ?? undefined;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
ctx.logger.warn('route resolution failed', {
|
|
18
|
+
provider,
|
|
19
|
+
error: error instanceof Error ? error.message : String(error),
|
|
20
|
+
});
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|