@remit/mailbox-service 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,1374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImapFlow-based IMAP connection
|
|
3
|
+
*
|
|
4
|
+
* Modern async/await replacement for the node-imap based ImapConnection.
|
|
5
|
+
* Provides the same interface but uses ImapFlow under the hood.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Readable } from "node:stream";
|
|
9
|
+
import { ImapFlow } from "imapflow";
|
|
10
|
+
import { simpleParser } from "mailparser";
|
|
11
|
+
import type {
|
|
12
|
+
FlatMailboxInfo,
|
|
13
|
+
ImapAddress,
|
|
14
|
+
ImapBoxStatus,
|
|
15
|
+
ImapConnectionConfig,
|
|
16
|
+
ImapConnectionState,
|
|
17
|
+
ImapEnvelopeSnapshot,
|
|
18
|
+
ImapMailboxStatus,
|
|
19
|
+
ImapMessage,
|
|
20
|
+
ImapNamespaces,
|
|
21
|
+
MailCredentials,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
import { MailConnectionError } from "./types.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert an envelope date into an ISO string, tolerating non-Date values.
|
|
27
|
+
*
|
|
28
|
+
* imapflow types `envelope.date` as a Date, but a malformed `Date:` header can
|
|
29
|
+
* leave it as a string or other value at runtime. Returns "" for absent or
|
|
30
|
+
* invalid dates so one bad header never poisons the whole fetch batch.
|
|
31
|
+
*/
|
|
32
|
+
export const toIsoDateString = (value: unknown): string => {
|
|
33
|
+
if (value instanceof Date) {
|
|
34
|
+
return Number.isNaN(value.getTime()) ? "" : value.toISOString();
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
37
|
+
const date = new Date(value);
|
|
38
|
+
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Coerce an IMAP INTERNALDATE into a valid `Date`, tolerating malformed input.
|
|
45
|
+
*
|
|
46
|
+
* INTERNALDATE is the server's receive time and is almost always a clean Date.
|
|
47
|
+
* But a broken server (or an unexpected imapflow runtime value) can yield a
|
|
48
|
+
* string that `new Date` cannot parse, or a wholly unexpected type. Either way
|
|
49
|
+
* `getTime()` would be `NaN`, which is the message's date-of-last-resort — it
|
|
50
|
+
* is the fallback `parseHeaderDate` uses and is stored as the message's
|
|
51
|
+
* `internalDate` — so a `NaN` here silently corrupts every downstream date and
|
|
52
|
+
* gets rejected by ElectroDB, permanently failing the message.
|
|
53
|
+
*
|
|
54
|
+
* When the value cannot produce a valid Date we fall back to `now` so the
|
|
55
|
+
* message still syncs with an approximate receive time rather than being
|
|
56
|
+
* dropped. Returns `null` only for a genuinely absent value, which the caller
|
|
57
|
+
* skips (a transient imapflow artifact, see #408 — not bad data).
|
|
58
|
+
*/
|
|
59
|
+
export const toInternalDate = (value: unknown): Date | null => {
|
|
60
|
+
if (value == null) return null;
|
|
61
|
+
if (value instanceof Date) {
|
|
62
|
+
return Number.isNaN(value.getTime()) ? new Date() : value;
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
65
|
+
const date = new Date(value);
|
|
66
|
+
return Number.isNaN(date.getTime()) ? new Date() : date;
|
|
67
|
+
}
|
|
68
|
+
return new Date();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ImapFlow-based IMAP connection
|
|
73
|
+
*
|
|
74
|
+
* Drop-in replacement for ImapConnection using the ImapFlow library.
|
|
75
|
+
* Benefits:
|
|
76
|
+
* - Native async/await API (no callback wrapping)
|
|
77
|
+
* - Built-in envelope parsing that works with mokapi
|
|
78
|
+
* - Proper IDLE support with events
|
|
79
|
+
* - Built-in TypeScript types
|
|
80
|
+
* - Active maintenance
|
|
81
|
+
*/
|
|
82
|
+
export class ImapFlowConnection {
|
|
83
|
+
private client: ImapFlow | null = null;
|
|
84
|
+
private _state: ImapConnectionState = "disconnected";
|
|
85
|
+
private config: ImapConnectionConfig;
|
|
86
|
+
private currentMailbox: string | null = null;
|
|
87
|
+
|
|
88
|
+
constructor(config: ImapConnectionConfig) {
|
|
89
|
+
this.config = config;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Current connection state
|
|
94
|
+
*/
|
|
95
|
+
get state(): ImapConnectionState {
|
|
96
|
+
return this._state;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether the connection is established and authenticated
|
|
101
|
+
*/
|
|
102
|
+
get isConnected(): boolean {
|
|
103
|
+
return this._state === "authenticated";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Connect to the IMAP server with retry logic.
|
|
108
|
+
*
|
|
109
|
+
* Retries up to 3 times with exponential backoff (1s, 2s, 4s) on connection errors.
|
|
110
|
+
* Authentication errors are not retried.
|
|
111
|
+
*/
|
|
112
|
+
connect = async (): Promise<void> => {
|
|
113
|
+
if (this.client) {
|
|
114
|
+
throw new Error("Already connected");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const maxRetries = 3;
|
|
118
|
+
const baseDelayMs = 1000;
|
|
119
|
+
|
|
120
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
121
|
+
try {
|
|
122
|
+
await this.attemptConnect();
|
|
123
|
+
return;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const classified = classifyImapError(
|
|
126
|
+
error,
|
|
127
|
+
`${this.config.host}:${this.config.port}`,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Auth errors won't fix themselves on retry — throw immediately.
|
|
131
|
+
if (classified?.kind === "auth") {
|
|
132
|
+
throw classified;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Final attempt exhausted: throw the classified error if we have
|
|
136
|
+
// one (network failures), otherwise let the raw error bubble.
|
|
137
|
+
if (attempt === maxRetries) {
|
|
138
|
+
throw classified ?? error;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Network and unknown errors fall through to retry-with-backoff.
|
|
142
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
143
|
+
const delay = baseDelayMs * 2 ** (attempt - 1);
|
|
144
|
+
await this.sleep(delay);
|
|
145
|
+
|
|
146
|
+
// Reset state for retry
|
|
147
|
+
this.cleanup();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Single connection attempt
|
|
154
|
+
*/
|
|
155
|
+
private attemptConnect = async (): Promise<void> => {
|
|
156
|
+
this._state = "connecting";
|
|
157
|
+
|
|
158
|
+
// Determine TLS options
|
|
159
|
+
// When secure is false, STARTTLS may still be used, so we need to handle self-signed certs
|
|
160
|
+
const tlsOptions = this.config.tlsOptions
|
|
161
|
+
? {
|
|
162
|
+
rejectUnauthorized: this.config.tlsOptions.rejectUnauthorized ?? true,
|
|
163
|
+
}
|
|
164
|
+
: !this.config.tls
|
|
165
|
+
? {
|
|
166
|
+
// Allow self-signed certs when TLS is disabled (for STARTTLS)
|
|
167
|
+
rejectUnauthorized: false,
|
|
168
|
+
}
|
|
169
|
+
: undefined;
|
|
170
|
+
|
|
171
|
+
const auth = buildImapAuth(this.config.user, this.config.credentials);
|
|
172
|
+
|
|
173
|
+
this.client = new ImapFlow({
|
|
174
|
+
host: this.config.host,
|
|
175
|
+
port: this.config.port,
|
|
176
|
+
secure: this.config.tls,
|
|
177
|
+
servername: this.config.tlsOptions?.servername,
|
|
178
|
+
auth,
|
|
179
|
+
tls: tlsOptions,
|
|
180
|
+
// Disable auto IDLE to work around servers that don't handle IDLE correctly
|
|
181
|
+
// This is equivalent to node-imap's forceNoop: true
|
|
182
|
+
disableAutoIdle: true,
|
|
183
|
+
logger: false, // Disable verbose logging
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this.client.on("close", () => {
|
|
187
|
+
this._state = "disconnected";
|
|
188
|
+
this.client = null;
|
|
189
|
+
this.currentMailbox = null;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
this.client.on("error", () => {
|
|
193
|
+
this._state = "error";
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await this.client.connect();
|
|
197
|
+
this._state = "authenticated";
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Cleanup client state for retry
|
|
202
|
+
*/
|
|
203
|
+
private cleanup = (): void => {
|
|
204
|
+
if (this.client) {
|
|
205
|
+
this.client.close();
|
|
206
|
+
this.client = null;
|
|
207
|
+
}
|
|
208
|
+
this._state = "disconnected";
|
|
209
|
+
this.currentMailbox = null;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Sleep for a specified duration
|
|
214
|
+
*/
|
|
215
|
+
private sleep = (ms: number): Promise<void> =>
|
|
216
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Disconnect from the IMAP server
|
|
220
|
+
*/
|
|
221
|
+
disconnect = async (): Promise<void> => {
|
|
222
|
+
if (!this.client) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const client = this.client;
|
|
227
|
+
|
|
228
|
+
// Use a timeout to avoid hanging on logout
|
|
229
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
230
|
+
setTimeout(() => {
|
|
231
|
+
// Force close if logout doesn't complete
|
|
232
|
+
client.close();
|
|
233
|
+
resolve();
|
|
234
|
+
}, 5000);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await Promise.race([client.logout(), timeoutPromise]);
|
|
239
|
+
} catch {
|
|
240
|
+
// Ignore errors during disconnect, force close
|
|
241
|
+
client.close();
|
|
242
|
+
} finally {
|
|
243
|
+
this._state = "disconnected";
|
|
244
|
+
this.client = null;
|
|
245
|
+
this.currentMailbox = null;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get IMAP namespaces
|
|
251
|
+
*
|
|
252
|
+
* Note: ImapFlow doesn't expose namespaces directly, so we return
|
|
253
|
+
* the default namespace. The mailbox listing works without explicit
|
|
254
|
+
* namespace handling in ImapFlow.
|
|
255
|
+
*/
|
|
256
|
+
getNamespaces = async (): Promise<ImapNamespaces> => {
|
|
257
|
+
this.ensureConnected();
|
|
258
|
+
|
|
259
|
+
// ImapFlow doesn't expose NAMESPACE info, return defaults
|
|
260
|
+
// This is sufficient as ImapFlow handles namespaces internally
|
|
261
|
+
return {
|
|
262
|
+
personal: [{ prefix: "", delimiter: "/" }],
|
|
263
|
+
other: [],
|
|
264
|
+
shared: [],
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* List all mailboxes as a flat list, preserving original paths from server.
|
|
270
|
+
* This avoids path corruption from split/join operations.
|
|
271
|
+
*
|
|
272
|
+
* @param nsPrefix - Optional namespace prefix to filter mailboxes
|
|
273
|
+
*/
|
|
274
|
+
listMailboxes = async (nsPrefix?: string): Promise<FlatMailboxInfo[]> => {
|
|
275
|
+
this.ensureConnected();
|
|
276
|
+
|
|
277
|
+
const mailboxes = await this.client?.list();
|
|
278
|
+
if (!mailboxes) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
const result: FlatMailboxInfo[] = [];
|
|
282
|
+
|
|
283
|
+
for (const mailbox of mailboxes) {
|
|
284
|
+
// Filter by namespace prefix if provided
|
|
285
|
+
if (nsPrefix !== undefined && !mailbox.path.startsWith(nsPrefix)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Extract name from path (last component)
|
|
290
|
+
const pathParts = mailbox.path.split(mailbox.delimiter);
|
|
291
|
+
const name = pathParts[pathParts.length - 1] || mailbox.path;
|
|
292
|
+
const parentPath =
|
|
293
|
+
pathParts.length > 1
|
|
294
|
+
? pathParts.slice(0, -1).join(mailbox.delimiter)
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
result.push({
|
|
298
|
+
fullPath: mailbox.path, // Use original path from server
|
|
299
|
+
name,
|
|
300
|
+
delimiter: mailbox.delimiter,
|
|
301
|
+
attributes: this.convertFlags(mailbox.flags),
|
|
302
|
+
parentPath,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return result;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Convert ImapFlow flags Set to string array
|
|
311
|
+
*/
|
|
312
|
+
private convertFlags = (flags: Set<string> | undefined): string[] => {
|
|
313
|
+
if (!flags) return [];
|
|
314
|
+
return Array.from(flags);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Open a mailbox for reading
|
|
319
|
+
*
|
|
320
|
+
* @param mailboxPath - Full path to the mailbox (e.g., "INBOX", "[Gmail]/Sent")
|
|
321
|
+
* @param readOnly - Whether to open read-only (default: true)
|
|
322
|
+
*/
|
|
323
|
+
openBox = async (
|
|
324
|
+
mailboxPath: string,
|
|
325
|
+
readOnly = true,
|
|
326
|
+
): Promise<ImapBoxStatus> => {
|
|
327
|
+
this.ensureConnected();
|
|
328
|
+
|
|
329
|
+
// Idempotency guard: re-selecting the currently-open mailbox is a no-op
|
|
330
|
+
// SELECT on the wire. `mailbox` exposes the live, already-selected box, so
|
|
331
|
+
// we can return its status without paying for another round-trip — this is
|
|
332
|
+
// what lets a batch issue one SELECT and many fetches on the same box.
|
|
333
|
+
if (this.currentMailbox === mailboxPath && this.client?.mailbox) {
|
|
334
|
+
return this.toBoxStatus(this.client.mailbox, readOnly);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const mailbox = await this.client?.mailboxOpen(mailboxPath, {
|
|
338
|
+
readOnly,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (!mailbox) {
|
|
342
|
+
throw new Error(`Failed to open mailbox: ${mailboxPath}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this.currentMailbox = mailboxPath;
|
|
346
|
+
|
|
347
|
+
return this.toBoxStatus(mailbox, readOnly);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build an ImapBoxStatus from an imapflow MailboxObject.
|
|
352
|
+
*/
|
|
353
|
+
private toBoxStatus = (
|
|
354
|
+
mailbox: {
|
|
355
|
+
path: string;
|
|
356
|
+
delimiter: string;
|
|
357
|
+
flags: Set<string>;
|
|
358
|
+
permanentFlags?: Set<string>;
|
|
359
|
+
uidValidity: bigint;
|
|
360
|
+
uidNext: number;
|
|
361
|
+
exists: number;
|
|
362
|
+
readOnly?: boolean;
|
|
363
|
+
},
|
|
364
|
+
readOnly: boolean,
|
|
365
|
+
): ImapBoxStatus => {
|
|
366
|
+
const pathParts = mailbox.path.split(mailbox.delimiter);
|
|
367
|
+
const name = pathParts[pathParts.length - 1] || mailbox.path;
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
name,
|
|
371
|
+
readOnly: mailbox.readOnly ?? readOnly,
|
|
372
|
+
uidvalidity: Number(mailbox.uidValidity),
|
|
373
|
+
uidnext: mailbox.uidNext,
|
|
374
|
+
flags: Array.from(mailbox.flags || []),
|
|
375
|
+
permFlags: Array.from(mailbox.permanentFlags || []),
|
|
376
|
+
persistentUIDs: true, // ImapFlow assumes persistent UIDs
|
|
377
|
+
messages: {
|
|
378
|
+
total: mailbox.exists,
|
|
379
|
+
new: 0, // ImapFlow doesn't provide unseen count on open
|
|
380
|
+
},
|
|
381
|
+
newKeywords: mailbox.permanentFlags?.has("\\*") ?? false,
|
|
382
|
+
};
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Close the currently open mailbox
|
|
387
|
+
*
|
|
388
|
+
* @param _expunge - Whether to permanently remove deleted messages (not used in ImapFlow)
|
|
389
|
+
*/
|
|
390
|
+
closeBox = async (_expunge = false): Promise<void> => {
|
|
391
|
+
this.ensureConnected();
|
|
392
|
+
|
|
393
|
+
if (this.currentMailbox) {
|
|
394
|
+
await this.client?.mailboxClose();
|
|
395
|
+
this.currentMailbox = null;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Search for messages
|
|
401
|
+
*/
|
|
402
|
+
search = async (criteria: unknown[]): Promise<number[]> => {
|
|
403
|
+
this.ensureConnected();
|
|
404
|
+
|
|
405
|
+
if (!this.currentMailbox) {
|
|
406
|
+
throw new Error("No mailbox selected");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Convert node-imap style criteria to ImapFlow search object
|
|
410
|
+
const searchQuery = this.convertSearchCriteria(criteria);
|
|
411
|
+
|
|
412
|
+
const result = await this.client?.search(searchQuery, { uid: true });
|
|
413
|
+
// search can return false if no messages match, or undefined if client is null
|
|
414
|
+
if (!result) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Convert node-imap style search criteria to ImapFlow format
|
|
422
|
+
*/
|
|
423
|
+
private convertSearchCriteria = (
|
|
424
|
+
criteria: unknown[],
|
|
425
|
+
): Record<string, unknown> => {
|
|
426
|
+
const result: Record<string, unknown> = {};
|
|
427
|
+
|
|
428
|
+
for (const criterion of criteria) {
|
|
429
|
+
if (typeof criterion === "string") {
|
|
430
|
+
// Simple flags like "ALL", "UNSEEN", etc.
|
|
431
|
+
switch (criterion.toUpperCase()) {
|
|
432
|
+
case "ALL":
|
|
433
|
+
// ALL is default, no filter needed
|
|
434
|
+
break;
|
|
435
|
+
case "UNSEEN":
|
|
436
|
+
result.seen = false;
|
|
437
|
+
break;
|
|
438
|
+
case "SEEN":
|
|
439
|
+
result.seen = true;
|
|
440
|
+
break;
|
|
441
|
+
case "FLAGGED":
|
|
442
|
+
result.flagged = true;
|
|
443
|
+
break;
|
|
444
|
+
case "UNFLAGGED":
|
|
445
|
+
result.flagged = false;
|
|
446
|
+
break;
|
|
447
|
+
case "DELETED":
|
|
448
|
+
result.deleted = true;
|
|
449
|
+
break;
|
|
450
|
+
case "UNDELETED":
|
|
451
|
+
result.deleted = false;
|
|
452
|
+
break;
|
|
453
|
+
case "ANSWERED":
|
|
454
|
+
result.answered = true;
|
|
455
|
+
break;
|
|
456
|
+
case "UNANSWERED":
|
|
457
|
+
result.answered = false;
|
|
458
|
+
break;
|
|
459
|
+
case "DRAFT":
|
|
460
|
+
result.draft = true;
|
|
461
|
+
break;
|
|
462
|
+
case "UNDRAFT":
|
|
463
|
+
result.draft = false;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
} else if (Array.isArray(criterion)) {
|
|
467
|
+
// Criteria with values like ["UID", "1:*"]
|
|
468
|
+
const [key, value] = criterion;
|
|
469
|
+
if (typeof key === "string") {
|
|
470
|
+
switch (key.toUpperCase()) {
|
|
471
|
+
case "UID":
|
|
472
|
+
result.uid = value;
|
|
473
|
+
break;
|
|
474
|
+
case "FROM":
|
|
475
|
+
result.from = value;
|
|
476
|
+
break;
|
|
477
|
+
case "TO":
|
|
478
|
+
result.to = value;
|
|
479
|
+
break;
|
|
480
|
+
case "SUBJECT":
|
|
481
|
+
result.subject = value;
|
|
482
|
+
break;
|
|
483
|
+
case "SINCE":
|
|
484
|
+
result.since = value;
|
|
485
|
+
break;
|
|
486
|
+
case "BEFORE":
|
|
487
|
+
result.before = value;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return result;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Fetch messages by UID
|
|
499
|
+
*
|
|
500
|
+
* Uses ImapFlow's native envelope parsing which works correctly
|
|
501
|
+
* with all IMAP servers including mokapi.
|
|
502
|
+
*/
|
|
503
|
+
fetchMessages = async (uids: number[]): Promise<ImapMessage[]> => {
|
|
504
|
+
this.ensureConnected();
|
|
505
|
+
const { client } = this;
|
|
506
|
+
|
|
507
|
+
if (!client) {
|
|
508
|
+
throw new Error("Not connected to IMAP server");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (!this.currentMailbox) {
|
|
512
|
+
throw new Error("No mailbox selected");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (uids.length === 0) {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const messages: ImapMessage[] = [];
|
|
520
|
+
|
|
521
|
+
// ImapFlow fetch with native envelope support + References header
|
|
522
|
+
const uidRange = uids.join(",");
|
|
523
|
+
|
|
524
|
+
const fetchIterator = client.fetch(
|
|
525
|
+
uidRange,
|
|
526
|
+
{
|
|
527
|
+
uid: true,
|
|
528
|
+
flags: true,
|
|
529
|
+
envelope: true,
|
|
530
|
+
bodyStructure: true,
|
|
531
|
+
internalDate: true,
|
|
532
|
+
size: true,
|
|
533
|
+
headers: ["references"],
|
|
534
|
+
},
|
|
535
|
+
{ uid: true },
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Connection may have been lost - fetch returns an async iterable
|
|
539
|
+
if (!fetchIterator) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`IMAP connection lost while fetching messages: ${uidRange}`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
for await (const msg of fetchIterator) {
|
|
546
|
+
// imapflow occasionally yields a row with undefined uid or internalDate
|
|
547
|
+
// on back-to-back FETCH calls (e.g. after a body-fetch on the same UID).
|
|
548
|
+
// Skipping the row is safe: the caller asked for a specific UID set and
|
|
549
|
+
// will simply not see that entry rather than the whole call crashing.
|
|
550
|
+
// See #408 for the investigation.
|
|
551
|
+
if (msg.uid == null || msg.internalDate == null) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Coerce INTERNALDATE without ever throwing: a malformed value must not
|
|
556
|
+
// abort the whole fetch batch. `null` only for an absent value (already
|
|
557
|
+
// handled by the guard above); a bad value falls back to now.
|
|
558
|
+
const internalDate = toInternalDate(msg.internalDate);
|
|
559
|
+
if (internalDate === null) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Parse References header if present
|
|
564
|
+
const references = await this.parseReferencesHeader(msg.headers);
|
|
565
|
+
|
|
566
|
+
messages.push({
|
|
567
|
+
uid: msg.uid,
|
|
568
|
+
seq: msg.seq,
|
|
569
|
+
flags: Array.from(msg.flags || []),
|
|
570
|
+
internalDate,
|
|
571
|
+
size: msg.size ?? 0,
|
|
572
|
+
envelope: this.convertEnvelope(msg.envelope),
|
|
573
|
+
references,
|
|
574
|
+
bodyStructure: msg.bodyStructure,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return messages;
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Cheap envelope-only FETCH for the UIDVALIDITY cursor rebuild (#1272):
|
|
583
|
+
* UID + Message-ID + INTERNALDATE only — no flags, no BODYSTRUCTURE, no
|
|
584
|
+
* References header, no body. Deliberately lighter than {@link
|
|
585
|
+
* fetchMessages} so a rebuild pass over a large mailbox stays cheap (epic
|
|
586
|
+
* #1281 invariant 6).
|
|
587
|
+
*/
|
|
588
|
+
fetchEnvelopeSnapshots = async (
|
|
589
|
+
uids: number[],
|
|
590
|
+
): Promise<ImapEnvelopeSnapshot[]> => {
|
|
591
|
+
this.ensureConnected();
|
|
592
|
+
const { client } = this;
|
|
593
|
+
|
|
594
|
+
if (!client) {
|
|
595
|
+
throw new Error("Not connected to IMAP server");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!this.currentMailbox) {
|
|
599
|
+
throw new Error("No mailbox selected");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (uids.length === 0) {
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const uidRange = uids.join(",");
|
|
607
|
+
|
|
608
|
+
const fetchIterator = client.fetch(
|
|
609
|
+
uidRange,
|
|
610
|
+
{ uid: true, envelope: true, internalDate: true },
|
|
611
|
+
{ uid: true },
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
if (!fetchIterator) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`IMAP connection lost while fetching envelope snapshots: ${uidRange}`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const snapshots: ImapEnvelopeSnapshot[] = [];
|
|
621
|
+
|
|
622
|
+
for await (const msg of fetchIterator) {
|
|
623
|
+
// Same defensive skip as fetchMessages (#408) — an occasional row
|
|
624
|
+
// with no uid/internalDate must not abort the whole batch.
|
|
625
|
+
if (msg.uid == null || msg.internalDate == null) {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const internalDate = toInternalDate(msg.internalDate);
|
|
630
|
+
if (internalDate === null) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
snapshots.push({
|
|
635
|
+
uid: msg.uid,
|
|
636
|
+
messageId: msg.envelope?.messageId ?? "",
|
|
637
|
+
internalDate,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return snapshots;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Fetch the full message body (RFC822 source) for a single message by UID.
|
|
646
|
+
*
|
|
647
|
+
* @param uid - The UID of the message to fetch
|
|
648
|
+
* @returns The raw message body as a Buffer
|
|
649
|
+
*/
|
|
650
|
+
fetchMessageBody = async (uid: number): Promise<Buffer> => {
|
|
651
|
+
this.ensureConnected();
|
|
652
|
+
const { client } = this;
|
|
653
|
+
|
|
654
|
+
if (!client) {
|
|
655
|
+
throw new Error("Not connected to IMAP server");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!this.currentMailbox) {
|
|
659
|
+
throw new Error("No mailbox selected");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const result = await client.download(String(uid), undefined, {
|
|
663
|
+
uid: true,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Connection may have been lost during download
|
|
667
|
+
if (!result || !result.content) {
|
|
668
|
+
throw new Error(
|
|
669
|
+
`IMAP connection lost while downloading message UID ${uid}`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const chunks: Buffer[] = [];
|
|
674
|
+
for await (const chunk of result.content) {
|
|
675
|
+
chunks.push(chunk);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return Buffer.concat(chunks);
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Fetch full message bodies (RFC822 source) for many UIDs in ONE pipelined
|
|
683
|
+
* ranged UID FETCH on a single connection — the desktop-client pattern.
|
|
684
|
+
*
|
|
685
|
+
* Mirrors `fetchMessages`: one comma-joined UID range, one `client.fetch`,
|
|
686
|
+
* one SELECT (the caller opens the box once for the whole batch). Yields
|
|
687
|
+
* `{ uid, source }` as each message arrives so the caller can stream each
|
|
688
|
+
* body straight to storage without buffering the whole batch.
|
|
689
|
+
*
|
|
690
|
+
* `source` is a readable stream over the message bytes — callers must
|
|
691
|
+
* consume it (e.g. pipe to an upload) before requesting the next item.
|
|
692
|
+
*/
|
|
693
|
+
async *fetchMessageBodies(
|
|
694
|
+
uids: number[],
|
|
695
|
+
): AsyncGenerator<{ uid: number; source: Readable }> {
|
|
696
|
+
this.ensureConnected();
|
|
697
|
+
const { client } = this;
|
|
698
|
+
|
|
699
|
+
if (!client) {
|
|
700
|
+
throw new Error("Not connected to IMAP server");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (!this.currentMailbox) {
|
|
704
|
+
throw new Error("No mailbox selected");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (uids.length === 0) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const uidRange = uids.join(",");
|
|
712
|
+
|
|
713
|
+
const fetchIterator = client.fetch(
|
|
714
|
+
uidRange,
|
|
715
|
+
{ uid: true, source: true },
|
|
716
|
+
{ uid: true },
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
if (!fetchIterator) {
|
|
720
|
+
throw new MailConnectionError(
|
|
721
|
+
"network",
|
|
722
|
+
`IMAP connection lost while fetching message bodies: ${uidRange}`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
try {
|
|
727
|
+
for await (const msg of fetchIterator) {
|
|
728
|
+
// imapflow can yield a row with an undefined uid or no source on
|
|
729
|
+
// back-to-back FETCH calls; skip it rather than crash the batch — the
|
|
730
|
+
// caller treats any UID it never sees as failed and re-enqueues it.
|
|
731
|
+
// See #408.
|
|
732
|
+
if (msg.uid == null || msg.source == null) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
yield { uid: msg.uid, source: Readable.from(msg.source) };
|
|
737
|
+
}
|
|
738
|
+
} catch (error) {
|
|
739
|
+
// A mid-stream socket drop surfaces as imapflow's
|
|
740
|
+
// `new Error("Connection closed")` with code `EConnectionClosed` — a
|
|
741
|
+
// string the rest of the code never matches on. Re-throw it as the
|
|
742
|
+
// typed MailConnectionError so the caller's fail-fast path triggers and
|
|
743
|
+
// re-enqueues the not-yet-yielded UIDs instead of failing the record.
|
|
744
|
+
throw (
|
|
745
|
+
classifyImapError(error, `${this.config.host}:${this.config.port}`) ??
|
|
746
|
+
error
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Parse the References header from IMAP headers buffer.
|
|
753
|
+
* Returns an array of Message-IDs, with the first being the thread root.
|
|
754
|
+
*/
|
|
755
|
+
private parseReferencesHeader = async (
|
|
756
|
+
headers: Buffer | undefined,
|
|
757
|
+
): Promise<string[] | undefined> => {
|
|
758
|
+
if (!headers) return undefined;
|
|
759
|
+
|
|
760
|
+
// Raw header bytes are untrusted. simpleParser is tolerant but can still
|
|
761
|
+
// throw on sufficiently broken input; a throw here would abort the whole
|
|
762
|
+
// fetch batch. Missing references just means "no thread parent", so on any
|
|
763
|
+
// parse failure treat the message as a thread root rather than crashing.
|
|
764
|
+
const parsed = await simpleParser(headers).catch(() => null);
|
|
765
|
+
|
|
766
|
+
if (!parsed?.references) return undefined;
|
|
767
|
+
|
|
768
|
+
// mailparser returns references as string or array
|
|
769
|
+
if (Array.isArray(parsed.references)) {
|
|
770
|
+
return parsed.references.length > 0 ? parsed.references : undefined;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Single reference as string
|
|
774
|
+
return [parsed.references];
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Convert ImapFlow envelope to our ImapEnvelope format
|
|
779
|
+
*/
|
|
780
|
+
private convertEnvelope = (
|
|
781
|
+
envelope:
|
|
782
|
+
| {
|
|
783
|
+
date?: Date;
|
|
784
|
+
subject?: string;
|
|
785
|
+
from?: Array<{ name?: string; address?: string }>;
|
|
786
|
+
sender?: Array<{ name?: string; address?: string }>;
|
|
787
|
+
replyTo?: Array<{ name?: string; address?: string }>;
|
|
788
|
+
to?: Array<{ name?: string; address?: string }>;
|
|
789
|
+
cc?: Array<{ name?: string; address?: string }>;
|
|
790
|
+
bcc?: Array<{ name?: string; address?: string }>;
|
|
791
|
+
inReplyTo?: string;
|
|
792
|
+
messageId?: string;
|
|
793
|
+
}
|
|
794
|
+
| undefined,
|
|
795
|
+
): ImapMessage["envelope"] => {
|
|
796
|
+
if (!envelope) {
|
|
797
|
+
return {
|
|
798
|
+
date: "",
|
|
799
|
+
subject: "",
|
|
800
|
+
from: [],
|
|
801
|
+
sender: [],
|
|
802
|
+
replyTo: [],
|
|
803
|
+
to: [],
|
|
804
|
+
cc: [],
|
|
805
|
+
bcc: [],
|
|
806
|
+
inReplyTo: "",
|
|
807
|
+
messageId: "",
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const convertAddresses = (
|
|
812
|
+
addrs?: Array<{ name?: string; address?: string }>,
|
|
813
|
+
): ImapAddress[] => {
|
|
814
|
+
if (!addrs) return [];
|
|
815
|
+
return addrs
|
|
816
|
+
.filter((a) => a.address)
|
|
817
|
+
.map((a) => {
|
|
818
|
+
const [mailbox, host] = (a.address || "").split("@");
|
|
819
|
+
return {
|
|
820
|
+
name: a.name || undefined,
|
|
821
|
+
mailbox: mailbox || "",
|
|
822
|
+
host: host || "",
|
|
823
|
+
};
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
date: toIsoDateString(envelope.date),
|
|
829
|
+
subject: envelope.subject ?? "",
|
|
830
|
+
from: convertAddresses(envelope.from),
|
|
831
|
+
sender: convertAddresses(envelope.sender),
|
|
832
|
+
replyTo: convertAddresses(envelope.replyTo),
|
|
833
|
+
to: convertAddresses(envelope.to),
|
|
834
|
+
cc: convertAddresses(envelope.cc),
|
|
835
|
+
bcc: convertAddresses(envelope.bcc),
|
|
836
|
+
inReplyTo: envelope.inReplyTo ?? "",
|
|
837
|
+
messageId: envelope.messageId ?? "",
|
|
838
|
+
};
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Add flags to messages by UID.
|
|
843
|
+
* Requires mailbox to be open (not read-only).
|
|
844
|
+
*
|
|
845
|
+
* @param uids - Array of message UIDs
|
|
846
|
+
* @param flags - Array of flags to add (e.g., ["\\Seen", "\\Flagged"])
|
|
847
|
+
*/
|
|
848
|
+
addFlags = async (uids: number[], flags: string[]): Promise<void> => {
|
|
849
|
+
this.ensureConnected();
|
|
850
|
+
const { client } = this;
|
|
851
|
+
|
|
852
|
+
if (!client) {
|
|
853
|
+
throw new Error("Not connected to IMAP server");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (!this.currentMailbox) {
|
|
857
|
+
throw new Error("No mailbox selected");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (uids.length === 0 || flags.length === 0) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const uidRange = uids.join(",");
|
|
865
|
+
await client.messageFlagsAdd(uidRange, flags, { uid: true });
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Remove flags from messages by UID.
|
|
870
|
+
* Requires mailbox to be open (not read-only).
|
|
871
|
+
*
|
|
872
|
+
* @param uids - Array of message UIDs
|
|
873
|
+
* @param flags - Array of flags to remove (e.g., ["\\Seen", "\\Flagged"])
|
|
874
|
+
*/
|
|
875
|
+
removeFlags = async (uids: number[], flags: string[]): Promise<void> => {
|
|
876
|
+
this.ensureConnected();
|
|
877
|
+
const { client } = this;
|
|
878
|
+
|
|
879
|
+
if (!client) {
|
|
880
|
+
throw new Error("Not connected to IMAP server");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (!this.currentMailbox) {
|
|
884
|
+
throw new Error("No mailbox selected");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (uids.length === 0 || flags.length === 0) {
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const uidRange = uids.join(",");
|
|
892
|
+
await client.messageFlagsRemove(uidRange, flags, { uid: true });
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Replace all flags on messages by UID.
|
|
897
|
+
* Requires mailbox to be open (not read-only).
|
|
898
|
+
*
|
|
899
|
+
* @param uids - Array of message UIDs
|
|
900
|
+
* @param flags - Array of flags to set (replaces all existing flags)
|
|
901
|
+
*/
|
|
902
|
+
setFlags = async (uids: number[], flags: string[]): Promise<void> => {
|
|
903
|
+
this.ensureConnected();
|
|
904
|
+
const { client } = this;
|
|
905
|
+
|
|
906
|
+
if (!client) {
|
|
907
|
+
throw new Error("Not connected to IMAP server");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (!this.currentMailbox) {
|
|
911
|
+
throw new Error("No mailbox selected");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (uids.length === 0) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const uidRange = uids.join(",");
|
|
919
|
+
await client.messageFlagsSet(uidRange, flags, { uid: true });
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Create a new mailbox.
|
|
924
|
+
*
|
|
925
|
+
* @param path - Full path of the mailbox to create (e.g., "Projects/ClientA")
|
|
926
|
+
* @returns Object with path and whether it was created (false if already exists)
|
|
927
|
+
*/
|
|
928
|
+
createMailbox = async (
|
|
929
|
+
path: string,
|
|
930
|
+
): Promise<{ path: string; created: boolean }> => {
|
|
931
|
+
this.ensureConnected();
|
|
932
|
+
const { client } = this;
|
|
933
|
+
|
|
934
|
+
if (!client) {
|
|
935
|
+
throw new Error("Not connected to IMAP server");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const result = await client.mailboxCreate(path);
|
|
939
|
+
return {
|
|
940
|
+
path: result.path,
|
|
941
|
+
created: result.created ?? true,
|
|
942
|
+
};
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Delete a mailbox.
|
|
947
|
+
*
|
|
948
|
+
* @param path - Full path of the mailbox to delete
|
|
949
|
+
* @returns Object with the deleted path
|
|
950
|
+
*/
|
|
951
|
+
deleteMailbox = async (path: string): Promise<{ path: string }> => {
|
|
952
|
+
this.ensureConnected();
|
|
953
|
+
const { client } = this;
|
|
954
|
+
|
|
955
|
+
if (!client) {
|
|
956
|
+
throw new Error("Not connected to IMAP server");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Cannot delete INBOX
|
|
960
|
+
if (path.toUpperCase() === "INBOX") {
|
|
961
|
+
throw new Error("Cannot delete INBOX");
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const result = await client.mailboxDelete(path);
|
|
965
|
+
return { path: result.path };
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Rename a mailbox.
|
|
970
|
+
*
|
|
971
|
+
* @param oldPath - Current path of the mailbox
|
|
972
|
+
* @param newPath - New path for the mailbox
|
|
973
|
+
* @returns Object with old and new paths
|
|
974
|
+
*/
|
|
975
|
+
renameMailbox = async (
|
|
976
|
+
oldPath: string,
|
|
977
|
+
newPath: string,
|
|
978
|
+
): Promise<{ path: string; newPath: string }> => {
|
|
979
|
+
this.ensureConnected();
|
|
980
|
+
const { client } = this;
|
|
981
|
+
|
|
982
|
+
if (!client) {
|
|
983
|
+
throw new Error("Not connected to IMAP server");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const result = await client.mailboxRename(oldPath, newPath);
|
|
987
|
+
return {
|
|
988
|
+
path: result.path,
|
|
989
|
+
newPath: result.newPath,
|
|
990
|
+
};
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Subscribe to a mailbox.
|
|
995
|
+
*
|
|
996
|
+
* @param path - Full path of the mailbox to subscribe to
|
|
997
|
+
*/
|
|
998
|
+
subscribeMailbox = async (path: string): Promise<void> => {
|
|
999
|
+
this.ensureConnected();
|
|
1000
|
+
const { client } = this;
|
|
1001
|
+
|
|
1002
|
+
if (!client) {
|
|
1003
|
+
throw new Error("Not connected to IMAP server");
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
await client.mailboxSubscribe(path);
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Unsubscribe from a mailbox.
|
|
1011
|
+
*
|
|
1012
|
+
* @param path - Full path of the mailbox to unsubscribe from
|
|
1013
|
+
*/
|
|
1014
|
+
unsubscribeMailbox = async (path: string): Promise<void> => {
|
|
1015
|
+
this.ensureConnected();
|
|
1016
|
+
const { client } = this;
|
|
1017
|
+
|
|
1018
|
+
if (!client) {
|
|
1019
|
+
throw new Error("Not connected to IMAP server");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
await client.mailboxUnsubscribe(path);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Move messages to another mailbox.
|
|
1027
|
+
* Uses ImapFlow's messageMove which wraps UID MOVE command.
|
|
1028
|
+
* Returns mapping of source UIDs to destination UIDs from COPYUID response.
|
|
1029
|
+
*
|
|
1030
|
+
* @param uids - Array of message UIDs to move
|
|
1031
|
+
* @param destination - Destination mailbox path
|
|
1032
|
+
* @returns Object with destination path, uidValidity, and uidMap
|
|
1033
|
+
*/
|
|
1034
|
+
moveMessages = async (
|
|
1035
|
+
uids: number[],
|
|
1036
|
+
destination: string,
|
|
1037
|
+
): Promise<{
|
|
1038
|
+
destination: string;
|
|
1039
|
+
uidValidity: number;
|
|
1040
|
+
uidMap: Map<number, number>;
|
|
1041
|
+
}> => {
|
|
1042
|
+
this.ensureConnected();
|
|
1043
|
+
const { client } = this;
|
|
1044
|
+
|
|
1045
|
+
if (!client) {
|
|
1046
|
+
throw new Error("Not connected to IMAP server");
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (!this.currentMailbox) {
|
|
1050
|
+
throw new Error("No mailbox selected");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (uids.length === 0) {
|
|
1054
|
+
return { destination, uidValidity: 0, uidMap: new Map() };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const uidRange = uids.join(",");
|
|
1058
|
+
const result = await client.messageMove(uidRange, destination, {
|
|
1059
|
+
uid: true,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// messageMove returns false if no messages were moved
|
|
1063
|
+
if (result === false) {
|
|
1064
|
+
return { destination, uidValidity: 0, uidMap: new Map() };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return {
|
|
1068
|
+
destination: result.destination,
|
|
1069
|
+
uidValidity: Number(result.uidValidity ?? 0),
|
|
1070
|
+
uidMap: result.uidMap ?? new Map(),
|
|
1071
|
+
};
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Copy messages to another mailbox.
|
|
1076
|
+
* Uses ImapFlow's messageCopy which wraps UID COPY command.
|
|
1077
|
+
* Returns mapping of source UIDs to destination UIDs from COPYUID response.
|
|
1078
|
+
*
|
|
1079
|
+
* @param uids - Array of message UIDs to copy
|
|
1080
|
+
* @param destination - Destination mailbox path
|
|
1081
|
+
* @returns Object with destination path, uidValidity, and uidMap
|
|
1082
|
+
*/
|
|
1083
|
+
copyMessages = async (
|
|
1084
|
+
uids: number[],
|
|
1085
|
+
destination: string,
|
|
1086
|
+
): Promise<{
|
|
1087
|
+
destination: string;
|
|
1088
|
+
uidValidity: number;
|
|
1089
|
+
uidMap: Map<number, number>;
|
|
1090
|
+
}> => {
|
|
1091
|
+
this.ensureConnected();
|
|
1092
|
+
const { client } = this;
|
|
1093
|
+
|
|
1094
|
+
if (!client) {
|
|
1095
|
+
throw new Error("Not connected to IMAP server");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (!this.currentMailbox) {
|
|
1099
|
+
throw new Error("No mailbox selected");
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (uids.length === 0) {
|
|
1103
|
+
return { destination, uidValidity: 0, uidMap: new Map() };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const uidRange = uids.join(",");
|
|
1107
|
+
const result = await client.messageCopy(uidRange, destination, {
|
|
1108
|
+
uid: true,
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// messageCopy returns false if no messages were copied
|
|
1112
|
+
if (result === false) {
|
|
1113
|
+
return { destination, uidValidity: 0, uidMap: new Map() };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
destination: result.destination,
|
|
1118
|
+
uidValidity: Number(result.uidValidity ?? 0),
|
|
1119
|
+
uidMap: result.uidMap ?? new Map(),
|
|
1120
|
+
};
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Permanently delete messages (mark \Deleted + expunge).
|
|
1125
|
+
* Uses ImapFlow's messageDelete which wraps STORE +FLAGS.SILENT \Deleted
|
|
1126
|
+
* followed by UID EXPUNGE.
|
|
1127
|
+
*
|
|
1128
|
+
* @param uids - Array of message UIDs to delete
|
|
1129
|
+
* @returns Number of messages deleted
|
|
1130
|
+
*/
|
|
1131
|
+
deleteMessages = async (uids: number[]): Promise<number> => {
|
|
1132
|
+
this.ensureConnected();
|
|
1133
|
+
const { client } = this;
|
|
1134
|
+
|
|
1135
|
+
if (!client) {
|
|
1136
|
+
throw new Error("Not connected to IMAP server");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (!this.currentMailbox) {
|
|
1140
|
+
throw new Error("No mailbox selected");
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (uids.length === 0) {
|
|
1144
|
+
return 0;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const uidRange = uids.join(",");
|
|
1148
|
+
const result = await client.messageDelete(uidRange, { uid: true });
|
|
1149
|
+
|
|
1150
|
+
// messageDelete returns boolean or deleted count
|
|
1151
|
+
if (typeof result === "number") {
|
|
1152
|
+
return result;
|
|
1153
|
+
}
|
|
1154
|
+
return result ? uids.length : 0;
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Get mailbox status.
|
|
1159
|
+
*
|
|
1160
|
+
* Message/unseen counts come from the IMAP STATUS command (no SELECT). The
|
|
1161
|
+
* \Deleted count is not part of imapflow's typed STATUS query (there is no
|
|
1162
|
+
* IMAP4rev2 STATUS=DELETED in the API), so it is derived by selecting the
|
|
1163
|
+
* mailbox read-only and running SEARCH \Deleted — a projection re-read from
|
|
1164
|
+
* the server, never a locally computed value.
|
|
1165
|
+
*
|
|
1166
|
+
* @param mailboxPath - Full path to the mailbox
|
|
1167
|
+
* @returns Mailbox status including unseen and deleted counts
|
|
1168
|
+
*/
|
|
1169
|
+
getMailboxStatus = async (
|
|
1170
|
+
mailboxPath: string,
|
|
1171
|
+
): Promise<ImapMailboxStatus> => {
|
|
1172
|
+
this.ensureConnected();
|
|
1173
|
+
const { client } = this;
|
|
1174
|
+
|
|
1175
|
+
if (!client) {
|
|
1176
|
+
throw new Error("Not connected to IMAP server");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const status = await client.status(mailboxPath, {
|
|
1180
|
+
messages: true,
|
|
1181
|
+
recent: true,
|
|
1182
|
+
unseen: true,
|
|
1183
|
+
uidNext: true,
|
|
1184
|
+
uidValidity: true,
|
|
1185
|
+
highestModseq: true,
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
const deletedCount = await this.countDeleted(mailboxPath);
|
|
1189
|
+
|
|
1190
|
+
return {
|
|
1191
|
+
messages: status.messages ?? 0,
|
|
1192
|
+
recent: status.recent ?? 0,
|
|
1193
|
+
unseen: status.unseen ?? 0,
|
|
1194
|
+
uidNext: status.uidNext ?? 0,
|
|
1195
|
+
uidValidity: Number(status.uidValidity ?? 0),
|
|
1196
|
+
highestModseq: Number(status.highestModseq ?? 0),
|
|
1197
|
+
deletedCount,
|
|
1198
|
+
};
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Count messages flagged \Deleted but not yet expunged.
|
|
1203
|
+
*
|
|
1204
|
+
* Selects the mailbox read-only (no state change) and counts SEARCH \Deleted
|
|
1205
|
+
* hits. Read-only SELECT is idempotent against an already-open mailbox.
|
|
1206
|
+
*/
|
|
1207
|
+
private countDeleted = async (mailboxPath: string): Promise<number> => {
|
|
1208
|
+
await this.openBox(mailboxPath, true);
|
|
1209
|
+
const deletedUids = await this.search(["DELETED"]);
|
|
1210
|
+
return deletedUids.length;
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Append a message to a mailbox.
|
|
1215
|
+
* Used primarily for testing to seed messages.
|
|
1216
|
+
*
|
|
1217
|
+
* @param mailbox - Mailbox path to append to
|
|
1218
|
+
* @param message - RFC 822 message content
|
|
1219
|
+
* @param flags - Optional flags to set on the message
|
|
1220
|
+
* @returns Object with destination path, uidValidity, and uid of the appended message
|
|
1221
|
+
*/
|
|
1222
|
+
append = async (
|
|
1223
|
+
mailbox: string,
|
|
1224
|
+
message: string | Buffer,
|
|
1225
|
+
flags?: string[],
|
|
1226
|
+
): Promise<{
|
|
1227
|
+
destination: string;
|
|
1228
|
+
uidValidity: number;
|
|
1229
|
+
uid: number;
|
|
1230
|
+
}> => {
|
|
1231
|
+
this.ensureConnected();
|
|
1232
|
+
const { client } = this;
|
|
1233
|
+
|
|
1234
|
+
if (!client) {
|
|
1235
|
+
throw new Error("Not connected to IMAP server");
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const result = await client.append(mailbox, message, flags);
|
|
1239
|
+
|
|
1240
|
+
// append returns false if the message could not be appended
|
|
1241
|
+
if (result === false) {
|
|
1242
|
+
throw new Error(`Failed to append message to ${mailbox}`);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
destination: result.destination,
|
|
1247
|
+
uidValidity: Number(result.uidValidity ?? 0),
|
|
1248
|
+
uid: result.uid ?? 0,
|
|
1249
|
+
};
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Ensure the connection is established
|
|
1254
|
+
*/
|
|
1255
|
+
private ensureConnected = (): void => {
|
|
1256
|
+
if (!this.client || this._state !== "authenticated") {
|
|
1257
|
+
throw new Error("Not connected to IMAP server");
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Create an ImapFlow connection from account data using password credentials.
|
|
1264
|
+
*/
|
|
1265
|
+
export const createImapFlowConnectionFromAccount = (
|
|
1266
|
+
account: {
|
|
1267
|
+
imapHost: string;
|
|
1268
|
+
imapPort: number;
|
|
1269
|
+
imapTls: boolean;
|
|
1270
|
+
username: string;
|
|
1271
|
+
},
|
|
1272
|
+
password: string,
|
|
1273
|
+
): ImapFlowConnection => {
|
|
1274
|
+
return new ImapFlowConnection({
|
|
1275
|
+
host: account.imapHost,
|
|
1276
|
+
port: account.imapPort,
|
|
1277
|
+
tls: account.imapTls,
|
|
1278
|
+
user: account.username,
|
|
1279
|
+
credentials: { kind: "password", password },
|
|
1280
|
+
});
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Create an ImapFlow connection using a credentials union (password or OAuth access token).
|
|
1285
|
+
*/
|
|
1286
|
+
export const createImapFlowConnectionWithCredentials = (
|
|
1287
|
+
account: {
|
|
1288
|
+
imapHost: string;
|
|
1289
|
+
imapPort: number;
|
|
1290
|
+
imapTls: boolean;
|
|
1291
|
+
username: string;
|
|
1292
|
+
},
|
|
1293
|
+
credentials: import("./types.js").MailCredentials,
|
|
1294
|
+
): ImapFlowConnection => {
|
|
1295
|
+
return new ImapFlowConnection({
|
|
1296
|
+
host: account.imapHost,
|
|
1297
|
+
port: account.imapPort,
|
|
1298
|
+
tls: account.imapTls,
|
|
1299
|
+
user: account.username,
|
|
1300
|
+
credentials,
|
|
1301
|
+
});
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Build the imapflow auth object from mail credentials.
|
|
1306
|
+
*
|
|
1307
|
+
* IMPORTANT: never include access-token values in error messages.
|
|
1308
|
+
*/
|
|
1309
|
+
const buildImapAuth = (
|
|
1310
|
+
user: string,
|
|
1311
|
+
credentials: MailCredentials,
|
|
1312
|
+
): { user: string; pass: string } | { user: string; accessToken: string } => {
|
|
1313
|
+
if (credentials.kind === "password") {
|
|
1314
|
+
return { user, pass: credentials.password };
|
|
1315
|
+
}
|
|
1316
|
+
if (credentials.kind === "accessToken") {
|
|
1317
|
+
return { user, accessToken: credentials.accessToken };
|
|
1318
|
+
}
|
|
1319
|
+
// Exhaustiveness check — fails to compile if a new credential kind is added
|
|
1320
|
+
// without handling it here.
|
|
1321
|
+
const _exhaustive: never = credentials;
|
|
1322
|
+
throw new Error(`Unknown credential kind: ${JSON.stringify(_exhaustive)}`);
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Classify a raw IMAP error into a MailConnectionError, or return null if
|
|
1327
|
+
* the error is not recognisable (let it bubble as-is).
|
|
1328
|
+
*
|
|
1329
|
+
* IMPORTANT: never include access-token values in error messages.
|
|
1330
|
+
*/
|
|
1331
|
+
const classifyImapError = (
|
|
1332
|
+
error: unknown,
|
|
1333
|
+
endpoint?: string,
|
|
1334
|
+
): MailConnectionError | null => {
|
|
1335
|
+
if (!(error instanceof Error)) {
|
|
1336
|
+
return null;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const msg = error.message;
|
|
1340
|
+
const code = (error as NodeJS.ErrnoException).code ?? "";
|
|
1341
|
+
|
|
1342
|
+
// Authentication failures
|
|
1343
|
+
if (
|
|
1344
|
+
msg.includes("Invalid credentials") ||
|
|
1345
|
+
msg.includes("Authentication failed") ||
|
|
1346
|
+
msg.includes("AUTHENTICATIONFAILED") ||
|
|
1347
|
+
msg.includes("AUTHENTICATE") ||
|
|
1348
|
+
(error as { authenticationFailed?: boolean }).authenticationFailed === true
|
|
1349
|
+
) {
|
|
1350
|
+
return new MailConnectionError("auth", "IMAP authentication failed");
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Network-level failures — NEVER include the original message as it may
|
|
1354
|
+
// echo back tokens in some server implementations. The endpoint (host:port)
|
|
1355
|
+
// is safe and makes DNS/connect failures self-describing.
|
|
1356
|
+
if (
|
|
1357
|
+
code === "ECONNREFUSED" ||
|
|
1358
|
+
code === "ETIMEDOUT" ||
|
|
1359
|
+
code === "ENOTFOUND" ||
|
|
1360
|
+
code === "ECONNRESET" ||
|
|
1361
|
+
code === "EHOSTUNREACH" ||
|
|
1362
|
+
// imapflow raises these when the socket is gone mid-command.
|
|
1363
|
+
code === "EConnectionClosed" ||
|
|
1364
|
+
code === "NoConnection"
|
|
1365
|
+
) {
|
|
1366
|
+
const where = endpoint ? ` (${endpoint})` : "";
|
|
1367
|
+
return new MailConnectionError(
|
|
1368
|
+
"network",
|
|
1369
|
+
`IMAP connection failed: ${code}${where}`,
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
return null;
|
|
1374
|
+
};
|