@bobfrankston/mailx 1.0.244 → 1.0.251
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/bin/mailx.js +20 -4
- package/client/.msger-window.json +1 -1
- package/client/android.html +4 -0
- package/client/app.js +90 -22
- package/client/components/folder-tree.js +48 -0
- package/client/components/message-viewer.js +54 -16
- package/client/index.html +5 -3
- package/client/styles/components.css +37 -0
- package/client/styles/layout.css +19 -0
- package/package.json +19 -10
- package/packages/mailx-imap/index.d.ts +14 -7
- package/packages/mailx-imap/index.js +207 -87
- package/packages/mailx-imap/package.json +4 -3
- package/packages/mailx-imap/providers/gmail-api.js +24 -5
- package/packages/mailx-server/index.js +3 -0
- package/packages/mailx-store/db.js +4 -2
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +173 -3
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -0
- package/packages/mailx-store-web/gmail-api-web.js +12 -0
- package/packages/mailx-store-web/imap-web-provider.d.ts +9 -0
- package/packages/mailx-store-web/imap-web-provider.js +45 -8
- package/packages/mailx-store-web/package.json +4 -1
- package/packages/mailx-types/index.d.ts +7 -0
- package/test-smtp-direct.mjs +4 -0
|
@@ -17,6 +17,8 @@ import { WebMailxService } from "./web-service.js";
|
|
|
17
17
|
import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, getDeviceId, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
|
|
18
18
|
import { GmailApiWebProvider } from "./gmail-api-web.js";
|
|
19
19
|
import { ImapWebProvider } from "./imap-web-provider.js";
|
|
20
|
+
import { SmtpClient } from "@bobfrankston/smtp-direct";
|
|
21
|
+
import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
|
|
20
22
|
// ── State ──
|
|
21
23
|
let db;
|
|
22
24
|
let bodyStore;
|
|
@@ -121,7 +123,12 @@ class AndroidSyncManager {
|
|
|
121
123
|
return 1;
|
|
122
124
|
return 0;
|
|
123
125
|
});
|
|
124
|
-
|
|
126
|
+
// Sync every folder, not just the first five — the old slice(0, 5)
|
|
127
|
+
// meant subfolders past the cutoff (e.g. _spam, custom labels)
|
|
128
|
+
// never picked up moves made on other clients, and those moves
|
|
129
|
+
// also stayed visible in the source folder because reconcile
|
|
130
|
+
// (below in syncFolder) never ran for the target.
|
|
131
|
+
for (const folder of sorted) {
|
|
125
132
|
try {
|
|
126
133
|
await this.syncFolder(account.id, folder.id);
|
|
127
134
|
}
|
|
@@ -185,6 +192,48 @@ class AndroidSyncManager {
|
|
|
185
192
|
this.db.recalcFolderCounts(folderId);
|
|
186
193
|
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
187
194
|
}
|
|
195
|
+
// Reconcile deletions — messages present locally but no longer on the
|
|
196
|
+
// server (moved away, deleted on another client). Without this, the
|
|
197
|
+
// Android client never drops removed rows: e.g., moves to _spam from
|
|
198
|
+
// another client showed up in _spam (next time it synced) but never
|
|
199
|
+
// disappeared from INBOX.
|
|
200
|
+
//
|
|
201
|
+
// Same safety guards as the desktop reconcile path:
|
|
202
|
+
// - Skip if the server list is empty but local has messages (likely
|
|
203
|
+
// a transient API failure that returned []).
|
|
204
|
+
// - Refuse to delete more than 50% of local in one pass — better to
|
|
205
|
+
// keep phantoms than to wipe a folder on a sync bug. Rebuild local
|
|
206
|
+
// cache fixes a stuck state.
|
|
207
|
+
try {
|
|
208
|
+
const serverUidsArr = await provider.getUids(folder.path);
|
|
209
|
+
const serverUids = new Set(serverUidsArr);
|
|
210
|
+
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
211
|
+
if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
212
|
+
console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const toDelete = localUids.filter(uid => !serverUids.has(uid));
|
|
216
|
+
const RECONCILE_DELETE_THRESHOLD = 0.5;
|
|
217
|
+
if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
|
|
218
|
+
console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
for (const uid of toDelete) {
|
|
222
|
+
this.db.deleteMessage(accountId, uid);
|
|
223
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
224
|
+
}
|
|
225
|
+
if (toDelete.length > 0) {
|
|
226
|
+
console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
|
|
227
|
+
this.db.recalcFolderCounts(folderId);
|
|
228
|
+
emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
|
|
235
|
+
}
|
|
236
|
+
emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
|
|
188
237
|
emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
|
|
189
238
|
}
|
|
190
239
|
storeProviderMessages(accountId, folderId, messages) {
|
|
@@ -286,8 +335,122 @@ class AndroidSyncManager {
|
|
|
286
335
|
async undeleteMessage(accountId, uid, folderId) {
|
|
287
336
|
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
288
337
|
}
|
|
289
|
-
queueOutgoingLocal(accountId,
|
|
290
|
-
|
|
338
|
+
queueOutgoingLocal(accountId, rawMessage) {
|
|
339
|
+
// Two paths, both real (no stubs that pretend success — see programming.md
|
|
340
|
+
// rule "Stubs MUST NOT appear successful"):
|
|
341
|
+
// - Gmail accounts: POST to users.messages.send (Gmail handles SMTP +
|
|
342
|
+
// auto-files into Sent label).
|
|
343
|
+
// - Non-Gmail accounts: smtp-direct over BridgeTransport (mailxapi.tcp).
|
|
344
|
+
// Caller (web-service.send) is sync-returning; we kick off the network
|
|
345
|
+
// request and surface success/failure via events. Compose UI listens for
|
|
346
|
+
// sendError/sendComplete.
|
|
347
|
+
const provider = this.getProvider(accountId);
|
|
348
|
+
if (provider && typeof provider.sendRaw === "function") {
|
|
349
|
+
provider.sendRaw(rawMessage)
|
|
350
|
+
.then((result) => {
|
|
351
|
+
console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
|
|
352
|
+
emitEvent({ type: "sendComplete", accountId, messageId: result.id });
|
|
353
|
+
})
|
|
354
|
+
.catch((e) => {
|
|
355
|
+
console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
|
|
356
|
+
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
|
|
361
|
+
// stored account JSON.
|
|
362
|
+
const accounts = db.getAccountConfigs();
|
|
363
|
+
const row = accounts.find(a => a.id === accountId);
|
|
364
|
+
if (!row) {
|
|
365
|
+
const e = "Unknown account";
|
|
366
|
+
console.error(`[send] ${accountId}: ${e}`);
|
|
367
|
+
emitEvent({ type: "sendError", accountId, error: e });
|
|
368
|
+
throw new Error(e);
|
|
369
|
+
}
|
|
370
|
+
let account;
|
|
371
|
+
try {
|
|
372
|
+
account = JSON.parse(row.configJson);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
const e = "Account config malformed";
|
|
376
|
+
emitEvent({ type: "sendError", accountId, error: e });
|
|
377
|
+
throw new Error(e);
|
|
378
|
+
}
|
|
379
|
+
if (!account.smtp) {
|
|
380
|
+
const e = "No SMTP config for this account";
|
|
381
|
+
console.error(`[send] ${accountId}: ${e}`);
|
|
382
|
+
emitEvent({ type: "sendError", accountId, error: e });
|
|
383
|
+
throw new Error(e);
|
|
384
|
+
}
|
|
385
|
+
// Fire async — same pattern as Gmail path above.
|
|
386
|
+
this.sendViaSmtpDirect(accountId, account, rawMessage)
|
|
387
|
+
.then((result) => {
|
|
388
|
+
console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
|
|
389
|
+
emitEvent({ type: "sendComplete", accountId });
|
|
390
|
+
})
|
|
391
|
+
.catch((e) => {
|
|
392
|
+
console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
|
|
393
|
+
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
|
|
397
|
+
async sendViaSmtpDirect(accountId, account, raw) {
|
|
398
|
+
const SMTP_PORT_STARTTLS = 587;
|
|
399
|
+
const SMTP_PORT_IMPLICIT_TLS = 465;
|
|
400
|
+
const smtp = account.smtp;
|
|
401
|
+
const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
|
|
402
|
+
const smtpHost = smtp.host || account.imap?.host;
|
|
403
|
+
if (!smtpHost)
|
|
404
|
+
throw new Error("No SMTP host");
|
|
405
|
+
// Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
|
|
406
|
+
const smtpUser = smtp.user || account.imap?.user || account.email;
|
|
407
|
+
const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
|
|
408
|
+
let auth;
|
|
409
|
+
if (authType === "password") {
|
|
410
|
+
const pass = smtp.password || account.imap?.password;
|
|
411
|
+
if (!pass)
|
|
412
|
+
throw new Error("SMTP password not configured");
|
|
413
|
+
auth = { method: "PLAIN", user: smtpUser, pass };
|
|
414
|
+
}
|
|
415
|
+
else if (authType === "oauth2") {
|
|
416
|
+
const tp = this.tokenProviders.get(accountId);
|
|
417
|
+
if (!tp)
|
|
418
|
+
throw new Error("OAuth token provider not registered");
|
|
419
|
+
const token = await tp();
|
|
420
|
+
auth = { method: "XOAUTH2", user: smtpUser, token };
|
|
421
|
+
}
|
|
422
|
+
// Recipients from headers
|
|
423
|
+
const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
|
|
424
|
+
const toMatch = raw.match(/^To:\s*(.+)$/mi);
|
|
425
|
+
const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
|
|
426
|
+
const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
|
|
427
|
+
const fromMatch = raw.match(/^From:\s*(.+)$/mi);
|
|
428
|
+
const recipients = [
|
|
429
|
+
...(toMatch ? parseAddrs(toMatch[1]) : []),
|
|
430
|
+
...(ccMatch ? parseAddrs(ccMatch[1]) : []),
|
|
431
|
+
...(bccMatch ? parseAddrs(bccMatch[1]) : []),
|
|
432
|
+
];
|
|
433
|
+
const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
|
|
434
|
+
if (recipients.length === 0)
|
|
435
|
+
throw new Error("No recipients");
|
|
436
|
+
const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
|
|
437
|
+
const client = new SmtpClient({
|
|
438
|
+
host: smtpHost,
|
|
439
|
+
port: smtpPort,
|
|
440
|
+
secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
|
|
441
|
+
auth,
|
|
442
|
+
localname: "mailx-android",
|
|
443
|
+
}, () => new BridgeTcpTransport());
|
|
444
|
+
try {
|
|
445
|
+
await client.connect();
|
|
446
|
+
return await client.sendMail({ from: sender, to: recipients }, rawToSend);
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
try {
|
|
450
|
+
await client.quit();
|
|
451
|
+
}
|
|
452
|
+
catch { /* ignore */ }
|
|
453
|
+
}
|
|
291
454
|
}
|
|
292
455
|
async saveDraft(_accountId, _raw, _prevUid, _draftId) {
|
|
293
456
|
return null;
|
|
@@ -618,6 +781,13 @@ export async function initAndroid() {
|
|
|
618
781
|
setTimeout(() => {
|
|
619
782
|
syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
|
|
620
783
|
}, 1000);
|
|
784
|
+
// Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
|
|
785
|
+
const SYNC_INTERVAL_MS = 2 * 60 * 1000;
|
|
786
|
+
setInterval(() => {
|
|
787
|
+
console.log("[sync] periodic poll");
|
|
788
|
+
vlog("periodic sync poll");
|
|
789
|
+
syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
|
|
790
|
+
}, SYNC_INTERVAL_MS);
|
|
621
791
|
console.log("[android] Initialization complete");
|
|
622
792
|
emitEvent({ type: "connected" });
|
|
623
793
|
}
|
|
@@ -24,6 +24,13 @@ export declare class GmailApiWebProvider implements MailProvider {
|
|
|
24
24
|
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
25
25
|
getUids(folder: string): Promise<number[]>;
|
|
26
26
|
close(): Promise<void>;
|
|
27
|
+
/** Send an RFC 2822 message via Gmail API users.messages.send. The server
|
|
28
|
+
* handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
|
|
29
|
+
* a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
|
|
30
|
+
sendRaw(rawRfc822: string): Promise<{
|
|
31
|
+
id: string;
|
|
32
|
+
threadId: string;
|
|
33
|
+
}>;
|
|
27
34
|
private folderToLabel;
|
|
28
35
|
private formatDate;
|
|
29
36
|
}
|
|
@@ -227,6 +227,18 @@ export class GmailApiWebProvider {
|
|
|
227
227
|
return ids.map(idToUid);
|
|
228
228
|
}
|
|
229
229
|
async close() { }
|
|
230
|
+
/** Send an RFC 2822 message via Gmail API users.messages.send. The server
|
|
231
|
+
* handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
|
|
232
|
+
* a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
|
|
233
|
+
async sendRaw(rawRfc822) {
|
|
234
|
+
const b64 = btoa(unescape(encodeURIComponent(rawRfc822)))
|
|
235
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
236
|
+
const data = await this.apiFetch("/messages/send", {
|
|
237
|
+
method: "POST",
|
|
238
|
+
body: JSON.stringify({ raw: b64 }),
|
|
239
|
+
});
|
|
240
|
+
return { id: data.id, threadId: data.threadId };
|
|
241
|
+
}
|
|
230
242
|
folderToLabel(path) {
|
|
231
243
|
const lower = path.toLowerCase();
|
|
232
244
|
if (lower === "inbox")
|
|
@@ -5,14 +5,23 @@
|
|
|
5
5
|
* The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it
|
|
6
6
|
* to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects
|
|
7
7
|
* that global name.
|
|
8
|
+
*
|
|
9
|
+
* Includes automatic retry on broken pipe / connection errors: if an operation fails
|
|
10
|
+
* with a connection-related error, we create a fresh client and retry once.
|
|
8
11
|
*/
|
|
9
12
|
import { type ImapClientConfig } from "@bobfrankston/iflow-direct";
|
|
10
13
|
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./provider-types.js";
|
|
11
14
|
export declare class ImapWebProvider implements MailProvider {
|
|
12
15
|
private client;
|
|
16
|
+
private config;
|
|
17
|
+
private transportFactory;
|
|
13
18
|
private specialFolders;
|
|
14
19
|
private folderListCache;
|
|
15
20
|
constructor(config: ImapClientConfig);
|
|
21
|
+
/** Create a fresh client (after broken pipe / connection error) */
|
|
22
|
+
private reconnect;
|
|
23
|
+
/** Run an operation with one retry on connection error */
|
|
24
|
+
private withRetry;
|
|
16
25
|
listFolders(): Promise<ProviderFolder[]>;
|
|
17
26
|
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
18
27
|
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it
|
|
6
6
|
* to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects
|
|
7
7
|
* that global name.
|
|
8
|
+
*
|
|
9
|
+
* Includes automatic retry on broken pipe / connection errors: if an operation fails
|
|
10
|
+
* with a connection-related error, we create a fresh client and retry once.
|
|
8
11
|
*/
|
|
9
12
|
import { CompatImapClient, BridgeTransport } from "@bobfrankston/iflow-direct";
|
|
10
13
|
/**
|
|
@@ -54,16 +57,50 @@ function toProviderMessage(m) {
|
|
|
54
57
|
source: m.source || "",
|
|
55
58
|
};
|
|
56
59
|
}
|
|
60
|
+
/** Check if an error is a connection/broken-pipe error worth retrying */
|
|
61
|
+
function isConnectionError(e) {
|
|
62
|
+
const msg = (e?.message || "").toLowerCase();
|
|
63
|
+
return msg.includes("broken pipe") || msg.includes("not connected") ||
|
|
64
|
+
msg.includes("connection") || msg.includes("socket") ||
|
|
65
|
+
msg.includes("timeout") || msg.includes("econnreset") ||
|
|
66
|
+
msg.includes("epipe") || msg.includes("closed");
|
|
67
|
+
}
|
|
57
68
|
export class ImapWebProvider {
|
|
58
69
|
client;
|
|
70
|
+
config;
|
|
71
|
+
transportFactory;
|
|
59
72
|
specialFolders = {};
|
|
60
73
|
folderListCache = null;
|
|
61
74
|
constructor(config) {
|
|
62
|
-
|
|
63
|
-
this.
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.transportFactory = () => new BridgeTransport();
|
|
77
|
+
this.client = new CompatImapClient(config, this.transportFactory);
|
|
78
|
+
}
|
|
79
|
+
/** Create a fresh client (after broken pipe / connection error) */
|
|
80
|
+
reconnect() {
|
|
81
|
+
console.log("[imap-web] reconnecting after connection error");
|
|
82
|
+
try {
|
|
83
|
+
this.client.logout();
|
|
84
|
+
}
|
|
85
|
+
catch { /* ignore */ }
|
|
86
|
+
this.client = new CompatImapClient(this.config, this.transportFactory);
|
|
87
|
+
}
|
|
88
|
+
/** Run an operation with one retry on connection error */
|
|
89
|
+
async withRetry(op, label) {
|
|
90
|
+
try {
|
|
91
|
+
return await op();
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (isConnectionError(e)) {
|
|
95
|
+
console.warn(`[imap-web] ${label}: ${e.message} — reconnecting and retrying`);
|
|
96
|
+
this.reconnect();
|
|
97
|
+
return await op();
|
|
98
|
+
}
|
|
99
|
+
throw e;
|
|
100
|
+
}
|
|
64
101
|
}
|
|
65
102
|
async listFolders() {
|
|
66
|
-
const native = await this.client.getFolderList();
|
|
103
|
+
const native = await this.withRetry(() => this.client.getFolderList(), "listFolders");
|
|
67
104
|
const special = this.client.getSpecialFolders(native);
|
|
68
105
|
this.specialFolders = special;
|
|
69
106
|
const result = native.map(f => toProviderFolder(f, this.specialFolders));
|
|
@@ -71,27 +108,27 @@ export class ImapWebProvider {
|
|
|
71
108
|
return result;
|
|
72
109
|
}
|
|
73
110
|
async fetchSince(folder, sinceUid, options) {
|
|
74
|
-
const msgs = await this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source });
|
|
111
|
+
const msgs = await this.withRetry(() => this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source }), `fetchSince(${folder})`);
|
|
75
112
|
return msgs.map(toProviderMessage);
|
|
76
113
|
}
|
|
77
114
|
async fetchByDate(folder, since, before, options, onChunk) {
|
|
78
115
|
const wrappedChunk = onChunk ? (raw) => onChunk(raw.map(toProviderMessage)) : undefined;
|
|
79
|
-
const msgs = await this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk);
|
|
116
|
+
const msgs = await this.withRetry(() => this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk), `fetchByDate(${folder})`);
|
|
80
117
|
return msgs.map(toProviderMessage);
|
|
81
118
|
}
|
|
82
119
|
async fetchByUids(folder, uids, options) {
|
|
83
120
|
if (!uids.length)
|
|
84
121
|
return [];
|
|
85
122
|
const range = uids.join(",");
|
|
86
|
-
const msgs = await this.client.fetchMessages(folder, range, { source: !!options?.source });
|
|
123
|
+
const msgs = await this.withRetry(() => this.client.fetchMessages(folder, range, { source: !!options?.source }), `fetchByUids(${folder})`);
|
|
87
124
|
return msgs.map(toProviderMessage);
|
|
88
125
|
}
|
|
89
126
|
async fetchOne(folder, uid, options) {
|
|
90
|
-
const msg = await this.client.fetchMessageByUid(folder, uid, { source: !!options?.source });
|
|
127
|
+
const msg = await this.withRetry(() => this.client.fetchMessageByUid(folder, uid, { source: !!options?.source }), `fetchOne(${folder}/${uid})`);
|
|
91
128
|
return msg ? toProviderMessage(msg) : null;
|
|
92
129
|
}
|
|
93
130
|
async getUids(folder) {
|
|
94
|
-
return this.client.getUids(folder);
|
|
131
|
+
return this.withRetry(() => this.client.getUids(folder), `getUids(${folder})`);
|
|
95
132
|
}
|
|
96
133
|
async close() {
|
|
97
134
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "file:../mailx-types",
|
|
13
|
+
"@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
|
|
14
|
+
"@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
|
|
15
|
+
"@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
|
|
13
16
|
"sql.js": "^1.14.1"
|
|
14
17
|
},
|
|
15
18
|
"repository": {
|
|
@@ -159,6 +159,13 @@ export type WsEvent = {
|
|
|
159
159
|
total: number;
|
|
160
160
|
unread: number;
|
|
161
161
|
}>;
|
|
162
|
+
} | {
|
|
163
|
+
type: "folderSynced";
|
|
164
|
+
accountId: string;
|
|
165
|
+
entries: {
|
|
166
|
+
folderId: number;
|
|
167
|
+
syncedAt: number;
|
|
168
|
+
}[];
|
|
162
169
|
} | {
|
|
163
170
|
type: "syncProgress";
|
|
164
171
|
accountId: string;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Removed after one-shot smtp-direct test on 2026-04-13.
|
|
2
|
+
// Original sent a test message to test1@bob.ma via iecc submission.
|
|
3
|
+
// Result: 250 Accepted message qp 15437 (server queued for delivery).
|
|
4
|
+
// File overwritten because it had a plaintext password; safe to delete.
|