@bobfrankston/iflow-direct 0.1.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/bridge-transport.d.ts +30 -0
- package/bridge-transport.js +60 -0
- package/credentials.json +9 -0
- package/gmail.d.ts +39 -0
- package/gmail.js +66 -0
- package/imap-compat.d.ts +83 -0
- package/imap-compat.js +254 -0
- package/imap-native.d.ts +135 -0
- package/imap-native.js +733 -0
- package/imap-protocol.d.ts +133 -0
- package/imap-protocol.js +391 -0
- package/index.d.ts +18 -0
- package/index.js +18 -0
- package/package.json +42 -0
- package/transport.d.ts +25 -0
- package/transport.js +6 -0
- package/types.d.ts +14 -0
- package/types.js +5 -0
package/imap-native.js
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native IMAP client — transport-agnostic.
|
|
3
|
+
* Uses ImapTransport for I/O, imap-protocol for parsing.
|
|
4
|
+
* Works with NodeTransport (desktop) or BridgeTransport (Android).
|
|
5
|
+
*
|
|
6
|
+
* This is a NEW client alongside the existing ImapClient (which wraps imapflow).
|
|
7
|
+
* Existing callers are not affected.
|
|
8
|
+
*/
|
|
9
|
+
import * as proto from "./imap-protocol.js";
|
|
10
|
+
export class NativeImapClient {
|
|
11
|
+
transport;
|
|
12
|
+
transportFactory;
|
|
13
|
+
config;
|
|
14
|
+
buffer = "";
|
|
15
|
+
pendingCommand = null;
|
|
16
|
+
capabilities = new Set();
|
|
17
|
+
_connected = false;
|
|
18
|
+
idleTag = null;
|
|
19
|
+
idleCallback = null;
|
|
20
|
+
verbose;
|
|
21
|
+
selectedMailbox = null;
|
|
22
|
+
mailboxInfo = { exists: 0, recent: 0, uidNext: 0, uidValidity: 0, flags: [], permanentFlags: [] };
|
|
23
|
+
greetingResolve = null;
|
|
24
|
+
/** Callback for waitForContinuation — set when waiting for "+" response */
|
|
25
|
+
continuationResolve = null;
|
|
26
|
+
constructor(config, transportFactory) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.transportFactory = transportFactory;
|
|
29
|
+
this.transport = transportFactory();
|
|
30
|
+
this.verbose = config.verbose || false;
|
|
31
|
+
}
|
|
32
|
+
get connected() { return this._connected; }
|
|
33
|
+
// ── Connection ──
|
|
34
|
+
async connect() {
|
|
35
|
+
const useTls = this.config.port === 993;
|
|
36
|
+
this.transport.onData((data) => this.handleData(data));
|
|
37
|
+
this.transport.onClose(() => {
|
|
38
|
+
this._connected = false;
|
|
39
|
+
// Reject any pending command so it doesn't hang forever
|
|
40
|
+
if (this.pendingCommand) {
|
|
41
|
+
const { reject } = this.pendingCommand;
|
|
42
|
+
this.pendingCommand = null;
|
|
43
|
+
reject(new Error("Connection closed"));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
this.transport.onError((err) => {
|
|
47
|
+
if (this.verbose)
|
|
48
|
+
console.error(` [imap] Transport error: ${err.message}`);
|
|
49
|
+
// Reject any pending command on transport error
|
|
50
|
+
if (this.pendingCommand) {
|
|
51
|
+
const { reject } = this.pendingCommand;
|
|
52
|
+
this.pendingCommand = null;
|
|
53
|
+
reject(err);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
await this.transport.connect(this.config.server, this.config.port, useTls, this.config.server);
|
|
57
|
+
// Read server greeting
|
|
58
|
+
const greeting = await this.readGreeting();
|
|
59
|
+
if (this.verbose)
|
|
60
|
+
console.log(` [imap] Greeting: ${greeting.raw}`);
|
|
61
|
+
// Parse capabilities from greeting
|
|
62
|
+
if (greeting.text.includes("CAPABILITY")) {
|
|
63
|
+
this.parseCapabilities(greeting.text);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
await this.capability();
|
|
67
|
+
}
|
|
68
|
+
// STARTTLS if needed
|
|
69
|
+
if (!useTls && this.capabilities.has("STARTTLS")) {
|
|
70
|
+
await this.starttls();
|
|
71
|
+
}
|
|
72
|
+
this._connected = true;
|
|
73
|
+
// Authenticate
|
|
74
|
+
await this.authenticate();
|
|
75
|
+
}
|
|
76
|
+
async readGreeting() {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
this.greetingResolve = null;
|
|
80
|
+
reject(new Error("Greeting timeout (10s)"));
|
|
81
|
+
}, 10000);
|
|
82
|
+
this.greetingResolve = (resp) => {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
resolve(resp);
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async authenticate() {
|
|
89
|
+
if (this.config.tokenProvider) {
|
|
90
|
+
const token = await this.config.tokenProvider();
|
|
91
|
+
const tag = proto.nextTag();
|
|
92
|
+
const cmd = proto.xoauth2Command(tag, this.config.username, token);
|
|
93
|
+
if (this.verbose)
|
|
94
|
+
console.log(` [imap] > AUTHENTICATE XOAUTH2 ...`);
|
|
95
|
+
const responses = await this.sendCommand(tag, cmd);
|
|
96
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
97
|
+
if (!tagged || tagged.type !== "OK") {
|
|
98
|
+
const errText = tagged?.text || responses.map(r => r.raw).join("; ");
|
|
99
|
+
throw new Error(`Authentication failed: ${errText}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (this.config.password) {
|
|
103
|
+
const tag = proto.nextTag();
|
|
104
|
+
const cmd = proto.loginCommand(tag, this.config.username, this.config.password);
|
|
105
|
+
if (this.verbose)
|
|
106
|
+
console.log(` [imap] > LOGIN ${this.config.username} ***`);
|
|
107
|
+
const responses = await this.sendCommand(tag, cmd);
|
|
108
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
109
|
+
if (!tagged || tagged.type !== "OK") {
|
|
110
|
+
const errText = tagged?.text || responses.map(r => r.raw).join("; ");
|
|
111
|
+
throw new Error(`Login failed: ${errText}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
throw new Error("No password or token provider configured");
|
|
116
|
+
}
|
|
117
|
+
// Re-read capabilities after auth (they may change)
|
|
118
|
+
await this.capability();
|
|
119
|
+
}
|
|
120
|
+
async starttls() {
|
|
121
|
+
const tag = proto.nextTag();
|
|
122
|
+
const responses = await this.sendCommand(tag, proto.starttlsCommand(tag));
|
|
123
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
124
|
+
if (!tagged || tagged.type !== "OK")
|
|
125
|
+
throw new Error("STARTTLS failed");
|
|
126
|
+
await this.transport.upgradeTLS(this.config.server);
|
|
127
|
+
await this.capability();
|
|
128
|
+
}
|
|
129
|
+
async capability() {
|
|
130
|
+
const tag = proto.nextTag();
|
|
131
|
+
const responses = await this.sendCommand(tag, proto.capabilityCommand(tag));
|
|
132
|
+
for (const r of responses) {
|
|
133
|
+
if (r.tag === "*" && r.type === "CAPABILITY") {
|
|
134
|
+
this.parseCapabilities(r.text);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return this.capabilities;
|
|
138
|
+
}
|
|
139
|
+
parseCapabilities(text) {
|
|
140
|
+
const caps = text.replace(/^CAPABILITY\s*/i, "").split(/\s+/);
|
|
141
|
+
this.capabilities.clear();
|
|
142
|
+
for (const c of caps)
|
|
143
|
+
this.capabilities.add(c.toUpperCase());
|
|
144
|
+
}
|
|
145
|
+
async logout() {
|
|
146
|
+
// Force-close the transport immediately. The LOGOUT command is a courtesy —
|
|
147
|
+
// the server cleans up the session when the socket closes.
|
|
148
|
+
// Sending LOGOUT and waiting caused hangs and connection leaks.
|
|
149
|
+
this.pendingCommand = null;
|
|
150
|
+
this.transport.close();
|
|
151
|
+
this._connected = false;
|
|
152
|
+
}
|
|
153
|
+
// ── Mailbox Operations ──
|
|
154
|
+
async select(mailbox) {
|
|
155
|
+
const tag = proto.nextTag();
|
|
156
|
+
const responses = await this.sendCommand(tag, proto.selectCommand(tag, mailbox));
|
|
157
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
158
|
+
if (!tagged || tagged.type !== "OK") {
|
|
159
|
+
throw new Error(`SELECT ${mailbox} failed: ${tagged?.text || "unknown"}`);
|
|
160
|
+
}
|
|
161
|
+
// Parse mailbox info from untagged responses
|
|
162
|
+
for (const r of responses) {
|
|
163
|
+
if (r.tag !== "*")
|
|
164
|
+
continue;
|
|
165
|
+
if (r.type === "EXISTS") {
|
|
166
|
+
this.mailboxInfo.exists = parseInt(r.text);
|
|
167
|
+
}
|
|
168
|
+
else if (r.type === "RECENT") {
|
|
169
|
+
this.mailboxInfo.recent = parseInt(r.text);
|
|
170
|
+
}
|
|
171
|
+
else if (r.type === "FLAGS") {
|
|
172
|
+
this.mailboxInfo.flags = [...proto.parseFlags(r.text)];
|
|
173
|
+
}
|
|
174
|
+
else if (r.type === "OK") {
|
|
175
|
+
const uidNextMatch = r.text.match(/UIDNEXT\s+(\d+)/i);
|
|
176
|
+
if (uidNextMatch)
|
|
177
|
+
this.mailboxInfo.uidNext = parseInt(uidNextMatch[1]);
|
|
178
|
+
const uidValMatch = r.text.match(/UIDVALIDITY\s+(\d+)/i);
|
|
179
|
+
if (uidValMatch)
|
|
180
|
+
this.mailboxInfo.uidValidity = parseInt(uidValMatch[1]);
|
|
181
|
+
const permMatch = r.text.match(/PERMANENTFLAGS\s+\(([^)]*)\)/i);
|
|
182
|
+
if (permMatch)
|
|
183
|
+
this.mailboxInfo.permanentFlags = permMatch[1].split(/\s+/).filter(Boolean);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.selectedMailbox = mailbox;
|
|
187
|
+
return { ...this.mailboxInfo };
|
|
188
|
+
}
|
|
189
|
+
async examine(mailbox) {
|
|
190
|
+
const tag = proto.nextTag();
|
|
191
|
+
const responses = await this.sendCommand(tag, proto.examineCommand(tag, mailbox));
|
|
192
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
193
|
+
if (!tagged || tagged.type !== "OK") {
|
|
194
|
+
throw new Error(`EXAMINE ${mailbox} failed: ${tagged?.text || "unknown"}`);
|
|
195
|
+
}
|
|
196
|
+
for (const r of responses) {
|
|
197
|
+
if (r.tag !== "*")
|
|
198
|
+
continue;
|
|
199
|
+
if (r.type === "EXISTS")
|
|
200
|
+
this.mailboxInfo.exists = parseInt(r.text);
|
|
201
|
+
else if (r.type === "RECENT")
|
|
202
|
+
this.mailboxInfo.recent = parseInt(r.text);
|
|
203
|
+
}
|
|
204
|
+
this.selectedMailbox = mailbox;
|
|
205
|
+
return { ...this.mailboxInfo };
|
|
206
|
+
}
|
|
207
|
+
/** Close the currently selected mailbox */
|
|
208
|
+
async closeMailbox() {
|
|
209
|
+
if (!this.selectedMailbox)
|
|
210
|
+
return;
|
|
211
|
+
const tag = proto.nextTag();
|
|
212
|
+
await this.sendCommand(tag, proto.buildCommand(tag, "CLOSE"));
|
|
213
|
+
this.selectedMailbox = null;
|
|
214
|
+
}
|
|
215
|
+
// ── Folder Operations ──
|
|
216
|
+
async listFolders() {
|
|
217
|
+
const tag = proto.nextTag();
|
|
218
|
+
const responses = await this.sendCommand(tag, proto.listCommand(tag));
|
|
219
|
+
const folders = [];
|
|
220
|
+
let unparsed = 0;
|
|
221
|
+
const listResponses = responses.filter(r => r.tag === "*" && r.type === "LIST");
|
|
222
|
+
if (listResponses.length === 0 && responses.length > 0) {
|
|
223
|
+
console.error(` [imap] LIST returned ${responses.length} responses but none were LIST type. Types: ${responses.map(r => `${r.tag}:${r.type}`).join(", ")}`);
|
|
224
|
+
if (responses.length <= 5) {
|
|
225
|
+
for (const r of responses)
|
|
226
|
+
console.error(` [imap] raw: ${JSON.stringify(r.text.substring(0, 200))}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const r of responses) {
|
|
230
|
+
if (r.tag === "*" && r.type === "LIST") {
|
|
231
|
+
const parsed = proto.parseListResponse(r.text);
|
|
232
|
+
if (parsed) {
|
|
233
|
+
folders.push(parsed);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
unparsed++;
|
|
237
|
+
if (unparsed <= 3)
|
|
238
|
+
console.error(` [imap] Unparsed LIST response: ${JSON.stringify(r.text.substring(0, 200))}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (unparsed > 0)
|
|
243
|
+
console.error(` [imap] ${unparsed} LIST responses could not be parsed (${responses.length} total responses)`);
|
|
244
|
+
return folders;
|
|
245
|
+
}
|
|
246
|
+
async getStatus(mailbox) {
|
|
247
|
+
const tag = proto.nextTag();
|
|
248
|
+
const responses = await this.sendCommand(tag, proto.statusCommand(tag, mailbox, ["MESSAGES", "UIDNEXT", "UNSEEN"]));
|
|
249
|
+
for (const r of responses) {
|
|
250
|
+
if (r.tag === "*" && r.type === "STATUS") {
|
|
251
|
+
const data = proto.parseStatusResponse(r.text);
|
|
252
|
+
if (data)
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
async createMailbox(mailbox) {
|
|
259
|
+
const tag = proto.nextTag();
|
|
260
|
+
const responses = await this.sendCommand(tag, proto.createCommand(tag, mailbox));
|
|
261
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
262
|
+
if (!tagged || tagged.type !== "OK")
|
|
263
|
+
throw new Error(`CREATE failed: ${tagged?.text || "unknown"}`);
|
|
264
|
+
}
|
|
265
|
+
async deleteMailbox(mailbox) {
|
|
266
|
+
const tag = proto.nextTag();
|
|
267
|
+
const responses = await this.sendCommand(tag, proto.deleteMailboxCommand(tag, mailbox));
|
|
268
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
269
|
+
if (!tagged || tagged.type !== "OK")
|
|
270
|
+
throw new Error(`DELETE failed: ${tagged?.text || "unknown"}`);
|
|
271
|
+
}
|
|
272
|
+
async renameMailbox(from, to) {
|
|
273
|
+
const tag = proto.nextTag();
|
|
274
|
+
const responses = await this.sendCommand(tag, proto.renameCommand(tag, from, to));
|
|
275
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
276
|
+
if (!tagged || tagged.type !== "OK")
|
|
277
|
+
throw new Error(`RENAME failed: ${tagged?.text || "unknown"}`);
|
|
278
|
+
}
|
|
279
|
+
// ── Message Operations ──
|
|
280
|
+
/** Fetch messages by UID range */
|
|
281
|
+
async fetchMessages(range, options = {}) {
|
|
282
|
+
const items = ["UID", "FLAGS", "ENVELOPE", "RFC822.SIZE", "INTERNALDATE"];
|
|
283
|
+
if (options.headers !== false)
|
|
284
|
+
items.push("BODY.PEEK[HEADER]");
|
|
285
|
+
if (options.source)
|
|
286
|
+
items.push("BODY.PEEK[]");
|
|
287
|
+
const tag = proto.nextTag();
|
|
288
|
+
const responses = await this.sendCommand(tag, proto.fetchCommand(tag, range, items));
|
|
289
|
+
return this.parseFetchResponses(responses);
|
|
290
|
+
}
|
|
291
|
+
/** Fetch messages since a UID */
|
|
292
|
+
async fetchSinceUid(sinceUid, options = {}, onChunk) {
|
|
293
|
+
const uids = await this.search(`UID ${sinceUid + 1}:*`);
|
|
294
|
+
if (uids.length === 0)
|
|
295
|
+
return [];
|
|
296
|
+
uids.reverse(); // Newest first
|
|
297
|
+
console.log(` [fetch] ${uids.length} UIDs since ${sinceUid} (newest first)`);
|
|
298
|
+
if (uids.length <= NativeImapClient.INITIAL_CHUNK_SIZE) {
|
|
299
|
+
const msgs = await this.fetchMessages(uids.join(","), options);
|
|
300
|
+
if (onChunk)
|
|
301
|
+
onChunk(msgs);
|
|
302
|
+
return msgs;
|
|
303
|
+
}
|
|
304
|
+
const allMessages = [];
|
|
305
|
+
let chunkSize = NativeImapClient.INITIAL_CHUNK_SIZE;
|
|
306
|
+
for (let i = 0; i < uids.length; i += chunkSize) {
|
|
307
|
+
const chunk = uids.slice(i, i + chunkSize);
|
|
308
|
+
const msgs = await this.fetchMessages(chunk.join(","), options);
|
|
309
|
+
allMessages.push(...msgs);
|
|
310
|
+
console.log(` [fetch] ${allMessages.length}/${uids.length} (chunk of ${chunk.length})`);
|
|
311
|
+
if (onChunk)
|
|
312
|
+
onChunk(msgs);
|
|
313
|
+
if (chunkSize < NativeImapClient.MAX_CHUNK_SIZE)
|
|
314
|
+
chunkSize = Math.min(chunkSize * 4, NativeImapClient.MAX_CHUNK_SIZE);
|
|
315
|
+
}
|
|
316
|
+
return allMessages;
|
|
317
|
+
}
|
|
318
|
+
/** Fetch messages by date range. Optional onChunk callback receives each batch as it arrives. */
|
|
319
|
+
async fetchByDate(since, before, options = {}, onChunk) {
|
|
320
|
+
const criteria = proto.buildSearchCriteria({
|
|
321
|
+
since,
|
|
322
|
+
before: before || undefined,
|
|
323
|
+
});
|
|
324
|
+
const uids = await this.search(criteria);
|
|
325
|
+
if (uids.length === 0)
|
|
326
|
+
return [];
|
|
327
|
+
// Reverse so newest messages (highest UIDs) come first
|
|
328
|
+
uids.reverse();
|
|
329
|
+
console.log(` [fetch] ${uids.length} UIDs to fetch (newest first)`);
|
|
330
|
+
const allMessages = [];
|
|
331
|
+
let chunkSize = NativeImapClient.INITIAL_CHUNK_SIZE;
|
|
332
|
+
for (let i = 0; i < uids.length; i += chunkSize) {
|
|
333
|
+
const chunk = uids.slice(i, i + chunkSize);
|
|
334
|
+
const msgs = await this.fetchMessages(chunk.join(","), options);
|
|
335
|
+
allMessages.push(...msgs);
|
|
336
|
+
console.log(` [fetch] ${allMessages.length}/${uids.length} (chunk of ${chunk.length})`);
|
|
337
|
+
if (onChunk)
|
|
338
|
+
onChunk(msgs);
|
|
339
|
+
if (chunkSize < NativeImapClient.MAX_CHUNK_SIZE)
|
|
340
|
+
chunkSize = Math.min(chunkSize * 4, NativeImapClient.MAX_CHUNK_SIZE);
|
|
341
|
+
}
|
|
342
|
+
return allMessages;
|
|
343
|
+
}
|
|
344
|
+
/** Fetch a single message by UID */
|
|
345
|
+
async fetchMessage(uid, options = {}) {
|
|
346
|
+
const msgs = await this.fetchMessages(String(uid), options);
|
|
347
|
+
return msgs.find(m => m.uid === uid) || null;
|
|
348
|
+
}
|
|
349
|
+
/** Get all UIDs in the current mailbox */
|
|
350
|
+
async getUids() {
|
|
351
|
+
return this.search("ALL");
|
|
352
|
+
}
|
|
353
|
+
/** UID SEARCH */
|
|
354
|
+
async search(criteria) {
|
|
355
|
+
const tag = proto.nextTag();
|
|
356
|
+
const responses = await this.sendCommand(tag, proto.searchCommand(tag, criteria));
|
|
357
|
+
for (const r of responses) {
|
|
358
|
+
if (r.tag === "*" && r.type === "SEARCH") {
|
|
359
|
+
return proto.parseSearchResponse(r.text);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
/** Set flags on a message */
|
|
365
|
+
async addFlags(uid, flags) {
|
|
366
|
+
const tag = proto.nextTag();
|
|
367
|
+
const responses = await this.sendCommand(tag, proto.storeCommand(tag, uid, "+FLAGS.SILENT", flags));
|
|
368
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
369
|
+
if (!tagged || tagged.type !== "OK")
|
|
370
|
+
throw new Error(`STORE +FLAGS failed: ${tagged?.text || "unknown"}`);
|
|
371
|
+
}
|
|
372
|
+
/** Remove flags from a message */
|
|
373
|
+
async removeFlags(uid, flags) {
|
|
374
|
+
const tag = proto.nextTag();
|
|
375
|
+
const responses = await this.sendCommand(tag, proto.storeCommand(tag, uid, "-FLAGS.SILENT", flags));
|
|
376
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
377
|
+
if (!tagged || tagged.type !== "OK")
|
|
378
|
+
throw new Error(`STORE -FLAGS failed: ${tagged?.text || "unknown"}`);
|
|
379
|
+
}
|
|
380
|
+
/** Copy a message to another mailbox */
|
|
381
|
+
async copyMessage(uid, destination) {
|
|
382
|
+
const tag = proto.nextTag();
|
|
383
|
+
const responses = await this.sendCommand(tag, proto.copyCommand(tag, uid, destination));
|
|
384
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
385
|
+
if (!tagged || tagged.type !== "OK")
|
|
386
|
+
throw new Error(`COPY failed: ${tagged?.text || "unknown"}`);
|
|
387
|
+
}
|
|
388
|
+
/** Move a message to another mailbox (MOVE or COPY+DELETE) */
|
|
389
|
+
async moveMessage(uid, destination) {
|
|
390
|
+
if (this.capabilities.has("MOVE")) {
|
|
391
|
+
const tag = proto.nextTag();
|
|
392
|
+
const responses = await this.sendCommand(tag, proto.moveCommand(tag, uid, destination));
|
|
393
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
394
|
+
if (!tagged || tagged.type !== "OK")
|
|
395
|
+
throw new Error(`MOVE failed: ${tagged?.text || "unknown"}`);
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
await this.copyMessage(uid, destination);
|
|
399
|
+
await this.addFlags(uid, ["\\Deleted"]);
|
|
400
|
+
await this.expunge();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** Delete a message by UID (flag + expunge) */
|
|
404
|
+
async deleteMessage(uid) {
|
|
405
|
+
await this.addFlags(uid, ["\\Deleted"]);
|
|
406
|
+
await this.expunge();
|
|
407
|
+
}
|
|
408
|
+
/** Expunge deleted messages */
|
|
409
|
+
async expunge() {
|
|
410
|
+
const tag = proto.nextTag();
|
|
411
|
+
await this.sendCommand(tag, proto.buildCommand(tag, "EXPUNGE"));
|
|
412
|
+
}
|
|
413
|
+
/** Append a message to a mailbox */
|
|
414
|
+
async appendMessage(mailbox, message, flags = []) {
|
|
415
|
+
const data = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
416
|
+
const size = new TextEncoder().encode(data).length;
|
|
417
|
+
const tag = proto.nextTag();
|
|
418
|
+
const cmd = proto.appendCommand(tag, mailbox, flags, size);
|
|
419
|
+
// Send command, wait for continuation
|
|
420
|
+
await this.transport.write(cmd);
|
|
421
|
+
// Wait for "+" continuation
|
|
422
|
+
const contResp = await this.waitForContinuation(tag);
|
|
423
|
+
if (!contResp)
|
|
424
|
+
throw new Error("APPEND: server did not send continuation");
|
|
425
|
+
// Send the message data + CRLF
|
|
426
|
+
await this.transport.write(data + "\r\n");
|
|
427
|
+
// Wait for tagged response
|
|
428
|
+
const responses = await this.waitForTagged(tag);
|
|
429
|
+
const tagged = responses.find(r => r.tag === tag);
|
|
430
|
+
if (!tagged || tagged.type !== "OK")
|
|
431
|
+
throw new Error(`APPEND failed: ${tagged?.text || "unknown"}`);
|
|
432
|
+
// Try to extract APPENDUID
|
|
433
|
+
const uidMatch = tagged.text.match(/APPENDUID\s+\d+\s+(\d+)/i);
|
|
434
|
+
return uidMatch ? parseInt(uidMatch[1]) : null;
|
|
435
|
+
}
|
|
436
|
+
// ── IDLE ──
|
|
437
|
+
async startIdle(onNewMail) {
|
|
438
|
+
this.idleCallback = onNewMail;
|
|
439
|
+
const tag = proto.nextTag();
|
|
440
|
+
this.idleTag = tag;
|
|
441
|
+
await this.transport.write(proto.idleCommand(tag));
|
|
442
|
+
// Wait for "+" continuation
|
|
443
|
+
await this.waitForContinuation(tag);
|
|
444
|
+
return async () => {
|
|
445
|
+
this.idleTag = null;
|
|
446
|
+
this.idleCallback = null;
|
|
447
|
+
await this.transport.write(proto.doneCommand());
|
|
448
|
+
await this.waitForTagged(tag);
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
// ── Message count (lightweight STATUS) ──
|
|
452
|
+
async getMessageCount(mailbox) {
|
|
453
|
+
const status = await this.getStatus(mailbox);
|
|
454
|
+
return status.messages || 0;
|
|
455
|
+
}
|
|
456
|
+
// ── Low-level command handling ──
|
|
457
|
+
/** Inactivity timeout — how long to wait with NO data before declaring the connection dead.
|
|
458
|
+
* This is NOT a wall-clock timeout. Timer resets every time data arrives from the server.
|
|
459
|
+
* A large FETCH returning data continuously will never timeout. */
|
|
460
|
+
inactivityTimeout = 30000;
|
|
461
|
+
/** Fetch chunk sizes — start small for quick first paint, ramp up for throughput */
|
|
462
|
+
static INITIAL_CHUNK_SIZE = 25;
|
|
463
|
+
static MAX_CHUNK_SIZE = 500;
|
|
464
|
+
/** Active command timer — reset by handleData on every data arrival */
|
|
465
|
+
commandTimer = null;
|
|
466
|
+
sendCommand(tag, command) {
|
|
467
|
+
return new Promise((resolve, reject) => {
|
|
468
|
+
if (this.verbose && !command.includes("LOGIN") && !command.includes("AUTHENTICATE")) {
|
|
469
|
+
console.log(` [imap] > ${command.trimEnd()}`);
|
|
470
|
+
}
|
|
471
|
+
const onTimeout = () => {
|
|
472
|
+
this.commandTimer = null;
|
|
473
|
+
this.pendingCommand = null;
|
|
474
|
+
// Kill the connection — a timed-out connection has stale data in the pipe
|
|
475
|
+
this.transport.close?.();
|
|
476
|
+
reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s): ${command.split("\r")[0].substring(0, 80)}`));
|
|
477
|
+
};
|
|
478
|
+
this.commandTimer = setTimeout(onTimeout, this.inactivityTimeout);
|
|
479
|
+
this.pendingCommand = {
|
|
480
|
+
tag, responses: [],
|
|
481
|
+
resolve: (responses) => { if (this.commandTimer)
|
|
482
|
+
clearTimeout(this.commandTimer); this.commandTimer = null; resolve(responses); },
|
|
483
|
+
reject: (err) => { if (this.commandTimer)
|
|
484
|
+
clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); },
|
|
485
|
+
};
|
|
486
|
+
this.transport.write(command).catch((err) => { if (this.commandTimer)
|
|
487
|
+
clearTimeout(this.commandTimer); this.commandTimer = null; reject(err); });
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
waitForContinuation(tag) {
|
|
491
|
+
return new Promise((resolve) => {
|
|
492
|
+
const timeout = setTimeout(() => {
|
|
493
|
+
this.continuationResolve = null;
|
|
494
|
+
resolve(null);
|
|
495
|
+
}, 30000);
|
|
496
|
+
this.continuationResolve = (resp) => {
|
|
497
|
+
clearTimeout(timeout);
|
|
498
|
+
this.continuationResolve = null;
|
|
499
|
+
resolve(resp);
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
waitForTagged(tag) {
|
|
504
|
+
return new Promise((resolve, reject) => {
|
|
505
|
+
this.pendingCommand = { tag, resolve, reject, responses: [] };
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
handleData(data) {
|
|
509
|
+
// Reset inactivity timer — data is flowing, connection is alive
|
|
510
|
+
if (this.commandTimer) {
|
|
511
|
+
clearTimeout(this.commandTimer);
|
|
512
|
+
this.commandTimer = setTimeout(() => {
|
|
513
|
+
this.commandTimer = null;
|
|
514
|
+
if (this.pendingCommand) {
|
|
515
|
+
const cmd = this.pendingCommand;
|
|
516
|
+
this.pendingCommand = null;
|
|
517
|
+
this.transport.close?.();
|
|
518
|
+
cmd.reject(new Error(`IMAP inactivity timeout (${this.inactivityTimeout / 1000}s)`));
|
|
519
|
+
}
|
|
520
|
+
}, this.inactivityTimeout);
|
|
521
|
+
}
|
|
522
|
+
this.buffer += data;
|
|
523
|
+
this.processBuffer();
|
|
524
|
+
}
|
|
525
|
+
processBuffer() {
|
|
526
|
+
while (true) {
|
|
527
|
+
// Check for literal {size}\r\n — reading exact BYTE count of literal data
|
|
528
|
+
// CRITICAL: literalBytes is in octets (from IMAP {N}), but this.buffer is a
|
|
529
|
+
// JavaScript string where multi-byte UTF-8 characters count as 1 character.
|
|
530
|
+
// We must use byte-accurate extraction (TextEncoder for browser, Buffer for Node).
|
|
531
|
+
if (this.pendingCommand?.literalBytes != null) {
|
|
532
|
+
const neededBytes = this.pendingCommand.literalBytes;
|
|
533
|
+
const encoded = new TextEncoder().encode(this.buffer);
|
|
534
|
+
const bufferBytes = encoded.byteLength;
|
|
535
|
+
if (bufferBytes >= neededBytes) {
|
|
536
|
+
const literal_bytes = encoded.subarray(0, neededBytes);
|
|
537
|
+
const rest_bytes = encoded.subarray(neededBytes);
|
|
538
|
+
let literal = new TextDecoder().decode(literal_bytes);
|
|
539
|
+
this.buffer = new TextDecoder().decode(rest_bytes);
|
|
540
|
+
// For non-BODY literals (e.g. display names in ENVELOPE), wrap in quotes
|
|
541
|
+
// so tokenizeParenList treats them as a single token
|
|
542
|
+
if (!this.pendingCommand.currentLiteralKey) {
|
|
543
|
+
literal = `"${literal.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "")}"`;
|
|
544
|
+
}
|
|
545
|
+
this.pendingCommand.literalBuffer = (this.pendingCommand.literalBuffer || "") + literal;
|
|
546
|
+
this.pendingCommand.literalBytes = undefined;
|
|
547
|
+
// Store the literal data by its BODY key for parseFetchResponses
|
|
548
|
+
if (this.pendingCommand.currentLiteralKey) {
|
|
549
|
+
if (!this.pendingCommand.literals)
|
|
550
|
+
this.pendingCommand.literals = new Map();
|
|
551
|
+
this.pendingCommand.literals.set(this.pendingCommand.currentLiteralKey, literal);
|
|
552
|
+
this.pendingCommand.currentLiteralKey = undefined;
|
|
553
|
+
this.pendingCommand.currentLiteralSize = undefined;
|
|
554
|
+
}
|
|
555
|
+
if (this.verbose)
|
|
556
|
+
console.log(` [imap] literal consumed, ${literal.length} bytes, buffer remaining: ${this.buffer.length}`);
|
|
557
|
+
// After consuming literal, check if the NEXT part has another literal
|
|
558
|
+
// (e.g., BODY[HEADER] {500}\r\n<data>BODY[] {2000}\r\n<data>)\r\n)
|
|
559
|
+
// Continue to process the next line/literal from the buffer
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (this.verbose && this.pendingCommand.literalBytes > 0) {
|
|
563
|
+
console.log(` [imap] waiting for literal: need ${neededBytes} bytes, have ${bufferBytes} bytes`);
|
|
564
|
+
}
|
|
565
|
+
break; // Wait for more data
|
|
566
|
+
}
|
|
567
|
+
const lineEnd = this.buffer.indexOf("\r\n");
|
|
568
|
+
if (lineEnd < 0)
|
|
569
|
+
break;
|
|
570
|
+
const line = this.buffer.substring(0, lineEnd + 2);
|
|
571
|
+
this.buffer = this.buffer.substring(lineEnd + 2);
|
|
572
|
+
// Check for literal announcement {size}\r\n at end of line
|
|
573
|
+
const literalMatch = line.match(/\{(\d+)\}\r\n$/);
|
|
574
|
+
if (literalMatch && this.pendingCommand) {
|
|
575
|
+
const size = parseInt(literalMatch[1]);
|
|
576
|
+
// Store the line prefix before {size} — will be prepended after literal is consumed
|
|
577
|
+
const linePrefix = line.substring(0, literalMatch.index);
|
|
578
|
+
if (this.pendingCommand.literalBuffer) {
|
|
579
|
+
// Already have buffered data from a previous literal — append this prefix
|
|
580
|
+
this.pendingCommand.literalBuffer += linePrefix;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
this.pendingCommand.literalBuffer = linePrefix;
|
|
584
|
+
}
|
|
585
|
+
this.pendingCommand.literalBytes = size;
|
|
586
|
+
// Extract the BODY section key from the prefix (e.g. BODY[], BODY[HEADER], BODY.PEEK[])
|
|
587
|
+
const keyMatch = linePrefix.match(/BODY(?:\.PEEK)?\[([^\]]*)\]\s*$/i);
|
|
588
|
+
if (keyMatch) {
|
|
589
|
+
this.pendingCommand.currentLiteralKey = `BODY[${keyMatch[1]}]`;
|
|
590
|
+
this.pendingCommand.currentLiteralSize = size;
|
|
591
|
+
}
|
|
592
|
+
if (this.verbose)
|
|
593
|
+
console.log(` [imap] literal announced: ${size} bytes, prefix: ${linePrefix.length} chars, key: ${this.pendingCommand.currentLiteralKey || "none"}`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// If we have buffered literal data, prepend it to this line (the closing part after the literal)
|
|
597
|
+
let fullLine = line;
|
|
598
|
+
if (this.pendingCommand?.literalBuffer) {
|
|
599
|
+
fullLine = this.pendingCommand.literalBuffer + line;
|
|
600
|
+
this.pendingCommand.literalBuffer = undefined;
|
|
601
|
+
}
|
|
602
|
+
const resp = proto.parseResponseLine(fullLine);
|
|
603
|
+
// Attach accumulated literals to the response and reset for next response
|
|
604
|
+
if (this.pendingCommand?.literals?.size) {
|
|
605
|
+
resp.literals = this.pendingCommand.literals;
|
|
606
|
+
this.pendingCommand.literals = undefined;
|
|
607
|
+
}
|
|
608
|
+
if (this.verbose) {
|
|
609
|
+
const display = resp.raw.length > 200 ? resp.raw.substring(0, 200) + `... (${resp.raw.length} bytes)` : resp.raw;
|
|
610
|
+
console.log(` [imap] < [tag=${resp.tag} type=${resp.type}] ${display}`);
|
|
611
|
+
}
|
|
612
|
+
// Server greeting — resolve readGreeting() promise
|
|
613
|
+
if (this.greetingResolve && resp.tag === "*" && (resp.type === "OK" || resp.type === "PREAUTH")) {
|
|
614
|
+
const resolve = this.greetingResolve;
|
|
615
|
+
this.greetingResolve = null;
|
|
616
|
+
resolve(resp);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
// During IDLE, handle EXISTS notifications
|
|
620
|
+
if (this.idleTag && resp.tag === "*" && resp.type === "EXISTS") {
|
|
621
|
+
const count = parseInt(resp.text);
|
|
622
|
+
if (this.idleCallback && count > this.mailboxInfo.exists) {
|
|
623
|
+
const newCount = count - this.mailboxInfo.exists;
|
|
624
|
+
this.mailboxInfo.exists = count;
|
|
625
|
+
this.idleCallback(newCount);
|
|
626
|
+
}
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
// Collect untagged responses for the pending command
|
|
630
|
+
if (resp.tag === "*" && this.pendingCommand) {
|
|
631
|
+
this.pendingCommand.responses.push(resp);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
// Continuation response
|
|
635
|
+
if (resp.tag === "+") {
|
|
636
|
+
if (this.continuationResolve) {
|
|
637
|
+
// APPEND or other command waiting for continuation
|
|
638
|
+
this.continuationResolve(resp);
|
|
639
|
+
}
|
|
640
|
+
else if (!this.idleTag && this.pendingCommand) {
|
|
641
|
+
// Unexpected continuation (e.g. AUTHENTICATE challenge) — cancel
|
|
642
|
+
this.transport.write("\r\n").catch(() => { });
|
|
643
|
+
}
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
// Tagged response — command complete
|
|
647
|
+
if (this.pendingCommand && resp.tag === this.pendingCommand.tag) {
|
|
648
|
+
this.pendingCommand.responses.push(resp);
|
|
649
|
+
const { resolve, responses } = this.pendingCommand;
|
|
650
|
+
this.pendingCommand = null;
|
|
651
|
+
resolve(responses);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
// Unhandled
|
|
655
|
+
this.handleUntaggedResponse(resp);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
handleUntaggedResponse(resp) {
|
|
659
|
+
if (resp.type === "EXISTS") {
|
|
660
|
+
this.mailboxInfo.exists = parseInt(resp.text);
|
|
661
|
+
}
|
|
662
|
+
else if (resp.type === "EXPUNGE") {
|
|
663
|
+
this.mailboxInfo.exists = Math.max(0, this.mailboxInfo.exists - 1);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// ── FETCH Response Parser ──
|
|
667
|
+
parseFetchResponses(responses) {
|
|
668
|
+
const messages = [];
|
|
669
|
+
for (const r of responses) {
|
|
670
|
+
if (r.tag !== "*" || r.type !== "FETCH")
|
|
671
|
+
continue;
|
|
672
|
+
const msg = {
|
|
673
|
+
seq: 0, uid: 0, flags: new Set(), date: null,
|
|
674
|
+
subject: "", messageId: "", from: [], to: [], cc: [], bcc: [],
|
|
675
|
+
sender: [], replyTo: [], inReplyTo: "", size: 0,
|
|
676
|
+
source: "", headers: "", seen: false, flagged: false,
|
|
677
|
+
answered: false, draft: false,
|
|
678
|
+
};
|
|
679
|
+
// Extract sequence number from "5 FETCH (...)"
|
|
680
|
+
const seqMatch = r.text.match(/^(\d+)\s+FETCH/);
|
|
681
|
+
if (seqMatch)
|
|
682
|
+
msg.seq = parseInt(seqMatch[1]);
|
|
683
|
+
// Extract UID
|
|
684
|
+
const uidMatch = r.text.match(/UID\s+(\d+)/);
|
|
685
|
+
if (uidMatch)
|
|
686
|
+
msg.uid = parseInt(uidMatch[1]);
|
|
687
|
+
// Extract FLAGS
|
|
688
|
+
const flagsMatch = r.text.match(/FLAGS\s+\(([^)]*)\)/);
|
|
689
|
+
if (flagsMatch) {
|
|
690
|
+
msg.flags = new Set(flagsMatch[1].split(/\s+/).filter(Boolean));
|
|
691
|
+
msg.seen = msg.flags.has("\\Seen");
|
|
692
|
+
msg.flagged = msg.flags.has("\\Flagged");
|
|
693
|
+
msg.answered = msg.flags.has("\\Answered");
|
|
694
|
+
msg.draft = msg.flags.has("\\Draft");
|
|
695
|
+
}
|
|
696
|
+
// Extract RFC822.SIZE
|
|
697
|
+
const sizeMatch = r.text.match(/RFC822\.SIZE\s+(\d+)/);
|
|
698
|
+
if (sizeMatch)
|
|
699
|
+
msg.size = parseInt(sizeMatch[1]);
|
|
700
|
+
// Extract INTERNALDATE
|
|
701
|
+
const dateMatch = r.text.match(/INTERNALDATE\s+"([^"]+)"/);
|
|
702
|
+
if (dateMatch)
|
|
703
|
+
msg.date = new Date(dateMatch[1]);
|
|
704
|
+
// Extract ENVELOPE
|
|
705
|
+
const envMatch = r.text.match(/ENVELOPE\s+(\(.*\))/);
|
|
706
|
+
if (envMatch) {
|
|
707
|
+
const env = proto.parseEnvelope(envMatch[1]);
|
|
708
|
+
msg.date = msg.date || env.date;
|
|
709
|
+
msg.subject = env.subject;
|
|
710
|
+
msg.messageId = env.messageId;
|
|
711
|
+
msg.from = env.from;
|
|
712
|
+
msg.to = env.to;
|
|
713
|
+
msg.cc = env.cc;
|
|
714
|
+
msg.bcc = env.bcc;
|
|
715
|
+
msg.sender = env.sender;
|
|
716
|
+
msg.replyTo = env.replyTo;
|
|
717
|
+
msg.inReplyTo = env.inReplyTo;
|
|
718
|
+
}
|
|
719
|
+
// Extract body source and headers from literals tracked by processBuffer
|
|
720
|
+
if (r.literals) {
|
|
721
|
+
const source = r.literals.get("BODY[]");
|
|
722
|
+
if (source)
|
|
723
|
+
msg.source = source;
|
|
724
|
+
const headers = r.literals.get("BODY[HEADER]");
|
|
725
|
+
if (headers)
|
|
726
|
+
msg.headers = headers;
|
|
727
|
+
}
|
|
728
|
+
messages.push(msg);
|
|
729
|
+
}
|
|
730
|
+
return messages;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
//# sourceMappingURL=imap-native.js.map
|