zele 0.3.16 → 0.3.20
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/README.md +155 -36
- package/dist/api-utils.d.ts +14 -0
- package/dist/api-utils.js +20 -0
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +71 -9
- package/dist/auth.js +186 -10
- package/dist/auth.js.map +1 -1
- package/dist/cli-types.d.ts +4 -0
- package/dist/cli-types.js +6 -0
- package/dist/cli-types.js.map +1 -0
- package/dist/cli.js +1 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.d.ts +2 -2
- package/dist/commands/attachment.js +2 -0
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.d.ts +2 -2
- package/dist/commands/auth-cmd.js +104 -6
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -2
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.d.ts +2 -2
- package/dist/commands/draft.js +58 -4
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/filter.d.ts +2 -2
- package/dist/commands/filter.js +7 -2
- package/dist/commands/filter.js.map +1 -1
- package/dist/commands/label.d.ts +2 -2
- package/dist/commands/label.js +19 -9
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.d.ts +2 -2
- package/dist/commands/mail-actions.js +290 -1
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.d.ts +2 -2
- package/dist/commands/mail.js +90 -23
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.d.ts +2 -2
- package/dist/commands/profile.js +25 -18
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -2
- package/dist/commands/watch.js.map +1 -1
- package/dist/db.js +24 -0
- package/dist/db.js.map +1 -1
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +2 -0
- package/dist/generated/internal/prismaNamespace.js +2 -0
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +97 -1
- package/dist/gmail-client.d.ts +73 -3
- package/dist/gmail-client.js +165 -5
- package/dist/gmail-client.js.map +1 -1
- package/dist/imap-smtp-client.d.ts +306 -0
- package/dist/imap-smtp-client.js +1349 -0
- package/dist/imap-smtp-client.js.map +1 -0
- package/dist/mail-tui.js.map +1 -1
- package/dist/unsubscribe.d.ts +76 -0
- package/dist/unsubscribe.js +224 -0
- package/dist/unsubscribe.js.map +1 -0
- package/package.json +6 -3
- package/schema.prisma +7 -5
- package/skills/zele/SKILL.md +26 -96
- package/src/api-utils.ts +20 -0
- package/src/auth.ts +282 -14
- package/src/cli-types.ts +8 -0
- package/src/cli.ts +2 -7
- package/src/commands/attachment.ts +3 -2
- package/src/commands/auth-cmd.ts +114 -8
- package/src/commands/calendar.ts +2 -2
- package/src/commands/draft.ts +65 -6
- package/src/commands/filter.ts +11 -5
- package/src/commands/label.ts +24 -13
- package/src/commands/mail-actions.ts +317 -5
- package/src/commands/mail.ts +97 -25
- package/src/commands/profile.ts +29 -19
- package/src/commands/watch.ts +2 -2
- package/src/db.ts +28 -0
- package/src/generated/internal/class.ts +2 -2
- package/src/generated/internal/prismaNamespace.ts +2 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
- package/src/generated/models/Account.ts +97 -1
- package/src/gmail-client.test.ts +155 -2
- package/src/gmail-client.ts +258 -6
- package/src/imap-smtp-client.ts +1560 -0
- package/src/mail-tui.tsx +2 -1
- package/src/schema.sql +2 -0
- package/src/unsubscribe.test.ts +487 -0
- package/src/unsubscribe.ts +255 -0
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
// IMAP/SMTP email client for non-Google accounts.
|
|
2
|
+
// Mirrors the GmailClient method signatures and return types so commands
|
|
3
|
+
// can work with both client types without major rewrites.
|
|
4
|
+
// Each IMAP operation opens a fresh connection (connect → operate → logout)
|
|
5
|
+
// to avoid stale connection issues. SMTP uses nodemailer transporter.
|
|
6
|
+
// Threading: each IMAP message is treated as a single-message "thread"
|
|
7
|
+
// with threadId = "folder:uid" (e.g. "INBOX:12345").
|
|
8
|
+
import { ImapFlow } from 'imapflow';
|
|
9
|
+
import * as errore from 'errore';
|
|
10
|
+
import { AuthError, ApiError, UnsupportedError, EmptyThreadError, NotFoundError } from './api-utils.js';
|
|
11
|
+
import { renderEmailBody } from './output.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Parse a threadId in the format "FOLDER:UID" back to folder + uid. */
|
|
16
|
+
function parseThreadId(threadId) {
|
|
17
|
+
const idx = threadId.lastIndexOf(':');
|
|
18
|
+
if (idx === -1)
|
|
19
|
+
return { folder: 'INBOX', uid: Number(threadId) };
|
|
20
|
+
return { folder: threadId.slice(0, idx), uid: Number(threadId.slice(idx + 1)) };
|
|
21
|
+
}
|
|
22
|
+
/** Build a threadId from folder + uid. */
|
|
23
|
+
function makeThreadId(folder, uid) {
|
|
24
|
+
return `${folder}:${uid}`;
|
|
25
|
+
}
|
|
26
|
+
/** Static fallback map from zele folder names to IMAP folder paths.
|
|
27
|
+
* Used only when specialUse discovery fails. */
|
|
28
|
+
const FOLDER_FALLBACKS = {
|
|
29
|
+
sent: ['Sent', 'Sent Items', 'Sent Messages', 'INBOX.Sent'],
|
|
30
|
+
trash: ['Trash', 'Deleted Items', 'Deleted Messages', 'INBOX.Trash'],
|
|
31
|
+
spam: ['Junk', 'Junk Email', 'Spam', 'INBOX.Junk'],
|
|
32
|
+
drafts: ['Drafts', 'Draft', 'INBOX.Drafts'],
|
|
33
|
+
archive: ['Archive', 'Archives', 'All Mail', '[Gmail]/All Mail', 'INBOX.Archive'],
|
|
34
|
+
};
|
|
35
|
+
/** RFC 6154 specialUse attributes mapped to zele folder names. */
|
|
36
|
+
const SPECIAL_USE_MAP = {
|
|
37
|
+
sent: '\\Sent',
|
|
38
|
+
trash: '\\Trash',
|
|
39
|
+
bin: '\\Trash',
|
|
40
|
+
spam: '\\Junk',
|
|
41
|
+
drafts: '\\Drafts',
|
|
42
|
+
draft: '\\Drafts',
|
|
43
|
+
archive: '\\Archive',
|
|
44
|
+
};
|
|
45
|
+
/** Convert imapflow address objects to our Sender type. */
|
|
46
|
+
function toSender(addr) {
|
|
47
|
+
if (!addr)
|
|
48
|
+
return { email: 'unknown' };
|
|
49
|
+
return { name: addr.name || undefined, email: addr.address ?? 'unknown' };
|
|
50
|
+
}
|
|
51
|
+
function toSenders(addrs) {
|
|
52
|
+
if (!addrs || addrs.length === 0)
|
|
53
|
+
return [];
|
|
54
|
+
return addrs.map(toSender);
|
|
55
|
+
}
|
|
56
|
+
/** Basic client-side query filter for watch events.
|
|
57
|
+
* Supports: from:, to:, subject:, is:unread, is:starred, has:attachment, and plain text search. */
|
|
58
|
+
function matchesQuery(msg, query) {
|
|
59
|
+
const lower = query.toLowerCase();
|
|
60
|
+
// Handle specific operators
|
|
61
|
+
const fromMatch = lower.match(/from:(\S+)/);
|
|
62
|
+
if (fromMatch && !msg.from.email.toLowerCase().includes(fromMatch[1]))
|
|
63
|
+
return false;
|
|
64
|
+
const toMatch = lower.match(/to:(\S+)/);
|
|
65
|
+
if (toMatch && !msg.to.some((t) => t.email.toLowerCase().includes(toMatch[1])))
|
|
66
|
+
return false;
|
|
67
|
+
const subjectMatch = lower.match(/subject:(?:"([^"]+)"|(\S+))/);
|
|
68
|
+
if (subjectMatch) {
|
|
69
|
+
const term = (subjectMatch[1] ?? subjectMatch[2]).toLowerCase();
|
|
70
|
+
if (!msg.subject.toLowerCase().includes(term))
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (lower.includes('is:unread') && !msg.unread)
|
|
74
|
+
return false;
|
|
75
|
+
if (lower.includes('is:starred') && !msg.starred)
|
|
76
|
+
return false;
|
|
77
|
+
if (lower.includes('has:attachment') && msg.attachments.length === 0)
|
|
78
|
+
return false;
|
|
79
|
+
// Plain text: strip operators and check remaining text against subject/from
|
|
80
|
+
const plainText = lower
|
|
81
|
+
.replace(/from:\S+/g, '')
|
|
82
|
+
.replace(/to:\S+/g, '')
|
|
83
|
+
.replace(/subject:(?:"[^"]+"|[^\s]+)/g, '')
|
|
84
|
+
.replace(/is:\S+/g, '')
|
|
85
|
+
.replace(/has:\S+/g, '')
|
|
86
|
+
.trim();
|
|
87
|
+
if (plainText && !msg.subject.toLowerCase().includes(plainText) && !msg.from.email.toLowerCase().includes(plainText)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/** Boundary helper for imapflow calls — converts auth errors to typed values. */
|
|
93
|
+
function imapBoundary(email, fn) {
|
|
94
|
+
return errore.tryAsync({
|
|
95
|
+
try: fn,
|
|
96
|
+
catch: (err) => {
|
|
97
|
+
const msg = String(err);
|
|
98
|
+
if (msg.includes('Authentication') || msg.includes('AUTHENTICATIONFAILED') || msg.includes('LOGIN') || msg.includes('Invalid credentials')) {
|
|
99
|
+
return new AuthError({ email, reason: msg });
|
|
100
|
+
}
|
|
101
|
+
return new ApiError({ reason: msg, cause: err });
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// ImapSmtpClient
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
export class ImapSmtpClient {
|
|
109
|
+
imapCreds;
|
|
110
|
+
smtpCreds;
|
|
111
|
+
account;
|
|
112
|
+
smtpTransporter = null;
|
|
113
|
+
constructor({ credentials, account }) {
|
|
114
|
+
this.imapCreds = credentials.imap;
|
|
115
|
+
this.smtpCreds = credentials.smtp;
|
|
116
|
+
this.account = account;
|
|
117
|
+
}
|
|
118
|
+
// =========================================================================
|
|
119
|
+
// IMAP connection helpers
|
|
120
|
+
// =========================================================================
|
|
121
|
+
createImapClient() {
|
|
122
|
+
if (!this.imapCreds)
|
|
123
|
+
throw new Error('IMAP not configured for this account');
|
|
124
|
+
return new ImapFlow({
|
|
125
|
+
host: this.imapCreds.host,
|
|
126
|
+
port: this.imapCreds.port,
|
|
127
|
+
secure: this.imapCreds.tls,
|
|
128
|
+
auth: { user: this.imapCreds.user, pass: this.imapCreds.password },
|
|
129
|
+
logger: false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a zele folder name (inbox, sent, trash, etc.) to the actual IMAP
|
|
134
|
+
* mailbox path by checking RFC 6154 specialUse attributes first, then
|
|
135
|
+
* falling back to common mailbox name variants.
|
|
136
|
+
*/
|
|
137
|
+
async resolveMailboxPath(client, folder) {
|
|
138
|
+
const lower = folder.toLowerCase();
|
|
139
|
+
if (lower === 'inbox')
|
|
140
|
+
return 'INBOX';
|
|
141
|
+
if (lower === 'starred' || lower === 'all')
|
|
142
|
+
return 'INBOX';
|
|
143
|
+
// Check if it's a raw IMAP path that doesn't match any known folder name
|
|
144
|
+
const specialUse = SPECIAL_USE_MAP[lower];
|
|
145
|
+
if (!specialUse)
|
|
146
|
+
return folder; // raw IMAP path, pass through
|
|
147
|
+
// Discover via specialUse (RFC 6154)
|
|
148
|
+
const mailboxes = await client.list();
|
|
149
|
+
const bySpecialUse = mailboxes.find((m) => m.specialUse === specialUse);
|
|
150
|
+
if (bySpecialUse)
|
|
151
|
+
return bySpecialUse.path;
|
|
152
|
+
// Fallback: try common folder names
|
|
153
|
+
const fallbacks = FOLDER_FALLBACKS[lower];
|
|
154
|
+
if (fallbacks) {
|
|
155
|
+
const paths = new Set(mailboxes.map((m) => m.path));
|
|
156
|
+
for (const name of fallbacks) {
|
|
157
|
+
if (paths.has(name))
|
|
158
|
+
return name;
|
|
159
|
+
}
|
|
160
|
+
// Case-insensitive search as last resort
|
|
161
|
+
const lowerPaths = new Map(mailboxes.map((m) => [m.path.toLowerCase(), m.path]));
|
|
162
|
+
for (const name of fallbacks) {
|
|
163
|
+
const found = lowerPaths.get(name.toLowerCase());
|
|
164
|
+
if (found)
|
|
165
|
+
return found;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Ultimate fallback: capitalize first letter
|
|
169
|
+
return folder.charAt(0).toUpperCase() + folder.slice(1);
|
|
170
|
+
}
|
|
171
|
+
/** Run an IMAP operation with auto-connect/logout.
|
|
172
|
+
* The entire callback is wrapped in imapBoundary so any IMAP error
|
|
173
|
+
* (getMailboxLock, search, fetch, etc.) becomes an error value. */
|
|
174
|
+
async withImap(fn) {
|
|
175
|
+
const client = this.createImapClient();
|
|
176
|
+
const connectResult = await imapBoundary(this.account.email, () => client.connect());
|
|
177
|
+
if (connectResult instanceof Error)
|
|
178
|
+
return connectResult;
|
|
179
|
+
const result = await imapBoundary(this.account.email, () => fn(client));
|
|
180
|
+
await client.logout().catch(() => { });
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
async getSmtpTransporter() {
|
|
184
|
+
if (this.smtpTransporter)
|
|
185
|
+
return this.smtpTransporter;
|
|
186
|
+
if (!this.smtpCreds)
|
|
187
|
+
return new UnsupportedError({ feature: 'Sending email', accountType: 'IMAP-only', hint: 'Add SMTP with: zele login imap --email ... --smtp-host ...' });
|
|
188
|
+
const nodemailer = await import('nodemailer');
|
|
189
|
+
this.smtpTransporter = nodemailer.default.createTransport({
|
|
190
|
+
host: this.smtpCreds.host,
|
|
191
|
+
port: this.smtpCreds.port,
|
|
192
|
+
secure: this.smtpCreds.tls,
|
|
193
|
+
auth: { user: this.smtpCreds.user, pass: this.smtpCreds.password },
|
|
194
|
+
});
|
|
195
|
+
return this.smtpTransporter;
|
|
196
|
+
}
|
|
197
|
+
// =========================================================================
|
|
198
|
+
// Thread operations (IMAP messages as single-message "threads")
|
|
199
|
+
// =========================================================================
|
|
200
|
+
async listThreads({ query, folder, maxResults = 25, labelIds, pageToken, } = {}) {
|
|
201
|
+
const lowerFolder = folder?.toLowerCase();
|
|
202
|
+
const isStarred = lowerFolder === 'starred';
|
|
203
|
+
// IMAP has no "all mail" folder on most servers — reject explicitly
|
|
204
|
+
if (lowerFolder === 'all') {
|
|
205
|
+
return new UnsupportedError({
|
|
206
|
+
feature: '"All Mail" folder',
|
|
207
|
+
accountType: 'IMAP/SMTP',
|
|
208
|
+
hint: 'Use --folder inbox, sent, trash, or another specific folder.',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return this.withImap(async (client) => {
|
|
212
|
+
const imapFolder = await this.resolveMailboxPath(client, folder ?? 'inbox');
|
|
213
|
+
const lock = await client.getMailboxLock(imapFolder);
|
|
214
|
+
try {
|
|
215
|
+
// Build search criteria — start with base criteria from folder
|
|
216
|
+
let searchCriteria = isStarred ? { flagged: true } : { all: true };
|
|
217
|
+
if (query) {
|
|
218
|
+
// Best-effort IMAP search: translate Gmail query syntax to IMAP SEARCH.
|
|
219
|
+
// Supported: from:, to:, subject:, newer_than:Nd/Nm, older_than:Nd/Nm,
|
|
220
|
+
// after:YYYY/MM/DD, before:YYYY/MM/DD, is:unread, is:starred,
|
|
221
|
+
// has:attachment, and plain text.
|
|
222
|
+
// Preserve base criteria (e.g. flagged from --folder starred)
|
|
223
|
+
const baseCriteria = isStarred ? { flagged: true } : {};
|
|
224
|
+
searchCriteria = { ...baseCriteria };
|
|
225
|
+
let hasSpecificCriteria = isStarred;
|
|
226
|
+
const fromMatch = query.match(/from:(\S+)/i);
|
|
227
|
+
if (fromMatch) {
|
|
228
|
+
searchCriteria.from = fromMatch[1];
|
|
229
|
+
hasSpecificCriteria = true;
|
|
230
|
+
}
|
|
231
|
+
const toMatch = query.match(/to:(\S+)/i);
|
|
232
|
+
if (toMatch) {
|
|
233
|
+
searchCriteria.to = toMatch[1];
|
|
234
|
+
hasSpecificCriteria = true;
|
|
235
|
+
}
|
|
236
|
+
const subjectMatch = query.match(/subject:(?:"([^"]+)"|(\S+))/i);
|
|
237
|
+
if (subjectMatch) {
|
|
238
|
+
searchCriteria.subject = subjectMatch[1] ?? subjectMatch[2];
|
|
239
|
+
hasSpecificCriteria = true;
|
|
240
|
+
}
|
|
241
|
+
// Date filters: newer_than:2d, newer_than:1m (days/months)
|
|
242
|
+
const newerMatch = query.match(/newer_than:(\d+)([dm])/i);
|
|
243
|
+
if (newerMatch) {
|
|
244
|
+
const n = Number(newerMatch[1]);
|
|
245
|
+
const unit = newerMatch[2].toLowerCase();
|
|
246
|
+
const since = new Date();
|
|
247
|
+
if (unit === 'd')
|
|
248
|
+
since.setDate(since.getDate() - n);
|
|
249
|
+
else
|
|
250
|
+
since.setMonth(since.getMonth() - n);
|
|
251
|
+
searchCriteria.since = since;
|
|
252
|
+
hasSpecificCriteria = true;
|
|
253
|
+
}
|
|
254
|
+
const olderMatch = query.match(/older_than:(\d+)([dm])/i);
|
|
255
|
+
if (olderMatch) {
|
|
256
|
+
const n = Number(olderMatch[1]);
|
|
257
|
+
const unit = olderMatch[2].toLowerCase();
|
|
258
|
+
const before = new Date();
|
|
259
|
+
if (unit === 'd')
|
|
260
|
+
before.setDate(before.getDate() - n);
|
|
261
|
+
else
|
|
262
|
+
before.setMonth(before.getMonth() - n);
|
|
263
|
+
searchCriteria.before = before;
|
|
264
|
+
hasSpecificCriteria = true;
|
|
265
|
+
}
|
|
266
|
+
// after:YYYY/MM/DD and before:YYYY/MM/DD
|
|
267
|
+
const afterMatch = query.match(/after:(\d{4}\/\d{1,2}\/\d{1,2})/i);
|
|
268
|
+
if (afterMatch) {
|
|
269
|
+
searchCriteria.since = new Date(afterMatch[1].replace(/\//g, '-'));
|
|
270
|
+
hasSpecificCriteria = true;
|
|
271
|
+
}
|
|
272
|
+
const beforeMatch = query.match(/before:(\d{4}\/\d{1,2}\/\d{1,2})/i);
|
|
273
|
+
if (beforeMatch) {
|
|
274
|
+
searchCriteria.before = new Date(beforeMatch[1].replace(/\//g, '-'));
|
|
275
|
+
hasSpecificCriteria = true;
|
|
276
|
+
}
|
|
277
|
+
// Flag filters
|
|
278
|
+
if (/is:unread/i.test(query)) {
|
|
279
|
+
searchCriteria.unseen = true;
|
|
280
|
+
hasSpecificCriteria = true;
|
|
281
|
+
}
|
|
282
|
+
if (/is:starred/i.test(query)) {
|
|
283
|
+
searchCriteria.flagged = true;
|
|
284
|
+
hasSpecificCriteria = true;
|
|
285
|
+
}
|
|
286
|
+
if (/has:attachment/i.test(query)) {
|
|
287
|
+
searchCriteria.header = { 'Content-Type': 'multipart/mixed' };
|
|
288
|
+
hasSpecificCriteria = true;
|
|
289
|
+
}
|
|
290
|
+
// Plain text remainder (strip known operators)
|
|
291
|
+
const plainText = query
|
|
292
|
+
.replace(/from:\S+/gi, '')
|
|
293
|
+
.replace(/to:\S+/gi, '')
|
|
294
|
+
.replace(/subject:(?:"[^"]+"|[^\s]+)/gi, '')
|
|
295
|
+
.replace(/newer_than:\S+/gi, '')
|
|
296
|
+
.replace(/older_than:\S+/gi, '')
|
|
297
|
+
.replace(/after:\S+/gi, '')
|
|
298
|
+
.replace(/before:\S+/gi, '')
|
|
299
|
+
.replace(/is:\S+/gi, '')
|
|
300
|
+
.replace(/has:\S+/gi, '')
|
|
301
|
+
.trim();
|
|
302
|
+
if (plainText) {
|
|
303
|
+
// Search in subject and body for remaining text
|
|
304
|
+
if (hasSpecificCriteria) {
|
|
305
|
+
searchCriteria.body = plainText;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
searchCriteria = { or: [{ subject: plainText }, { body: plainText }] };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (!hasSpecificCriteria) {
|
|
312
|
+
searchCriteria = { all: true };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const searchResult = await client.search(searchCriteria, { uid: true });
|
|
316
|
+
const uids = searchResult === false ? [] : searchResult;
|
|
317
|
+
if (uids.length === 0) {
|
|
318
|
+
return {
|
|
319
|
+
threads: [],
|
|
320
|
+
rawThreads: [],
|
|
321
|
+
nextPageToken: null,
|
|
322
|
+
resultSizeEstimate: 0,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
// Sort by UID descending (newest first) and paginate
|
|
326
|
+
const sorted = [...uids].sort((a, b) => b - a);
|
|
327
|
+
const startIndex = pageToken ? Number(pageToken) : 0;
|
|
328
|
+
const page = sorted.slice(startIndex, startIndex + maxResults);
|
|
329
|
+
const nextPageToken = startIndex + maxResults < sorted.length
|
|
330
|
+
? String(startIndex + maxResults)
|
|
331
|
+
: null;
|
|
332
|
+
// Fetch envelope data for the page
|
|
333
|
+
const threads = [];
|
|
334
|
+
if (page.length > 0) {
|
|
335
|
+
const uidRange = page.join(',');
|
|
336
|
+
for await (const msg of client.fetch(uidRange, {
|
|
337
|
+
uid: true,
|
|
338
|
+
envelope: true,
|
|
339
|
+
flags: true,
|
|
340
|
+
bodyStructure: true,
|
|
341
|
+
}, { uid: true })) {
|
|
342
|
+
const env = msg.envelope;
|
|
343
|
+
if (!env)
|
|
344
|
+
continue;
|
|
345
|
+
const flags = msg.flags ?? new Set();
|
|
346
|
+
const threadId = makeThreadId(imapFolder, msg.uid);
|
|
347
|
+
threads.push({
|
|
348
|
+
id: threadId,
|
|
349
|
+
historyId: null,
|
|
350
|
+
snippet: env.subject ?? '',
|
|
351
|
+
subject: env.subject ?? '(no subject)',
|
|
352
|
+
from: toSender(env.from?.[0]),
|
|
353
|
+
to: toSenders(env.to),
|
|
354
|
+
cc: toSenders(env.cc),
|
|
355
|
+
date: env.date?.toISOString() ?? new Date().toISOString(),
|
|
356
|
+
labelIds: [],
|
|
357
|
+
unread: !flags.has('\\Seen'),
|
|
358
|
+
starred: flags.has('\\Flagged'),
|
|
359
|
+
messageCount: 1,
|
|
360
|
+
inReplyTo: env.inReplyTo ?? null,
|
|
361
|
+
hasAttachments: this.hasAttachments(msg),
|
|
362
|
+
// IMAP list view uses envelope-only fetch, so raw headers aren't
|
|
363
|
+
// available. List-Unsubscribe stays null in list mode; it's
|
|
364
|
+
// resolved during getThread() where `source: true` is fetched.
|
|
365
|
+
listUnsubscribe: null,
|
|
366
|
+
listUnsubscribePost: null,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Sort by date descending (envelopes may not come in order)
|
|
371
|
+
threads.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
372
|
+
return {
|
|
373
|
+
threads,
|
|
374
|
+
rawThreads: [],
|
|
375
|
+
nextPageToken,
|
|
376
|
+
resultSizeEstimate: sorted.length,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
lock.release();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async getThread({ threadId }) {
|
|
385
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
386
|
+
const result = await this.withImap(async (client) => {
|
|
387
|
+
const lock = await client.getMailboxLock(folder);
|
|
388
|
+
try {
|
|
389
|
+
// Fetch full message with body
|
|
390
|
+
let message = null;
|
|
391
|
+
for await (const msg of client.fetch(String(uid), {
|
|
392
|
+
uid: true,
|
|
393
|
+
envelope: true,
|
|
394
|
+
flags: true,
|
|
395
|
+
bodyStructure: true,
|
|
396
|
+
source: true,
|
|
397
|
+
}, { uid: true })) {
|
|
398
|
+
message = msg;
|
|
399
|
+
}
|
|
400
|
+
if (!message) {
|
|
401
|
+
return new NotFoundError({ resource: `message ${threadId}` });
|
|
402
|
+
}
|
|
403
|
+
const parsed = this.parseImapMessage(message, folder);
|
|
404
|
+
const threadData = {
|
|
405
|
+
id: threadId,
|
|
406
|
+
historyId: null,
|
|
407
|
+
messages: [parsed],
|
|
408
|
+
subject: parsed.subject,
|
|
409
|
+
snippet: parsed.snippet,
|
|
410
|
+
from: parsed.from,
|
|
411
|
+
date: parsed.date,
|
|
412
|
+
labelIds: [],
|
|
413
|
+
hasUnread: parsed.unread,
|
|
414
|
+
messageCount: 1,
|
|
415
|
+
};
|
|
416
|
+
return { parsed: threadData, raw: {} };
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
lock.release();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
// getThread is expected to throw on failure (same as GmailClient)
|
|
423
|
+
// because callers like mail read destructure the result directly.
|
|
424
|
+
if (result instanceof Error)
|
|
425
|
+
throw result;
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
async getMessage({ messageId }) {
|
|
429
|
+
// For IMAP, messageId is the same as threadId
|
|
430
|
+
const result = await this.getThread({ threadId: messageId });
|
|
431
|
+
return result.parsed.messages[0];
|
|
432
|
+
}
|
|
433
|
+
async getRawMessage({ messageId }) {
|
|
434
|
+
const { folder, uid } = parseThreadId(messageId);
|
|
435
|
+
return this.withImap(async (client) => {
|
|
436
|
+
const lock = await client.getMailboxLock(folder);
|
|
437
|
+
try {
|
|
438
|
+
for await (const msg of client.fetch(String(uid), {
|
|
439
|
+
uid: true,
|
|
440
|
+
source: true,
|
|
441
|
+
}, { uid: true })) {
|
|
442
|
+
if (msg.source) {
|
|
443
|
+
return msg.source.toString('utf-8');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return new NotFoundError({ resource: `message ${messageId}` });
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
lock.release();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// =========================================================================
|
|
454
|
+
// Send operations (SMTP)
|
|
455
|
+
// =========================================================================
|
|
456
|
+
async sendMessage({ to, subject, body, cc, bcc, inReplyTo, references, attachments, }) {
|
|
457
|
+
const transporter = await this.getSmtpTransporter();
|
|
458
|
+
if (transporter instanceof Error)
|
|
459
|
+
return transporter;
|
|
460
|
+
const fromEmail = this.account.email;
|
|
461
|
+
const mailOptions = {
|
|
462
|
+
from: fromEmail,
|
|
463
|
+
to: to.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', '),
|
|
464
|
+
subject,
|
|
465
|
+
text: body,
|
|
466
|
+
};
|
|
467
|
+
if (cc && cc.length > 0) {
|
|
468
|
+
mailOptions.cc = cc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ');
|
|
469
|
+
}
|
|
470
|
+
if (bcc && bcc.length > 0) {
|
|
471
|
+
mailOptions.bcc = bcc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ');
|
|
472
|
+
}
|
|
473
|
+
if (inReplyTo) {
|
|
474
|
+
mailOptions.inReplyTo = inReplyTo;
|
|
475
|
+
}
|
|
476
|
+
if (references) {
|
|
477
|
+
mailOptions.references = references;
|
|
478
|
+
}
|
|
479
|
+
if (attachments && attachments.length > 0) {
|
|
480
|
+
mailOptions.attachments = attachments.map((a) => ({
|
|
481
|
+
filename: a.filename,
|
|
482
|
+
content: a.content,
|
|
483
|
+
contentType: a.mimeType,
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
const sendResult = await transporter.sendMail(mailOptions)
|
|
487
|
+
.catch((e) => new ApiError({ reason: `SMTP send failed: ${String(e)}`, cause: e }));
|
|
488
|
+
if (sendResult instanceof Error)
|
|
489
|
+
return sendResult;
|
|
490
|
+
// APPEND a copy to the Sent folder so `mail list --folder sent` shows it.
|
|
491
|
+
// SMTP alone doesn't guarantee a copy in the mailbox.
|
|
492
|
+
// Build the raw MIME using nodemailer's MailComposer so attachments, HTML, etc. are preserved.
|
|
493
|
+
if (this.imapCreds) {
|
|
494
|
+
const nodemailer = await import('nodemailer');
|
|
495
|
+
const MailComposer = nodemailer.default?.MailComposer ?? nodemailer.MailComposer;
|
|
496
|
+
const rawMime = MailComposer
|
|
497
|
+
? await new Promise((resolve, reject) => {
|
|
498
|
+
const mail = new MailComposer({ ...mailOptions, messageId: sendResult.messageId });
|
|
499
|
+
mail.compile().build((err, message) => {
|
|
500
|
+
if (err)
|
|
501
|
+
reject(err);
|
|
502
|
+
else
|
|
503
|
+
resolve(message);
|
|
504
|
+
});
|
|
505
|
+
}).catch((e) => new ApiError({ reason: `Failed to compile MIME for Sent copy: ${String(e)}`, cause: e }))
|
|
506
|
+
: (() => {
|
|
507
|
+
// Fallback: build plain-text RFC 822 if MailComposer unavailable
|
|
508
|
+
const rawHeaders = [
|
|
509
|
+
`From: ${fromEmail}`,
|
|
510
|
+
`To: ${mailOptions.to}`,
|
|
511
|
+
`Subject: ${subject}`,
|
|
512
|
+
`Date: ${new Date().toUTCString()}`,
|
|
513
|
+
`MIME-Version: 1.0`,
|
|
514
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
515
|
+
...(mailOptions.cc ? [`Cc: ${mailOptions.cc}`] : []),
|
|
516
|
+
...(inReplyTo ? [`In-Reply-To: ${inReplyTo}`] : []),
|
|
517
|
+
...(references ? [`References: ${references}`] : []),
|
|
518
|
+
...(sendResult.messageId ? [`Message-ID: ${sendResult.messageId}`] : []),
|
|
519
|
+
];
|
|
520
|
+
return Buffer.from(rawHeaders.join('\r\n') + '\r\n\r\n' + body);
|
|
521
|
+
})();
|
|
522
|
+
if (rawMime instanceof Error) {
|
|
523
|
+
console.warn('Failed to build MIME for Sent copy:', rawMime.message);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
const appendResult = await this.withImap(async (client) => {
|
|
527
|
+
const sentPath = await this.resolveMailboxPath(client, 'sent');
|
|
528
|
+
await client.append(sentPath, rawMime, ['\\Seen']);
|
|
529
|
+
});
|
|
530
|
+
if (appendResult instanceof Error) {
|
|
531
|
+
console.warn('Sent message but failed to save to Sent folder:', appendResult.message);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
id: sendResult.messageId ?? 'unknown',
|
|
537
|
+
threadId: 'unknown',
|
|
538
|
+
labelIds: ['SENT'],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
async replyToThread({ threadId, body, replyAll = false, cc, fromEmail, }) {
|
|
542
|
+
const thread = await this.getThread({ threadId });
|
|
543
|
+
if (thread.parsed.messages.length === 0) {
|
|
544
|
+
return new EmptyThreadError({ threadId });
|
|
545
|
+
}
|
|
546
|
+
const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
|
|
547
|
+
const replyTo = lastMsg.replyTo ?? lastMsg.from.email;
|
|
548
|
+
const to = [{ email: replyTo }];
|
|
549
|
+
let resolvedCc;
|
|
550
|
+
if (replyAll) {
|
|
551
|
+
const myEmail = this.account.email.toLowerCase();
|
|
552
|
+
const allRecipients = [
|
|
553
|
+
...lastMsg.to.map((r) => r.email),
|
|
554
|
+
...(lastMsg.cc?.map((r) => r.email) ?? []),
|
|
555
|
+
]
|
|
556
|
+
.filter((e) => e.toLowerCase() !== myEmail)
|
|
557
|
+
.filter((e) => e.toLowerCase() !== replyTo.toLowerCase());
|
|
558
|
+
if (allRecipients.length > 0) {
|
|
559
|
+
resolvedCc = allRecipients.map((e) => ({ email: e }));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (cc) {
|
|
563
|
+
resolvedCc = [...(resolvedCc ?? []), ...cc];
|
|
564
|
+
}
|
|
565
|
+
const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ');
|
|
566
|
+
return this.sendMessage({
|
|
567
|
+
to,
|
|
568
|
+
subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
|
|
569
|
+
body,
|
|
570
|
+
cc: resolvedCc,
|
|
571
|
+
inReplyTo: lastMsg.messageId,
|
|
572
|
+
references: refs || undefined,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
async forwardThread({ threadId, to, body, fromEmail, }) {
|
|
576
|
+
const thread = await this.getThread({ threadId });
|
|
577
|
+
if (thread.parsed.messages.length === 0) {
|
|
578
|
+
return new EmptyThreadError({ threadId });
|
|
579
|
+
}
|
|
580
|
+
const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
|
|
581
|
+
const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType);
|
|
582
|
+
const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
|
|
583
|
+
? `${lastMsg.from.name} <${lastMsg.from.email}>`
|
|
584
|
+
: lastMsg.from.email;
|
|
585
|
+
const fullBody = [
|
|
586
|
+
body ?? '',
|
|
587
|
+
'',
|
|
588
|
+
'---------- Forwarded message ----------',
|
|
589
|
+
`From: ${fromStr}`,
|
|
590
|
+
`Date: ${lastMsg.date}`,
|
|
591
|
+
`Subject: ${lastMsg.subject}`,
|
|
592
|
+
`To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
|
|
593
|
+
'',
|
|
594
|
+
renderedBody,
|
|
595
|
+
].join('\n');
|
|
596
|
+
return this.sendMessage({
|
|
597
|
+
to,
|
|
598
|
+
subject: `Fwd: ${lastMsg.subject}`,
|
|
599
|
+
body: fullBody,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// =========================================================================
|
|
603
|
+
// Flag operations (IMAP STORE)
|
|
604
|
+
// =========================================================================
|
|
605
|
+
async star({ threadIds }) {
|
|
606
|
+
return this.modifyFlags(threadIds, { add: ['\\Flagged'] });
|
|
607
|
+
}
|
|
608
|
+
async unstar({ threadIds }) {
|
|
609
|
+
return this.modifyFlags(threadIds, { remove: ['\\Flagged'] });
|
|
610
|
+
}
|
|
611
|
+
async markAsRead({ threadIds }) {
|
|
612
|
+
return this.modifyFlags(threadIds, { add: ['\\Seen'] });
|
|
613
|
+
}
|
|
614
|
+
async markAsUnread({ threadIds }) {
|
|
615
|
+
return this.modifyFlags(threadIds, { remove: ['\\Seen'] });
|
|
616
|
+
}
|
|
617
|
+
async trash({ threadId }) {
|
|
618
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
619
|
+
return this.withImap(async (client) => {
|
|
620
|
+
const trashPath = await this.resolveMailboxPath(client, 'trash');
|
|
621
|
+
const lock = await client.getMailboxLock(folder);
|
|
622
|
+
try {
|
|
623
|
+
const moved = await errore.tryAsync({
|
|
624
|
+
try: () => client.messageMove(String(uid), trashPath, { uid: true }),
|
|
625
|
+
catch: (err) => new ApiError({ reason: `Failed to move to Trash: ${String(err)}`, cause: err }),
|
|
626
|
+
});
|
|
627
|
+
if (moved instanceof Error) {
|
|
628
|
+
// Fallback: set \Deleted flag
|
|
629
|
+
await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
lock.release();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
async untrash({ threadId }) {
|
|
638
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
639
|
+
// Move from whatever folder back to INBOX
|
|
640
|
+
return this.withImap(async (client) => {
|
|
641
|
+
const lock = await client.getMailboxLock(folder);
|
|
642
|
+
try {
|
|
643
|
+
await client.messageMove(String(uid), 'INBOX', { uid: true });
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
lock.release();
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async archive({ threadIds }) {
|
|
651
|
+
for (const threadId of threadIds) {
|
|
652
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
653
|
+
const result = await this.withImap(async (client) => {
|
|
654
|
+
const archivePath = await this.resolveMailboxPath(client, 'archive');
|
|
655
|
+
const lock = await client.getMailboxLock(folder);
|
|
656
|
+
try {
|
|
657
|
+
const moved = await errore.tryAsync({
|
|
658
|
+
try: () => client.messageMove(String(uid), archivePath, { uid: true }),
|
|
659
|
+
catch: (err) => new ApiError({ reason: `Failed to move to Archive: ${String(err)}`, cause: err }),
|
|
660
|
+
});
|
|
661
|
+
if (moved instanceof Error) {
|
|
662
|
+
// No archive folder available — mark as read as a minimal archive behavior
|
|
663
|
+
await client.messageFlagsAdd(String(uid), ['\\Seen'], { uid: true });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
lock.release();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
if (result instanceof Error)
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async markAsSpam({ threadIds }) {
|
|
675
|
+
for (const threadId of threadIds) {
|
|
676
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
677
|
+
const result = await this.withImap(async (client) => {
|
|
678
|
+
const junkPath = await this.resolveMailboxPath(client, 'spam');
|
|
679
|
+
const lock = await client.getMailboxLock(folder);
|
|
680
|
+
try {
|
|
681
|
+
const moveResult = await errore.tryAsync({
|
|
682
|
+
try: () => client.messageMove(String(uid), junkPath, { uid: true }),
|
|
683
|
+
catch: (err) => new ApiError({ reason: `Failed to move to Junk: ${String(err)}`, cause: err }),
|
|
684
|
+
});
|
|
685
|
+
if (moveResult instanceof Error) {
|
|
686
|
+
// Fallback: set $Junk keyword
|
|
687
|
+
await client.messageFlagsAdd(String(uid), ['$Junk'], { uid: true });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
lock.release();
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
if (result instanceof Error)
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async unmarkSpam({ threadIds }) {
|
|
699
|
+
for (const threadId of threadIds) {
|
|
700
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
701
|
+
const result = await this.withImap(async (client) => {
|
|
702
|
+
const lock = await client.getMailboxLock(folder);
|
|
703
|
+
try {
|
|
704
|
+
await client.messageMove(String(uid), 'INBOX', { uid: true });
|
|
705
|
+
}
|
|
706
|
+
finally {
|
|
707
|
+
lock.release();
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
if (result instanceof Error)
|
|
711
|
+
return result;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
async trashAllSpam() {
|
|
715
|
+
return this.withImap(async (client) => {
|
|
716
|
+
const junkPath = await this.resolveMailboxPath(client, 'spam');
|
|
717
|
+
const lock = await client.getMailboxLock(junkPath);
|
|
718
|
+
try {
|
|
719
|
+
const searchResult = await client.search({ all: true }, { uid: true });
|
|
720
|
+
const uids = searchResult === false ? [] : searchResult;
|
|
721
|
+
if (uids.length === 0)
|
|
722
|
+
return { count: 0 };
|
|
723
|
+
// Move all to Trash
|
|
724
|
+
const uidRange = uids.join(',');
|
|
725
|
+
const trashPath = await this.resolveMailboxPath(client, 'trash');
|
|
726
|
+
const moveResult = await errore.tryAsync({
|
|
727
|
+
try: () => client.messageMove(uidRange, trashPath, { uid: true }),
|
|
728
|
+
catch: (err) => new ApiError({ reason: `Failed to move spam to Trash: ${String(err)}`, cause: err }),
|
|
729
|
+
});
|
|
730
|
+
if (moveResult instanceof Error) {
|
|
731
|
+
await client.messageFlagsAdd(uidRange, ['\\Deleted'], { uid: true });
|
|
732
|
+
}
|
|
733
|
+
return { count: uids.length };
|
|
734
|
+
}
|
|
735
|
+
finally {
|
|
736
|
+
lock.release();
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
// =========================================================================
|
|
741
|
+
// Label operations (not supported for IMAP)
|
|
742
|
+
// =========================================================================
|
|
743
|
+
async listLabels() {
|
|
744
|
+
return new UnsupportedError({
|
|
745
|
+
feature: 'Labels',
|
|
746
|
+
accountType: 'IMAP/SMTP',
|
|
747
|
+
hint: 'IMAP accounts use folders. Use --folder to browse different mailboxes.',
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
async modifyLabels(_opts) {
|
|
751
|
+
return new UnsupportedError({
|
|
752
|
+
feature: 'Label modification',
|
|
753
|
+
accountType: 'IMAP/SMTP',
|
|
754
|
+
hint: 'IMAP accounts use folders, not labels.',
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
// =========================================================================
|
|
758
|
+
// Profile
|
|
759
|
+
// =========================================================================
|
|
760
|
+
async getProfile() {
|
|
761
|
+
// For IMAP, we can get basic info but not Gmail-specific stats
|
|
762
|
+
return {
|
|
763
|
+
emailAddress: this.account.email,
|
|
764
|
+
messagesTotal: 0,
|
|
765
|
+
threadsTotal: 0,
|
|
766
|
+
historyId: '0',
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
async getEmailAliases() {
|
|
770
|
+
// IMAP doesn't have send-as aliases
|
|
771
|
+
return [{ email: this.account.email, primary: true }];
|
|
772
|
+
}
|
|
773
|
+
// =========================================================================
|
|
774
|
+
// Attachment operations
|
|
775
|
+
// =========================================================================
|
|
776
|
+
async getAttachment({ messageId, attachmentId }) {
|
|
777
|
+
const { folder, uid } = parseThreadId(messageId);
|
|
778
|
+
return this.withImap(async (client) => {
|
|
779
|
+
const lock = await client.getMailboxLock(folder);
|
|
780
|
+
try {
|
|
781
|
+
// attachmentId is the MIME part number (e.g. "2", "1.2")
|
|
782
|
+
for await (const msg of client.fetch(String(uid), {
|
|
783
|
+
uid: true,
|
|
784
|
+
bodyParts: [attachmentId],
|
|
785
|
+
}, { uid: true })) {
|
|
786
|
+
const parts = msg.bodyParts;
|
|
787
|
+
if (parts) {
|
|
788
|
+
for (const [_key, value] of parts) {
|
|
789
|
+
return value.toString('base64');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return new NotFoundError({ resource: `attachment ${attachmentId} in message ${messageId}` });
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
lock.release();
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
// =========================================================================
|
|
801
|
+
// Watch (IMAP polling — simplified version without IDLE)
|
|
802
|
+
// =========================================================================
|
|
803
|
+
async *watchInbox({ folder = 'inbox', intervalMs = 15_000, query, once = false, } = {}) {
|
|
804
|
+
// Resolve folder path once (use a fresh connection)
|
|
805
|
+
let imapFolder = 'INBOX';
|
|
806
|
+
const resolveResult = await this.withImap(async (client) => {
|
|
807
|
+
return this.resolveMailboxPath(client, folder);
|
|
808
|
+
});
|
|
809
|
+
if (resolveResult instanceof Error)
|
|
810
|
+
throw resolveResult;
|
|
811
|
+
imapFolder = resolveResult;
|
|
812
|
+
let lastUid = 0;
|
|
813
|
+
// Seed with current highest UID
|
|
814
|
+
const seedResult = await this.withImap(async (client) => {
|
|
815
|
+
const lock = await client.getMailboxLock(imapFolder);
|
|
816
|
+
try {
|
|
817
|
+
const searchResult = await client.search({ all: true }, { uid: true });
|
|
818
|
+
const uids = searchResult === false ? [] : searchResult;
|
|
819
|
+
return uids.length > 0 ? Math.max(...uids) : 0;
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
lock.release();
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
if (seedResult instanceof Error)
|
|
826
|
+
throw seedResult;
|
|
827
|
+
lastUid = seedResult;
|
|
828
|
+
while (true) {
|
|
829
|
+
// Check for new messages since lastUid
|
|
830
|
+
const pollResult = await this.withImap(async (client) => {
|
|
831
|
+
const lock = await client.getMailboxLock(imapFolder);
|
|
832
|
+
try {
|
|
833
|
+
// Search for UIDs > lastUid
|
|
834
|
+
const searchResult = await client.search({ uid: `${lastUid + 1}:*` }, { uid: true });
|
|
835
|
+
const uids = searchResult === false ? [] : searchResult;
|
|
836
|
+
const newUids = uids.filter((u) => u > lastUid);
|
|
837
|
+
const events = [];
|
|
838
|
+
if (newUids.length > 0) {
|
|
839
|
+
const uidRange = newUids.join(',');
|
|
840
|
+
for await (const msg of client.fetch(uidRange, {
|
|
841
|
+
uid: true,
|
|
842
|
+
envelope: true,
|
|
843
|
+
flags: true,
|
|
844
|
+
source: true,
|
|
845
|
+
}, { uid: true })) {
|
|
846
|
+
const parsed = this.parseImapMessage(msg, imapFolder);
|
|
847
|
+
events.push({
|
|
848
|
+
account: this.account,
|
|
849
|
+
type: 'new_message',
|
|
850
|
+
message: parsed,
|
|
851
|
+
threadId: makeThreadId(imapFolder, msg.uid),
|
|
852
|
+
});
|
|
853
|
+
if (msg.uid > lastUid)
|
|
854
|
+
lastUid = msg.uid;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return events;
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
lock.release();
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
if (pollResult instanceof Error)
|
|
864
|
+
throw pollResult;
|
|
865
|
+
for (const event of pollResult) {
|
|
866
|
+
// Client-side query filtering (basic: from:, to:, subject:, is:unread, is:starred)
|
|
867
|
+
if (query && !matchesQuery(event.message, query))
|
|
868
|
+
continue;
|
|
869
|
+
yield event;
|
|
870
|
+
}
|
|
871
|
+
if (once)
|
|
872
|
+
return;
|
|
873
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// =========================================================================
|
|
877
|
+
// Draft operations (IMAP Drafts folder)
|
|
878
|
+
// =========================================================================
|
|
879
|
+
async listDrafts({ query, maxResults = 20, pageToken, } = {}) {
|
|
880
|
+
return this.withImap(async (client) => {
|
|
881
|
+
const draftsPath = await this.resolveMailboxPath(client, 'drafts');
|
|
882
|
+
const lock = await client.getMailboxLock(draftsPath);
|
|
883
|
+
try {
|
|
884
|
+
const searchCriteria = query ? { or: [{ subject: query }, { body: query }] } : { all: true };
|
|
885
|
+
const searchResult = await client.search(searchCriteria, { uid: true });
|
|
886
|
+
const uids = searchResult === false ? [] : searchResult;
|
|
887
|
+
const sorted = [...uids].sort((a, b) => b - a);
|
|
888
|
+
const startIndex = pageToken ? Number(pageToken) : 0;
|
|
889
|
+
const page = sorted.slice(startIndex, startIndex + maxResults);
|
|
890
|
+
const nextPageToken = startIndex + maxResults < sorted.length ? String(startIndex + maxResults) : null;
|
|
891
|
+
const drafts = [];
|
|
892
|
+
if (page.length > 0) {
|
|
893
|
+
const uidRange = page.join(',');
|
|
894
|
+
for await (const msg of client.fetch(uidRange, {
|
|
895
|
+
uid: true,
|
|
896
|
+
envelope: true,
|
|
897
|
+
}, { uid: true })) {
|
|
898
|
+
const env = msg.envelope;
|
|
899
|
+
if (!env)
|
|
900
|
+
continue;
|
|
901
|
+
drafts.push({
|
|
902
|
+
id: makeThreadId(draftsPath, msg.uid),
|
|
903
|
+
subject: env.subject ?? '(no subject)',
|
|
904
|
+
to: (env.to ?? []).map((a) => a.address ?? '').filter(Boolean),
|
|
905
|
+
date: env.date?.toISOString() ?? new Date().toISOString(),
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return { drafts, nextPageToken };
|
|
910
|
+
}
|
|
911
|
+
finally {
|
|
912
|
+
lock.release();
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
async createDraft({ to, subject, body, cc, bcc, threadId, fromEmail, }) {
|
|
917
|
+
// Build MIME message and APPEND to Drafts folder
|
|
918
|
+
const headers = [
|
|
919
|
+
`From: ${fromEmail ?? this.account.email}`,
|
|
920
|
+
`To: ${to.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`,
|
|
921
|
+
`Subject: ${subject}`,
|
|
922
|
+
`Date: ${new Date().toUTCString()}`,
|
|
923
|
+
`MIME-Version: 1.0`,
|
|
924
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
925
|
+
];
|
|
926
|
+
if (cc && cc.length > 0) {
|
|
927
|
+
headers.push(`Cc: ${cc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`);
|
|
928
|
+
}
|
|
929
|
+
if (bcc && bcc.length > 0) {
|
|
930
|
+
headers.push(`Bcc: ${bcc.map((r) => r.name ? `"${r.name}" <${r.email}>` : r.email).join(', ')}`);
|
|
931
|
+
}
|
|
932
|
+
const raw = headers.join('\r\n') + '\r\n\r\n' + body;
|
|
933
|
+
const rawBuffer = Buffer.from(raw);
|
|
934
|
+
const result = await this.withImap(async (client) => {
|
|
935
|
+
const draftsPath = await this.resolveMailboxPath(client, 'drafts');
|
|
936
|
+
const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen']);
|
|
937
|
+
const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined;
|
|
938
|
+
return {
|
|
939
|
+
id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
|
|
940
|
+
message: { id: 'unknown' },
|
|
941
|
+
threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
|
|
942
|
+
};
|
|
943
|
+
});
|
|
944
|
+
if (result instanceof Error)
|
|
945
|
+
throw result;
|
|
946
|
+
return result;
|
|
947
|
+
}
|
|
948
|
+
async getDraft({ draftId }) {
|
|
949
|
+
// Reuse getThread to fetch the full message from Drafts folder
|
|
950
|
+
const result = await this.getThread({ threadId: draftId });
|
|
951
|
+
const msg = result.parsed.messages[0];
|
|
952
|
+
return {
|
|
953
|
+
id: draftId,
|
|
954
|
+
message: msg,
|
|
955
|
+
to: msg.to,
|
|
956
|
+
cc: msg.cc ?? [],
|
|
957
|
+
bcc: msg.bcc,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
async sendDraft({ draftId }) {
|
|
961
|
+
// Fetch the draft message, send it via SMTP, then delete the draft
|
|
962
|
+
const draft = await this.getDraft({ draftId });
|
|
963
|
+
const result = await this.sendMessage({
|
|
964
|
+
to: draft.to,
|
|
965
|
+
subject: draft.message.subject,
|
|
966
|
+
body: draft.message.body,
|
|
967
|
+
cc: draft.cc.length > 0 ? draft.cc : undefined,
|
|
968
|
+
bcc: draft.bcc.length > 0 ? draft.bcc : undefined,
|
|
969
|
+
});
|
|
970
|
+
if (result instanceof Error)
|
|
971
|
+
return result;
|
|
972
|
+
// Delete the draft after sending
|
|
973
|
+
await this.deleteDraft({ draftId });
|
|
974
|
+
return result;
|
|
975
|
+
}
|
|
976
|
+
async deleteDraft({ draftId }) {
|
|
977
|
+
const { folder, uid } = parseThreadId(draftId);
|
|
978
|
+
return this.withImap(async (client) => {
|
|
979
|
+
const lock = await client.getMailboxLock(folder);
|
|
980
|
+
try {
|
|
981
|
+
await client.messageFlagsAdd(String(uid), ['\\Deleted'], { uid: true });
|
|
982
|
+
await client.messageDelete(String(uid), { uid: true });
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
lock.release();
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Update an existing draft. IMAP has no native update — we delete the old
|
|
991
|
+
* draft and APPEND a new message to the Drafts folder.
|
|
992
|
+
*/
|
|
993
|
+
async updateDraft({ draftId, to, subject, body, cc, bcc, fromEmail, }) {
|
|
994
|
+
// Delete old draft first — check for errors before creating replacement
|
|
995
|
+
const deleted = await this.deleteDraft({ draftId });
|
|
996
|
+
if (deleted instanceof Error)
|
|
997
|
+
return deleted;
|
|
998
|
+
// Create new draft with updated content
|
|
999
|
+
return this.createDraft({ to, subject, body, cc, bcc, fromEmail });
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Create a draft reply to a thread. Resolves reply-to, reply-all CCs,
|
|
1003
|
+
* and sets In-Reply-To/References headers, then appends to Drafts.
|
|
1004
|
+
*/
|
|
1005
|
+
async createDraftReply({ threadId, body, replyAll = false, cc, fromEmail, }) {
|
|
1006
|
+
const thread = await this.getThread({ threadId });
|
|
1007
|
+
if (thread.parsed.messages.length === 0) {
|
|
1008
|
+
return new EmptyThreadError({ threadId });
|
|
1009
|
+
}
|
|
1010
|
+
const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
|
|
1011
|
+
const replyTo = lastMsg.replyTo ?? lastMsg.from.email;
|
|
1012
|
+
const to = [{ email: replyTo }];
|
|
1013
|
+
let resolvedCc;
|
|
1014
|
+
if (replyAll) {
|
|
1015
|
+
const myEmail = this.account.email.toLowerCase();
|
|
1016
|
+
const allRecipients = [
|
|
1017
|
+
...lastMsg.to.map((r) => r.email),
|
|
1018
|
+
...(lastMsg.cc?.map((r) => r.email) ?? []),
|
|
1019
|
+
]
|
|
1020
|
+
.filter((e) => e.toLowerCase() !== myEmail)
|
|
1021
|
+
.filter((e) => e.toLowerCase() !== replyTo.toLowerCase());
|
|
1022
|
+
if (allRecipients.length > 0) {
|
|
1023
|
+
resolvedCc = allRecipients.map((e) => ({ email: e }));
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (cc) {
|
|
1027
|
+
resolvedCc = [...(resolvedCc ?? []), ...cc];
|
|
1028
|
+
}
|
|
1029
|
+
const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ');
|
|
1030
|
+
const subject = lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`;
|
|
1031
|
+
// Build MIME with reply headers
|
|
1032
|
+
const headers = [
|
|
1033
|
+
`From: ${fromEmail ?? this.account.email}`,
|
|
1034
|
+
`To: ${to.map((r) => r.email).join(', ')}`,
|
|
1035
|
+
`Subject: ${subject}`,
|
|
1036
|
+
`Date: ${new Date().toUTCString()}`,
|
|
1037
|
+
`MIME-Version: 1.0`,
|
|
1038
|
+
`Content-Type: text/plain; charset=utf-8`,
|
|
1039
|
+
];
|
|
1040
|
+
if (resolvedCc && resolvedCc.length > 0) {
|
|
1041
|
+
headers.push(`Cc: ${resolvedCc.map((r) => r.email).join(', ')}`);
|
|
1042
|
+
}
|
|
1043
|
+
if (lastMsg.messageId) {
|
|
1044
|
+
headers.push(`In-Reply-To: ${lastMsg.messageId}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (refs) {
|
|
1047
|
+
headers.push(`References: ${refs}`);
|
|
1048
|
+
}
|
|
1049
|
+
const raw = headers.join('\r\n') + '\r\n\r\n' + body;
|
|
1050
|
+
const rawBuffer = Buffer.from(raw);
|
|
1051
|
+
const result = await this.withImap(async (client) => {
|
|
1052
|
+
const draftsPath = await this.resolveMailboxPath(client, 'drafts');
|
|
1053
|
+
const appendResult = await client.append(draftsPath, rawBuffer, ['\\Draft', '\\Seen']);
|
|
1054
|
+
const uid = appendResult && typeof appendResult === 'object' && 'uid' in appendResult ? appendResult.uid : undefined;
|
|
1055
|
+
return {
|
|
1056
|
+
id: uid ? makeThreadId(draftsPath, uid) : 'unknown',
|
|
1057
|
+
message: { id: 'unknown' },
|
|
1058
|
+
threadId: uid ? makeThreadId(draftsPath, uid) : 'unknown',
|
|
1059
|
+
};
|
|
1060
|
+
});
|
|
1061
|
+
if (result instanceof Error)
|
|
1062
|
+
return result;
|
|
1063
|
+
return result;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Create a draft forwarding a thread. Builds the forwarded-message body
|
|
1067
|
+
* and appends to Drafts folder.
|
|
1068
|
+
*/
|
|
1069
|
+
async createDraftForward({ threadId, to, body, fromEmail, }) {
|
|
1070
|
+
const thread = await this.getThread({ threadId });
|
|
1071
|
+
if (thread.parsed.messages.length === 0) {
|
|
1072
|
+
return new EmptyThreadError({ threadId });
|
|
1073
|
+
}
|
|
1074
|
+
const lastMsg = thread.parsed.messages[thread.parsed.messages.length - 1];
|
|
1075
|
+
const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType);
|
|
1076
|
+
const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
|
|
1077
|
+
? `${lastMsg.from.name} <${lastMsg.from.email}>`
|
|
1078
|
+
: lastMsg.from.email;
|
|
1079
|
+
const fullBody = [
|
|
1080
|
+
body ?? '',
|
|
1081
|
+
'',
|
|
1082
|
+
'---------- Forwarded message ----------',
|
|
1083
|
+
`From: ${fromStr}`,
|
|
1084
|
+
`Date: ${lastMsg.date}`,
|
|
1085
|
+
`Subject: ${lastMsg.subject}`,
|
|
1086
|
+
`To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
|
|
1087
|
+
'',
|
|
1088
|
+
renderedBody,
|
|
1089
|
+
].join('\n');
|
|
1090
|
+
return this.createDraft({
|
|
1091
|
+
to,
|
|
1092
|
+
subject: `Fwd: ${lastMsg.subject}`,
|
|
1093
|
+
body: fullBody,
|
|
1094
|
+
fromEmail,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
// =========================================================================
|
|
1098
|
+
// Folder listing (IMAP equivalent of labels)
|
|
1099
|
+
// =========================================================================
|
|
1100
|
+
async listFolders() {
|
|
1101
|
+
return this.withImap(async (client) => {
|
|
1102
|
+
const mailboxes = await client.list();
|
|
1103
|
+
return mailboxes.map((m) => ({
|
|
1104
|
+
name: m.name,
|
|
1105
|
+
path: m.path,
|
|
1106
|
+
specialUse: m.specialUse ?? undefined,
|
|
1107
|
+
flags: Array.from(m.flags),
|
|
1108
|
+
}));
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
// =========================================================================
|
|
1112
|
+
// Cache stubs (no-op for IMAP — no local thread cache)
|
|
1113
|
+
// =========================================================================
|
|
1114
|
+
async invalidateThreads(_threadIds) { }
|
|
1115
|
+
async invalidateThread(_threadId) { }
|
|
1116
|
+
// =========================================================================
|
|
1117
|
+
// Private helpers
|
|
1118
|
+
// =========================================================================
|
|
1119
|
+
/** Modify IMAP flags on messages. Groups by folder for efficiency. */
|
|
1120
|
+
async modifyFlags(threadIds, opts) {
|
|
1121
|
+
// Group by folder
|
|
1122
|
+
const byFolder = new Map();
|
|
1123
|
+
for (const threadId of threadIds) {
|
|
1124
|
+
const { folder, uid } = parseThreadId(threadId);
|
|
1125
|
+
const uids = byFolder.get(folder) ?? [];
|
|
1126
|
+
uids.push(uid);
|
|
1127
|
+
byFolder.set(folder, uids);
|
|
1128
|
+
}
|
|
1129
|
+
for (const [folder, uids] of byFolder) {
|
|
1130
|
+
const result = await this.withImap(async (client) => {
|
|
1131
|
+
const lock = await client.getMailboxLock(folder);
|
|
1132
|
+
try {
|
|
1133
|
+
const uidRange = uids.join(',');
|
|
1134
|
+
if (opts.add && opts.add.length > 0) {
|
|
1135
|
+
await client.messageFlagsAdd(uidRange, opts.add, { uid: true });
|
|
1136
|
+
}
|
|
1137
|
+
if (opts.remove && opts.remove.length > 0) {
|
|
1138
|
+
await client.messageFlagsRemove(uidRange, opts.remove, { uid: true });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
finally {
|
|
1142
|
+
lock.release();
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
if (result instanceof Error)
|
|
1146
|
+
return result;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/** Check if a message has attachments from its bodyStructure. */
|
|
1150
|
+
hasAttachments(msg) {
|
|
1151
|
+
const bs = msg.bodyStructure;
|
|
1152
|
+
if (!bs)
|
|
1153
|
+
return false;
|
|
1154
|
+
// Check for non-inline parts
|
|
1155
|
+
const check = (part) => {
|
|
1156
|
+
if (part.disposition === 'attachment')
|
|
1157
|
+
return true;
|
|
1158
|
+
if (part.childNodes)
|
|
1159
|
+
return part.childNodes.some(check);
|
|
1160
|
+
return false;
|
|
1161
|
+
};
|
|
1162
|
+
return check(bs);
|
|
1163
|
+
}
|
|
1164
|
+
/** Parse an imapflow FetchMessageObject into our ParsedMessage type. */
|
|
1165
|
+
parseImapMessage(msg, folder) {
|
|
1166
|
+
const env = msg.envelope ?? {};
|
|
1167
|
+
const flags = msg.flags ?? new Set();
|
|
1168
|
+
const threadId = makeThreadId(folder, msg.uid);
|
|
1169
|
+
// Extract body from source if available
|
|
1170
|
+
let body = '';
|
|
1171
|
+
let mimeType = 'text/plain';
|
|
1172
|
+
let textBody = null;
|
|
1173
|
+
let listUnsubscribe;
|
|
1174
|
+
let listUnsubscribePost;
|
|
1175
|
+
if (msg.source) {
|
|
1176
|
+
const source = msg.source.toString('utf-8');
|
|
1177
|
+
const bodyResult = this.extractBodyFromSource(source);
|
|
1178
|
+
body = bodyResult.body;
|
|
1179
|
+
mimeType = bodyResult.mimeType;
|
|
1180
|
+
textBody = bodyResult.textBody;
|
|
1181
|
+
// Extract List-Unsubscribe / List-Unsubscribe-Post from raw MIME headers.
|
|
1182
|
+
// envelope-based fetches don't surface these, but getThread always fetches
|
|
1183
|
+
// source so it's available by the time we parse a full message.
|
|
1184
|
+
const headerEnd = source.indexOf('\r\n\r\n');
|
|
1185
|
+
const altEnd = source.indexOf('\n\n');
|
|
1186
|
+
const headerSplit = headerEnd !== -1 ? headerEnd : altEnd;
|
|
1187
|
+
const headerText = headerSplit === -1 ? source : source.slice(0, headerSplit);
|
|
1188
|
+
listUnsubscribe = this.getHeader(headerText, 'list-unsubscribe');
|
|
1189
|
+
listUnsubscribePost = this.getHeader(headerText, 'list-unsubscribe-post');
|
|
1190
|
+
}
|
|
1191
|
+
// Extract attachments from bodyStructure
|
|
1192
|
+
const attachments = [];
|
|
1193
|
+
if (msg.bodyStructure) {
|
|
1194
|
+
this.collectAttachments(msg.bodyStructure, '', attachments);
|
|
1195
|
+
}
|
|
1196
|
+
return {
|
|
1197
|
+
id: threadId,
|
|
1198
|
+
threadId,
|
|
1199
|
+
subject: env.subject ?? '(no subject)',
|
|
1200
|
+
snippet: (env.subject ?? '').slice(0, 100),
|
|
1201
|
+
from: toSender(env.from?.[0]),
|
|
1202
|
+
to: toSenders(env.to),
|
|
1203
|
+
cc: env.cc ? toSenders(env.cc) : null,
|
|
1204
|
+
bcc: toSenders(env.bcc),
|
|
1205
|
+
replyTo: env.replyTo?.[0]?.address,
|
|
1206
|
+
date: env.date?.toISOString() ?? new Date().toISOString(),
|
|
1207
|
+
labelIds: [],
|
|
1208
|
+
unread: !flags.has('\\Seen'),
|
|
1209
|
+
starred: flags.has('\\Flagged'),
|
|
1210
|
+
isDraft: flags.has('\\Draft'),
|
|
1211
|
+
messageId: env.messageId ?? '',
|
|
1212
|
+
inReplyTo: env.inReplyTo,
|
|
1213
|
+
references: undefined,
|
|
1214
|
+
listUnsubscribe,
|
|
1215
|
+
listUnsubscribePost,
|
|
1216
|
+
body,
|
|
1217
|
+
mimeType,
|
|
1218
|
+
textBody,
|
|
1219
|
+
attachments,
|
|
1220
|
+
auth: null, // IMAP doesn't provide SPF/DKIM/DMARC
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
/** Extract body text from raw RFC 2822 source. */
|
|
1224
|
+
extractBodyFromSource(source) {
|
|
1225
|
+
// Find the boundary between headers and body
|
|
1226
|
+
const headerEnd = source.indexOf('\r\n\r\n');
|
|
1227
|
+
if (headerEnd === -1) {
|
|
1228
|
+
const altEnd = source.indexOf('\n\n');
|
|
1229
|
+
if (altEnd === -1)
|
|
1230
|
+
return { body: '', mimeType: 'text/plain', textBody: null };
|
|
1231
|
+
return this.parseBody(source.slice(altEnd + 2), source.slice(0, altEnd));
|
|
1232
|
+
}
|
|
1233
|
+
return this.parseBody(source.slice(headerEnd + 4), source.slice(0, headerEnd));
|
|
1234
|
+
}
|
|
1235
|
+
parseBody(bodyContent, headers) {
|
|
1236
|
+
const contentType = this.getHeader(headers, 'content-type') ?? 'text/plain';
|
|
1237
|
+
const transferEncoding = this.getHeader(headers, 'content-transfer-encoding') ?? '7bit';
|
|
1238
|
+
// Check if multipart
|
|
1239
|
+
const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i);
|
|
1240
|
+
if (boundaryMatch) {
|
|
1241
|
+
const boundary = boundaryMatch[1];
|
|
1242
|
+
return this.parseMultipart(bodyContent, boundary);
|
|
1243
|
+
}
|
|
1244
|
+
// Single-part body
|
|
1245
|
+
let decoded = this.decodeTransferEncoding(bodyContent, transferEncoding);
|
|
1246
|
+
const charsetMatch = contentType.match(/charset="?([^";\s]+)"?/i);
|
|
1247
|
+
if (charsetMatch) {
|
|
1248
|
+
// Already UTF-8 string, but note the charset for future handling
|
|
1249
|
+
}
|
|
1250
|
+
const isHtml = contentType.toLowerCase().includes('text/html');
|
|
1251
|
+
return {
|
|
1252
|
+
body: decoded,
|
|
1253
|
+
mimeType: isHtml ? 'text/html' : 'text/plain',
|
|
1254
|
+
textBody: isHtml ? null : decoded,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
parseMultipart(body, boundary) {
|
|
1258
|
+
const parts = body.split(`--${boundary}`);
|
|
1259
|
+
let htmlBody = null;
|
|
1260
|
+
let textBody = null;
|
|
1261
|
+
for (const part of parts) {
|
|
1262
|
+
if (part.trim() === '--' || part.trim() === '')
|
|
1263
|
+
continue;
|
|
1264
|
+
const partHeaderEnd = part.indexOf('\r\n\r\n');
|
|
1265
|
+
const altEnd = part.indexOf('\n\n');
|
|
1266
|
+
const splitPos = partHeaderEnd !== -1 ? partHeaderEnd : altEnd;
|
|
1267
|
+
if (splitPos === -1)
|
|
1268
|
+
continue;
|
|
1269
|
+
const partHeaders = part.slice(0, splitPos);
|
|
1270
|
+
const partBody = part.slice(splitPos + (partHeaderEnd !== -1 ? 4 : 2));
|
|
1271
|
+
const partContentType = this.getHeader(partHeaders, 'content-type') ?? 'text/plain';
|
|
1272
|
+
const partEncoding = this.getHeader(partHeaders, 'content-transfer-encoding') ?? '7bit';
|
|
1273
|
+
// Recursive multipart
|
|
1274
|
+
const nestedBoundary = partContentType.match(/boundary="?([^";\s]+)"?/i);
|
|
1275
|
+
if (nestedBoundary) {
|
|
1276
|
+
const nested = this.parseMultipart(partBody, nestedBoundary[1]);
|
|
1277
|
+
if (nested.mimeType === 'text/html')
|
|
1278
|
+
htmlBody = nested.body;
|
|
1279
|
+
if (nested.textBody)
|
|
1280
|
+
textBody = nested.textBody;
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
const decoded = this.decodeTransferEncoding(partBody, partEncoding);
|
|
1284
|
+
if (partContentType.toLowerCase().includes('text/html')) {
|
|
1285
|
+
htmlBody = decoded;
|
|
1286
|
+
}
|
|
1287
|
+
else if (partContentType.toLowerCase().includes('text/plain')) {
|
|
1288
|
+
textBody = decoded;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
// Prefer HTML, fall back to text
|
|
1292
|
+
if (htmlBody)
|
|
1293
|
+
return { body: htmlBody, mimeType: 'text/html', textBody };
|
|
1294
|
+
if (textBody)
|
|
1295
|
+
return { body: textBody, mimeType: 'text/plain', textBody };
|
|
1296
|
+
return { body: '', mimeType: 'text/plain', textBody: null };
|
|
1297
|
+
}
|
|
1298
|
+
decodeTransferEncoding(content, encoding) {
|
|
1299
|
+
const enc = encoding.toLowerCase().trim();
|
|
1300
|
+
if (enc === 'base64') {
|
|
1301
|
+
return Buffer.from(content.replace(/\s/g, ''), 'base64').toString('utf-8');
|
|
1302
|
+
}
|
|
1303
|
+
if (enc === 'quoted-printable') {
|
|
1304
|
+
return content
|
|
1305
|
+
.replace(/=\r?\n/g, '') // Soft line breaks
|
|
1306
|
+
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
1307
|
+
}
|
|
1308
|
+
return content;
|
|
1309
|
+
}
|
|
1310
|
+
getHeader(headers, name) {
|
|
1311
|
+
const regex = new RegExp(`^${name}:\\s*(.+?)$`, 'im');
|
|
1312
|
+
const match = headers.match(regex);
|
|
1313
|
+
if (!match)
|
|
1314
|
+
return undefined;
|
|
1315
|
+
// Handle folded headers (continuation lines starting with whitespace)
|
|
1316
|
+
let value = match[1].trim();
|
|
1317
|
+
const lines = headers.split(/\r?\n/);
|
|
1318
|
+
let found = false;
|
|
1319
|
+
for (const line of lines) {
|
|
1320
|
+
if (found && /^\s/.test(line)) {
|
|
1321
|
+
value += ' ' + line.trim();
|
|
1322
|
+
}
|
|
1323
|
+
else if (line.toLowerCase().startsWith(name.toLowerCase() + ':')) {
|
|
1324
|
+
found = true;
|
|
1325
|
+
}
|
|
1326
|
+
else if (found) {
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return value;
|
|
1331
|
+
}
|
|
1332
|
+
/** Recursively collect attachment metadata from bodyStructure. */
|
|
1333
|
+
collectAttachments(part, prefix, attachments) {
|
|
1334
|
+
if (part.disposition === 'attachment' || (part.disposition === 'inline' && part.parameters?.name)) {
|
|
1335
|
+
attachments.push({
|
|
1336
|
+
attachmentId: part.part ?? prefix,
|
|
1337
|
+
filename: part.dispositionParameters?.filename ?? part.parameters?.name ?? 'attachment',
|
|
1338
|
+
mimeType: part.type ?? 'application/octet-stream',
|
|
1339
|
+
size: part.size ?? 0,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
if (part.childNodes) {
|
|
1343
|
+
for (let i = 0; i < part.childNodes.length; i++) {
|
|
1344
|
+
this.collectAttachments(part.childNodes[i], prefix ? `${prefix}.${i + 1}` : String(i + 1), attachments);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
//# sourceMappingURL=imap-smtp-client.js.map
|