@pellux/goodvibes-tui 0.24.0 → 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 +15 -0
- package/README.md +4 -5
- package/docs/foundation-artifacts/operator-contract.json +304 -230
- package/package.json +2 -2
- 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,499 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Pure IMAP / MIME parsing helpers for the daemon-owned IMAP connector.
|
|
3
|
+
//
|
|
4
|
+
// This module is dependency-free (only node Buffer) and contains the wire-
|
|
5
|
+
// format parsing, MIME decoding, and text-normalization helpers used by
|
|
6
|
+
// `imap-connector.ts`. Everything here is exported for unit testing and is
|
|
7
|
+
// re-exported from `imap-connector.ts` so consumers keep a single import path.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface ImapEnvelopeSummary {
|
|
11
|
+
readonly uid: number;
|
|
12
|
+
readonly from: string;
|
|
13
|
+
readonly subject: string;
|
|
14
|
+
readonly date: string;
|
|
15
|
+
readonly unread: boolean;
|
|
16
|
+
readonly bodyPreview: string;
|
|
17
|
+
readonly messageId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ImapAttachmentSummary {
|
|
21
|
+
readonly filename: string;
|
|
22
|
+
readonly contentType: string;
|
|
23
|
+
readonly sizeBytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ImapFullMessage {
|
|
27
|
+
readonly uid: number;
|
|
28
|
+
readonly from: string;
|
|
29
|
+
readonly subject: string;
|
|
30
|
+
readonly date: string;
|
|
31
|
+
readonly messageId: string;
|
|
32
|
+
readonly bodyText: string;
|
|
33
|
+
readonly bodyHtml?: string;
|
|
34
|
+
readonly attachments?: ImapAttachmentSummary[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ImapError extends Error {
|
|
38
|
+
readonly code: string;
|
|
39
|
+
constructor(message: string, code = 'IMAP_ERROR') {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'ImapError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const IMAP_MONTHS = [
|
|
47
|
+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
48
|
+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Format an ISO date string into the IMAP SEARCH date form: DD-Mon-YYYY. */
|
|
52
|
+
export function toImapSearchDate(iso: string): string {
|
|
53
|
+
const date = new Date(iso);
|
|
54
|
+
if (Number.isNaN(date.getTime())) {
|
|
55
|
+
throw new ImapError(`Invalid since date: ${iso}`, 'IMAP_BAD_DATE');
|
|
56
|
+
}
|
|
57
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
58
|
+
const month = IMAP_MONTHS[date.getUTCMonth()];
|
|
59
|
+
const year = date.getUTCFullYear();
|
|
60
|
+
return `${day}-${month}-${year}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Pure parsing helpers (exported for unit testing)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/** Quote and escape a string for use as an IMAP quoted-string argument. */
|
|
68
|
+
export function quoteImapString(value: string): string {
|
|
69
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse UID list out of an untagged `* SEARCH ...` response. */
|
|
73
|
+
export function parseSearchUids(lines: string[]): number[] {
|
|
74
|
+
const uids: number[] = [];
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const match = /^\* SEARCH(.*)$/i.exec(line);
|
|
77
|
+
if (!match) continue;
|
|
78
|
+
for (const token of match[1].trim().split(/\s+/)) {
|
|
79
|
+
const n = Number(token);
|
|
80
|
+
if (Number.isInteger(n) && n > 0) uids.push(n);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return uids;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse APPENDUID response code: `[APPENDUID <validity> <uid>]`. */
|
|
87
|
+
export function parseAppendUid(lines: string[]): number {
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const match = /\[APPENDUID\s+\d+\s+(\d+)\]/i.exec(line);
|
|
90
|
+
if (match) return Number(match[1]);
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse an IMAP ENVELOPE structure into addressable fields.
|
|
97
|
+
* Envelope order (RFC 3501): date subject from sender reply-to to cc bcc
|
|
98
|
+
* in-reply-to message-id.
|
|
99
|
+
*/
|
|
100
|
+
export function parseEnvelope(envelope: string): {
|
|
101
|
+
date: string;
|
|
102
|
+
subject: string;
|
|
103
|
+
from: string;
|
|
104
|
+
messageId: string;
|
|
105
|
+
} {
|
|
106
|
+
const tokens = tokenizeParen(envelope);
|
|
107
|
+
// tokens: [date, subject, from(list), sender, reply-to, to, cc, bcc,
|
|
108
|
+
// in-reply-to, message-id]
|
|
109
|
+
const date = nilToEmpty(tokens[0]);
|
|
110
|
+
const subject = decodeMimeWords(nilToEmpty(tokens[1]));
|
|
111
|
+
const from = parseAddressList(tokens[2]);
|
|
112
|
+
const messageId = nilToEmpty(tokens[9]);
|
|
113
|
+
return { date, subject, from, messageId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parse `* n FETCH (...)` summary lines into envelope summaries. */
|
|
117
|
+
export function parseFetchSummaries(lines: string[]): ImapEnvelopeSummary[] {
|
|
118
|
+
const joined = lines.join('\n');
|
|
119
|
+
const blocks = splitFetchBlocks(joined);
|
|
120
|
+
const summaries: ImapEnvelopeSummary[] = [];
|
|
121
|
+
for (const block of blocks) {
|
|
122
|
+
const uid = extractUid(block);
|
|
123
|
+
if (uid === null) continue;
|
|
124
|
+
const flags = extractFlags(block);
|
|
125
|
+
const unread = !flags.includes('\\Seen');
|
|
126
|
+
const envelopeRaw = extractParenValue(block, 'ENVELOPE');
|
|
127
|
+
const env = envelopeRaw
|
|
128
|
+
? parseEnvelope(envelopeRaw)
|
|
129
|
+
: { date: '', subject: '', from: '', messageId: '' };
|
|
130
|
+
const headerMessageId = extractHeaderMessageId(block);
|
|
131
|
+
const preview = extractBodyPreview(block);
|
|
132
|
+
summaries.push({
|
|
133
|
+
uid,
|
|
134
|
+
from: env.from,
|
|
135
|
+
subject: env.subject,
|
|
136
|
+
date: env.date,
|
|
137
|
+
unread,
|
|
138
|
+
bodyPreview: preview,
|
|
139
|
+
messageId: env.messageId || headerMessageId,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return summaries;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parse a full-body FETCH response (`BODY[]`) into a structured message. */
|
|
146
|
+
export function parseFullMessage(uid: number, lines: string[]): ImapFullMessage | null {
|
|
147
|
+
const joined = lines.join('\n');
|
|
148
|
+
const blocks = splitFetchBlocks(joined);
|
|
149
|
+
for (const block of blocks) {
|
|
150
|
+
const blockUid = extractUid(block);
|
|
151
|
+
const rawBody = extractLiteralFor(block, /BODY\[\]/i) ?? extractLiteralFor(block, /RFC822/i);
|
|
152
|
+
if (rawBody === null) continue;
|
|
153
|
+
if (blockUid !== null && blockUid !== uid) continue;
|
|
154
|
+
const mime = parseMimeMessage(rawBody);
|
|
155
|
+
const envelopeRaw = extractParenValue(block, 'ENVELOPE');
|
|
156
|
+
const env = envelopeRaw
|
|
157
|
+
? parseEnvelope(envelopeRaw)
|
|
158
|
+
: { date: mime.headers.date ?? '', subject: mime.headers.subject ?? '', from: mime.headers.from ?? '', messageId: mime.headers['message-id'] ?? '' };
|
|
159
|
+
return {
|
|
160
|
+
uid: blockUid ?? uid,
|
|
161
|
+
from: env.from || mime.headers.from || '',
|
|
162
|
+
subject: env.subject || mime.headers.subject || '',
|
|
163
|
+
date: env.date || mime.headers.date || '',
|
|
164
|
+
messageId: env.messageId || mime.headers['message-id'] || '',
|
|
165
|
+
bodyText: mime.text,
|
|
166
|
+
...(mime.html ? { bodyHtml: mime.html } : {}),
|
|
167
|
+
...(mime.attachments.length > 0 ? { attachments: mime.attachments } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- low-level token helpers ----------------------------------------------
|
|
174
|
+
|
|
175
|
+
function nilToEmpty(token: string | undefined): string {
|
|
176
|
+
if (token === undefined) return '';
|
|
177
|
+
const trimmed = token.trim();
|
|
178
|
+
if (trimmed === '' || /^NIL$/i.test(trimmed)) return '';
|
|
179
|
+
// Strip surrounding quotes if quoted-string.
|
|
180
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
181
|
+
return unescapeImapString(trimmed.slice(1, -1));
|
|
182
|
+
}
|
|
183
|
+
return trimmed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function unescapeImapString(value: string): string {
|
|
187
|
+
return value.replace(/\\(.)/g, '$1');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Tokenize the top-level items of a parenthesized IMAP list, respecting nested
|
|
192
|
+
* parens and quoted strings. Returns the raw token strings.
|
|
193
|
+
*/
|
|
194
|
+
export function tokenizeParen(input: string): string[] {
|
|
195
|
+
let src = input.trim();
|
|
196
|
+
if (src.startsWith('(') && src.endsWith(')')) src = src.slice(1, -1);
|
|
197
|
+
const tokens: string[] = [];
|
|
198
|
+
let i = 0;
|
|
199
|
+
while (i < src.length) {
|
|
200
|
+
const ch = src[i];
|
|
201
|
+
if (ch === ' ') { i += 1; continue; }
|
|
202
|
+
if (ch === '"') {
|
|
203
|
+
let j = i + 1;
|
|
204
|
+
let out = '"';
|
|
205
|
+
while (j < src.length) {
|
|
206
|
+
out += src[j];
|
|
207
|
+
if (src[j] === '\\') { out += src[j + 1] ?? ''; j += 2; continue; }
|
|
208
|
+
if (src[j] === '"') { j += 1; break; }
|
|
209
|
+
j += 1;
|
|
210
|
+
}
|
|
211
|
+
tokens.push(out);
|
|
212
|
+
i = j;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (ch === '(') {
|
|
216
|
+
let depth = 0;
|
|
217
|
+
let j = i;
|
|
218
|
+
let inQuote = false;
|
|
219
|
+
while (j < src.length) {
|
|
220
|
+
const c = src[j];
|
|
221
|
+
if (inQuote) {
|
|
222
|
+
if (c === '\\') { j += 2; continue; }
|
|
223
|
+
if (c === '"') inQuote = false;
|
|
224
|
+
} else if (c === '"') {
|
|
225
|
+
inQuote = true;
|
|
226
|
+
} else if (c === '(') {
|
|
227
|
+
depth += 1;
|
|
228
|
+
} else if (c === ')') {
|
|
229
|
+
depth -= 1;
|
|
230
|
+
if (depth === 0) { j += 1; break; }
|
|
231
|
+
}
|
|
232
|
+
j += 1;
|
|
233
|
+
}
|
|
234
|
+
tokens.push(src.slice(i, j));
|
|
235
|
+
i = j;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Atom (until whitespace).
|
|
239
|
+
let j = i;
|
|
240
|
+
while (j < src.length && src[j] !== ' ') j += 1;
|
|
241
|
+
tokens.push(src.slice(i, j));
|
|
242
|
+
i = j;
|
|
243
|
+
}
|
|
244
|
+
return tokens;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Parse the first address out of an IMAP address-list structure. */
|
|
248
|
+
export function parseAddressList(token: string | undefined): string {
|
|
249
|
+
if (token === undefined) return '';
|
|
250
|
+
const trimmed = token.trim();
|
|
251
|
+
if (trimmed === '' || /^NIL$/i.test(trimmed)) return '';
|
|
252
|
+
// An address-list is `((name adl mailbox host) (...))`. tokenizeParen on the
|
|
253
|
+
// whole list yields one token per address group; take the first group, then
|
|
254
|
+
// tokenize that group into its (name adl mailbox host) parts.
|
|
255
|
+
const firstAddr = tokenizeParen(trimmed)[0];
|
|
256
|
+
if (firstAddr === undefined) return '';
|
|
257
|
+
const parts = tokenizeParen(firstAddr);
|
|
258
|
+
const name = nilToEmpty(parts[0]);
|
|
259
|
+
const mailbox = nilToEmpty(parts[2]);
|
|
260
|
+
const host = nilToEmpty(parts[3]);
|
|
261
|
+
const address = mailbox && host ? `${mailbox}@${host}` : (mailbox || host);
|
|
262
|
+
if (name && address) return `${decodeMimeWords(name)} <${address}>`;
|
|
263
|
+
return address || decodeMimeWords(name);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function splitFetchBlocks(joined: string): string[] {
|
|
267
|
+
// Split on untagged FETCH markers while keeping each block whole.
|
|
268
|
+
const blocks: string[] = [];
|
|
269
|
+
const regex = /(^|\n)\* \d+ FETCH /gi;
|
|
270
|
+
const indices: number[] = [];
|
|
271
|
+
let m: RegExpExecArray | null;
|
|
272
|
+
while ((m = regex.exec(joined)) !== null) {
|
|
273
|
+
indices.push(m.index + (m[1] ? 1 : 0));
|
|
274
|
+
}
|
|
275
|
+
for (let k = 0; k < indices.length; k += 1) {
|
|
276
|
+
const start = indices[k];
|
|
277
|
+
const end = k + 1 < indices.length ? indices[k + 1] : joined.length;
|
|
278
|
+
blocks.push(joined.slice(start, end));
|
|
279
|
+
}
|
|
280
|
+
return blocks;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function extractUid(block: string): number | null {
|
|
284
|
+
const match = /UID (\d+)/i.exec(block);
|
|
285
|
+
return match ? Number(match[1]) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function extractFlags(block: string): string[] {
|
|
289
|
+
const match = /FLAGS \(([^)]*)\)/i.exec(block);
|
|
290
|
+
if (!match) return [];
|
|
291
|
+
return match[1].trim().split(/\s+/).filter(Boolean);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Extract a parenthesized value following a key (e.g. ENVELOPE (...)). */
|
|
295
|
+
export function extractParenValue(block: string, key: string): string | null {
|
|
296
|
+
const keyIdx = block.toUpperCase().indexOf(key.toUpperCase());
|
|
297
|
+
if (keyIdx < 0) return null;
|
|
298
|
+
let i = keyIdx + key.length;
|
|
299
|
+
while (i < block.length && block[i] !== '(') i += 1;
|
|
300
|
+
if (i >= block.length) return null;
|
|
301
|
+
let depth = 0;
|
|
302
|
+
let inQuote = false;
|
|
303
|
+
const start = i;
|
|
304
|
+
while (i < block.length) {
|
|
305
|
+
const c = block[i];
|
|
306
|
+
if (inQuote) {
|
|
307
|
+
if (c === '\\') { i += 2; continue; }
|
|
308
|
+
if (c === '"') inQuote = false;
|
|
309
|
+
} else if (c === '"') {
|
|
310
|
+
inQuote = true;
|
|
311
|
+
} else if (c === '(') {
|
|
312
|
+
depth += 1;
|
|
313
|
+
} else if (c === ')') {
|
|
314
|
+
depth -= 1;
|
|
315
|
+
if (depth === 0) { i += 1; break; }
|
|
316
|
+
}
|
|
317
|
+
i += 1;
|
|
318
|
+
}
|
|
319
|
+
return block.slice(start, i);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Extract the literal text that immediately follows a `KEY {n}` marker. */
|
|
323
|
+
export function extractLiteralFor(block: string, keyPattern: RegExp): string | null {
|
|
324
|
+
const re = new RegExp(`${keyPattern.source}[^{]*\\{(\\d+)\\}\\n`, keyPattern.flags.includes('i') ? 'i' : '');
|
|
325
|
+
const match = re.exec(block);
|
|
326
|
+
if (!match) return null;
|
|
327
|
+
const length = Number(match[1]);
|
|
328
|
+
const startIdx = match.index + match[0].length;
|
|
329
|
+
return block.slice(startIdx, startIdx + length);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function extractHeaderMessageId(block: string): string {
|
|
333
|
+
const literal = extractLiteralFor(block, /BODY\[HEADER\.FIELDS \(MESSAGE-ID\)\]/i);
|
|
334
|
+
const source = literal ?? block;
|
|
335
|
+
const match = /Message-ID:\s*(<[^>]+>)/i.exec(source);
|
|
336
|
+
return match ? match[1] : '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function extractBodyPreview(block: string): string {
|
|
340
|
+
const literal = extractLiteralFor(block, /BODY\[TEXT\](?:<\d+(?:\.\d+)?>)?/i);
|
|
341
|
+
if (literal === null) return '';
|
|
342
|
+
return collapseWhitespace(stripHtml(literal)).slice(0, 280);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// MIME parsing (sufficient for text/plain, text/html, multipart, attachments)
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
export interface ParsedMime {
|
|
350
|
+
headers: Record<string, string>;
|
|
351
|
+
text: string;
|
|
352
|
+
html?: string;
|
|
353
|
+
attachments: ImapAttachmentSummary[];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function parseMimeMessage(raw: string): ParsedMime {
|
|
357
|
+
const { headers, body } = splitHeadersBody(raw);
|
|
358
|
+
const contentType = headers['content-type'] ?? 'text/plain';
|
|
359
|
+
const attachments: ImapAttachmentSummary[] = [];
|
|
360
|
+
let text = '';
|
|
361
|
+
let html: string | undefined;
|
|
362
|
+
|
|
363
|
+
const boundary = extractBoundary(contentType);
|
|
364
|
+
if (boundary) {
|
|
365
|
+
const parts = splitMultipart(body, boundary);
|
|
366
|
+
for (const part of parts) {
|
|
367
|
+
const { headers: ph, body: pb } = splitHeadersBody(part);
|
|
368
|
+
const pct = ph['content-type'] ?? 'text/plain';
|
|
369
|
+
const disposition = ph['content-disposition'] ?? '';
|
|
370
|
+
const decoded = decodeTransferEncoding(pb, ph['content-transfer-encoding']);
|
|
371
|
+
if (/attachment|filename=/i.test(disposition) || (!/text\//i.test(pct) && /name=/i.test(pct))) {
|
|
372
|
+
attachments.push({
|
|
373
|
+
filename: extractParam(disposition, 'filename') || extractParam(pct, 'name') || 'attachment',
|
|
374
|
+
contentType: pct.split(';')[0].trim(),
|
|
375
|
+
sizeBytes: Buffer.byteLength(decoded, 'utf-8'),
|
|
376
|
+
});
|
|
377
|
+
} else if (/text\/html/i.test(pct)) {
|
|
378
|
+
html = decoded.trim();
|
|
379
|
+
} else if (/text\/plain/i.test(pct)) {
|
|
380
|
+
text = decoded.trim();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!text && html) text = collapseWhitespace(stripHtml(html));
|
|
384
|
+
} else {
|
|
385
|
+
const decoded = decodeTransferEncoding(body, headers['content-transfer-encoding']);
|
|
386
|
+
if (/text\/html/i.test(contentType)) {
|
|
387
|
+
html = decoded.trim();
|
|
388
|
+
text = collapseWhitespace(stripHtml(decoded));
|
|
389
|
+
} else {
|
|
390
|
+
text = decoded.trim();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { headers, text, ...(html ? { html } : {}), attachments };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function splitHeadersBody(raw: string): { headers: Record<string, string>; body: string } {
|
|
398
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
399
|
+
const sepIdx = normalized.indexOf('\n\n');
|
|
400
|
+
const headerText = sepIdx >= 0 ? normalized.slice(0, sepIdx) : normalized;
|
|
401
|
+
const body = sepIdx >= 0 ? normalized.slice(sepIdx + 2) : '';
|
|
402
|
+
const headers: Record<string, string> = {};
|
|
403
|
+
// Unfold continuation lines (leading whitespace).
|
|
404
|
+
const unfolded = headerText.replace(/\n[ \t]+/g, ' ');
|
|
405
|
+
for (const line of unfolded.split('\n')) {
|
|
406
|
+
const idx = line.indexOf(':');
|
|
407
|
+
if (idx < 0) continue;
|
|
408
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
409
|
+
const value = line.slice(idx + 1).trim();
|
|
410
|
+
headers[key] = decodeMimeWords(value);
|
|
411
|
+
}
|
|
412
|
+
return { headers, body };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function extractBoundary(contentType: string): string | null {
|
|
416
|
+
const match = /boundary="?([^";]+)"?/i.exec(contentType);
|
|
417
|
+
return match ? match[1] : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function splitMultipart(body: string, boundary: string): string[] {
|
|
421
|
+
const marker = `--${boundary}`;
|
|
422
|
+
const segments = body.split(marker);
|
|
423
|
+
const parts: string[] = [];
|
|
424
|
+
for (const seg of segments) {
|
|
425
|
+
const trimmed = seg.replace(/^\r?\n/, '');
|
|
426
|
+
if (trimmed === '' || trimmed.startsWith('--')) continue;
|
|
427
|
+
parts.push(trimmed);
|
|
428
|
+
}
|
|
429
|
+
return parts;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function extractParam(source: string, name: string): string {
|
|
433
|
+
const match = new RegExp(`${name}="?([^";]+)"?`, 'i').exec(source);
|
|
434
|
+
return match ? match[1].trim() : '';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function decodeTransferEncoding(body: string, encoding: string | undefined): string {
|
|
438
|
+
const enc = (encoding ?? '').toLowerCase().trim();
|
|
439
|
+
if (enc === 'base64') {
|
|
440
|
+
try {
|
|
441
|
+
return Buffer.from(body.replace(/\s+/g, ''), 'base64').toString('utf-8');
|
|
442
|
+
} catch {
|
|
443
|
+
return body;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (enc === 'quoted-printable') {
|
|
447
|
+
return decodeQuotedPrintable(body);
|
|
448
|
+
}
|
|
449
|
+
return body;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function decodeQuotedPrintable(input: string): string {
|
|
453
|
+
// Strip soft line breaks, then decode `=XX` escapes into raw bytes and
|
|
454
|
+
// interpret the resulting byte stream as UTF-8 (a single `=C3=A9` pair must
|
|
455
|
+
// become one 'é', not two Latin-1 characters).
|
|
456
|
+
const unfolded = input.replace(/=\r?\n/g, '');
|
|
457
|
+
const bytes: number[] = [];
|
|
458
|
+
for (let i = 0; i < unfolded.length; i += 1) {
|
|
459
|
+
const ch = unfolded[i];
|
|
460
|
+
if (ch === '=' && /[0-9A-Fa-f]{2}/.test(unfolded.slice(i + 1, i + 3))) {
|
|
461
|
+
bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16));
|
|
462
|
+
i += 2;
|
|
463
|
+
} else {
|
|
464
|
+
// Preserve the original character's UTF-8 bytes.
|
|
465
|
+
for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return Buffer.from(bytes).toString('utf-8');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Decode RFC 2047 encoded-words (=?charset?B/Q?text?=). */
|
|
472
|
+
export function decodeMimeWords(input: string): string {
|
|
473
|
+
return input.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_, _charset, enc, text) => {
|
|
474
|
+
if (enc.toUpperCase() === 'B') {
|
|
475
|
+
try {
|
|
476
|
+
return Buffer.from(text, 'base64').toString('utf-8');
|
|
477
|
+
} catch {
|
|
478
|
+
return text;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return decodeQuotedPrintable(text.replace(/_/g, ' '));
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function stripHtml(html: string): string {
|
|
486
|
+
return html
|
|
487
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
488
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
489
|
+
.replace(/<[^>]+>/g, ' ')
|
|
490
|
+
.replace(/ /gi, ' ')
|
|
491
|
+
.replace(/&/gi, '&')
|
|
492
|
+
.replace(/</gi, '<')
|
|
493
|
+
.replace(/>/gi, '>')
|
|
494
|
+
.replace(/"/gi, '"');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function collapseWhitespace(input: string): string {
|
|
498
|
+
return input.replace(/\s+/g, ' ').trim();
|
|
499
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Email operator-method surface barrel.
|
|
3
|
+
//
|
|
4
|
+
// Integration wires this surface by calling registerEmailMethods(ctx) once and
|
|
5
|
+
// retaining the returned Unregister. Connectors and pure helpers are re-exported
|
|
6
|
+
// for daemon-internal reuse and unit testing.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export { registerEmailMethods, resolveEmailSettings } from './register.ts';
|
|
10
|
+
export type {
|
|
11
|
+
ResolvedEmailSettings,
|
|
12
|
+
EmailMethodsOptions,
|
|
13
|
+
ImapClient,
|
|
14
|
+
SmtpClient,
|
|
15
|
+
} from './register.ts';
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
ImapConnector,
|
|
19
|
+
ImapError,
|
|
20
|
+
toImapSearchDate,
|
|
21
|
+
quoteImapString,
|
|
22
|
+
parseSearchUids,
|
|
23
|
+
parseAppendUid,
|
|
24
|
+
parseEnvelope,
|
|
25
|
+
parseFetchSummaries,
|
|
26
|
+
parseFullMessage,
|
|
27
|
+
parseAddressList,
|
|
28
|
+
parseMimeMessage,
|
|
29
|
+
splitHeadersBody,
|
|
30
|
+
tokenizeParen,
|
|
31
|
+
extractParenValue,
|
|
32
|
+
extractLiteralFor,
|
|
33
|
+
unescapeImapString,
|
|
34
|
+
decodeTransferEncoding,
|
|
35
|
+
decodeQuotedPrintable,
|
|
36
|
+
decodeMimeWords,
|
|
37
|
+
stripHtml,
|
|
38
|
+
collapseWhitespace,
|
|
39
|
+
} from './imap-connector.ts';
|
|
40
|
+
export type {
|
|
41
|
+
ImapConnectionSettings,
|
|
42
|
+
ImapEnvelopeSummary,
|
|
43
|
+
ImapFullMessage,
|
|
44
|
+
ImapAttachmentSummary,
|
|
45
|
+
ImapListOptions,
|
|
46
|
+
ImapAppendResult,
|
|
47
|
+
ParsedMime,
|
|
48
|
+
} from './imap-connector.ts';
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
SmtpConnector,
|
|
52
|
+
SmtpError,
|
|
53
|
+
extractCompleteReply,
|
|
54
|
+
extractAddress,
|
|
55
|
+
parseRecipients,
|
|
56
|
+
dotStuff,
|
|
57
|
+
generateMessageId,
|
|
58
|
+
buildRfc5322Message,
|
|
59
|
+
encodeHeaderValue,
|
|
60
|
+
formatRfc2822Date,
|
|
61
|
+
encodeQuotedPrintable,
|
|
62
|
+
} from './smtp-connector.ts';
|
|
63
|
+
export type {
|
|
64
|
+
SmtpConnectionSettings,
|
|
65
|
+
SmtpMessage,
|
|
66
|
+
SmtpSendResult,
|
|
67
|
+
Rfc5322Parts,
|
|
68
|
+
} from './smtp-connector.ts';
|