@bobfrankston/mailx 1.0.236 → 1.0.238
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/client/android.html +2 -0
- package/client/compose/compose.html +3 -3
- package/client/compose/compose.js +77 -65
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +5 -1
- package/packages/mailx-imap/index.js +42 -12
- package/packages/mailx-store-web/android-bootstrap.js +29 -1
- package/packages/mailx-store-web/imap-web-provider.d.ts +24 -0
- package/packages/mailx-store-web/imap-web-provider.js +103 -0
package/client/android.html
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"@bobfrankston/mailx-store-web": "../packages/mailx-store-web/index.js",
|
|
16
16
|
"@bobfrankston/mailx-store-web/": "../packages/mailx-store-web/",
|
|
17
17
|
"@bobfrankston/mailx-types": "../packages/mailx-types/index.js",
|
|
18
|
+
"@bobfrankston/iflow-direct": "../node_modules/@bobfrankston/iflow-direct/index.js",
|
|
19
|
+
"@bobfrankston/iflow-direct/": "../node_modules/@bobfrankston/iflow-direct/",
|
|
18
20
|
"sql.js": "../packages/mailx-store-web/sql-wasm-esm.js"
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
<body>
|
|
14
14
|
<div class="compose-header">
|
|
15
15
|
<div class="compose-field compose-from-field">
|
|
16
|
-
<label for="compose-from-
|
|
17
|
-
<
|
|
18
|
-
<
|
|
16
|
+
<label for="compose-from-input">From</label>
|
|
17
|
+
<input type="text" id="compose-from-input" list="compose-from-options" autocomplete="off" spellcheck="false">
|
|
18
|
+
<datalist id="compose-from-options"></datalist>
|
|
19
19
|
</div>
|
|
20
20
|
<div class="compose-field">
|
|
21
21
|
<label for="compose-to">To</label>
|
|
@@ -59,12 +59,16 @@ const container = document.getElementById("compose-editor");
|
|
|
59
59
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
60
60
|
const editor = await createEditor(container, editorType);
|
|
61
61
|
// ── Populate from init data ──
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// From field is a free-text input with a <datalist> of known accounts. The
|
|
63
|
+
// user can pick a preset or type an arbitrary "Name <addr@domain>" — no
|
|
64
|
+
// separate "Other..." escape hatch, no hidden custom input toggle.
|
|
65
|
+
const fromInput = document.getElementById("compose-from-input");
|
|
66
|
+
const fromOptions = document.getElementById("compose-from-options");
|
|
64
67
|
const toInput = document.getElementById("compose-to");
|
|
65
68
|
const ccInput = document.getElementById("compose-cc");
|
|
66
69
|
const bccInput = document.getElementById("compose-bcc");
|
|
67
70
|
const subjectInput = document.getElementById("compose-subject");
|
|
71
|
+
let knownAccounts = [];
|
|
68
72
|
// ── AI ghost text autocomplete ──
|
|
69
73
|
if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !== "off") {
|
|
70
74
|
import("./ghost-text.js").then(({ initGhostText }) => {
|
|
@@ -74,53 +78,65 @@ if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !==
|
|
|
74
78
|
}, { debounceMs: appSettings.autocomplete.debounceMs || 600 });
|
|
75
79
|
}).catch(() => { });
|
|
76
80
|
}
|
|
77
|
-
/**
|
|
78
|
-
function
|
|
79
|
-
|
|
81
|
+
/** Format an account for the From field: "Name <email>". */
|
|
82
|
+
function formatAccountFrom(acct) {
|
|
83
|
+
return `${acct.name} <${acct.email}>`;
|
|
84
|
+
}
|
|
85
|
+
/** Populate the From <datalist> with one entry per known account and set
|
|
86
|
+
* the input's current value to the selected account (or the first/default
|
|
87
|
+
* account when no selection is given). */
|
|
88
|
+
function populateFromOptions(accounts, selectedId) {
|
|
89
|
+
knownAccounts = accounts;
|
|
90
|
+
fromOptions.innerHTML = "";
|
|
80
91
|
for (const acct of accounts) {
|
|
81
92
|
const opt = document.createElement("option");
|
|
82
|
-
opt.value = acct
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
opt.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
other.value = "__custom__";
|
|
96
|
-
other.textContent = "Other...";
|
|
97
|
-
fromSelect.appendChild(other);
|
|
98
|
-
}
|
|
99
|
-
fromSelect.addEventListener("change", () => {
|
|
100
|
-
if (fromSelect.value === "__custom__") {
|
|
101
|
-
fromCustom.hidden = false;
|
|
102
|
-
fromCustom.focus();
|
|
93
|
+
opt.value = formatAccountFrom(acct);
|
|
94
|
+
// datalist options can carry a label so the dropdown row shows the
|
|
95
|
+
// friendly account tag ("gmail", "bob.ma") next to the address.
|
|
96
|
+
const tag = acct.label || acct.name;
|
|
97
|
+
opt.label = tag;
|
|
98
|
+
fromOptions.appendChild(opt);
|
|
99
|
+
}
|
|
100
|
+
if (!fromInput.value) {
|
|
101
|
+
const selected = (selectedId && accounts.find(a => a.id === selectedId)) ||
|
|
102
|
+
accounts.find(a => a.defaultSend) ||
|
|
103
|
+
accounts[0];
|
|
104
|
+
if (selected)
|
|
105
|
+
fromInput.value = formatAccountFrom(selected);
|
|
103
106
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
}
|
|
108
|
+
/** Parse the current From input into { name, address } for header building. */
|
|
109
|
+
function parseFromInput() {
|
|
110
|
+
const raw = fromInput.value.trim();
|
|
111
|
+
const match = raw.match(/^(.+?)\s*<(.+?)>$/);
|
|
112
|
+
if (match)
|
|
113
|
+
return { name: match[1].trim(), address: match[2].trim() };
|
|
114
|
+
return { name: "", address: raw };
|
|
115
|
+
}
|
|
116
|
+
/** Match the From input's address against the known accounts table and
|
|
117
|
+
* return that account's id. Used by send() / saveDraft() to decide which
|
|
118
|
+
* account to send through. Falls back to defaultSend, then first account. */
|
|
110
119
|
function getFromAccountId() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
const { address } = parseFromInput();
|
|
121
|
+
const lower = address.toLowerCase();
|
|
122
|
+
// Exact match wins
|
|
123
|
+
const exact = knownAccounts.find(a => a.email.toLowerCase() === lower);
|
|
124
|
+
if (exact)
|
|
125
|
+
return exact.id;
|
|
126
|
+
// Same-domain match — handles +tag aliases and identity addresses
|
|
127
|
+
const domain = lower.split("@")[1] || "";
|
|
128
|
+
if (domain) {
|
|
129
|
+
const sameDomain = knownAccounts.find(a => a.email.toLowerCase().endsWith("@" + domain));
|
|
130
|
+
if (sameDomain)
|
|
131
|
+
return sameDomain.id;
|
|
132
|
+
}
|
|
133
|
+
// Give up — use default send account or the first account
|
|
134
|
+
const def = knownAccounts.find(a => a.defaultSend) || knownAccounts[0];
|
|
135
|
+
return def?.id || "";
|
|
117
136
|
}
|
|
118
|
-
/** Get the From
|
|
137
|
+
/** Get the raw From header string ("Name <addr>"). */
|
|
119
138
|
function getFromAddress() {
|
|
120
|
-
|
|
121
|
-
return fromCustom.value;
|
|
122
|
-
const opt = fromSelect.selectedOptions[0];
|
|
123
|
-
return opt ? `${opt.dataset.name} <${opt.dataset.email}>` : "";
|
|
139
|
+
return fromInput.value.trim();
|
|
124
140
|
}
|
|
125
141
|
/** Smart tab — skip to next empty field, ending at body */
|
|
126
142
|
function smartTab(current) {
|
|
@@ -245,32 +261,28 @@ function formatAddrs(addrs) {
|
|
|
245
261
|
function parseAddrs(s) {
|
|
246
262
|
if (!s.trim())
|
|
247
263
|
return [];
|
|
248
|
-
|
|
249
|
-
|
|
264
|
+
// Split on commas and drop empty segments. This handles trailing commas
|
|
265
|
+
// ("foo@x.com,") and stray whitespace ("foo@x.com, ,bar@y.com") without
|
|
266
|
+
// producing phantom empty addresses that fail validation on send.
|
|
267
|
+
return s.split(",")
|
|
268
|
+
.map(p => p.trim())
|
|
269
|
+
.filter(p => p.length > 0)
|
|
270
|
+
.map(part => {
|
|
271
|
+
const match = part.match(/^(.+?)\s*<(.+?)>$/);
|
|
250
272
|
if (match)
|
|
251
273
|
return { name: match[1].trim(), address: match[2].trim() };
|
|
252
|
-
return { name: "", address: part
|
|
274
|
+
return { name: "", address: part };
|
|
253
275
|
});
|
|
254
276
|
}
|
|
255
277
|
function applyInit(init) {
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
idOpt.textContent = `${displayName} <${replyAddr}>`;
|
|
265
|
-
idOpt.dataset.email = replyAddr;
|
|
266
|
-
idOpt.dataset.name = displayName;
|
|
267
|
-
idOpt.selected = true;
|
|
268
|
-
// Populate rest of dropdown, then prepend identity option
|
|
269
|
-
populateFromSelect(init.accounts, "");
|
|
270
|
-
fromSelect.prepend(idOpt);
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
populateFromSelect(init.accounts, init.accountId);
|
|
278
|
+
// Populate the From datalist with known accounts
|
|
279
|
+
populateFromOptions(init.accounts, init.accountId);
|
|
280
|
+
// If the reply has a specific identity address (alias / +tag), set it
|
|
281
|
+
// as the From value directly — overrides the account default.
|
|
282
|
+
if (init.fromAddress) {
|
|
283
|
+
const account = init.accounts.find(a => a.id === init.accountId);
|
|
284
|
+
const displayName = account?.name || "";
|
|
285
|
+
fromInput.value = displayName ? `${displayName} <${init.fromAddress}>` : init.fromAddress;
|
|
274
286
|
}
|
|
275
287
|
toInput.value = formatAddrs(init.to);
|
|
276
288
|
ccInput.value = formatAddrs(init.cc);
|
|
@@ -390,7 +402,7 @@ function scheduleDraftSave() {
|
|
|
390
402
|
applyInit(init);
|
|
391
403
|
}
|
|
392
404
|
else {
|
|
393
|
-
|
|
405
|
+
populateFromOptions(accounts);
|
|
394
406
|
toInput.focus();
|
|
395
407
|
}
|
|
396
408
|
// Wire debounced saves to input events — checkpoint ~1.5s after the last
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.238",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.300",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.300",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -137,7 +137,11 @@ export declare class ImapManager extends EventEmitter {
|
|
|
137
137
|
* (deleted from another device, for example). The caller uses that to
|
|
138
138
|
* delete the stale row locally instead of showing a generic error. */
|
|
139
139
|
private fetchMessageBodyViaApi;
|
|
140
|
-
/** Background body prefetch — download bodies for messages that don't have them
|
|
140
|
+
/** Background body prefetch — download bodies for messages that don't have them.
|
|
141
|
+
* Server-side deletions (isNotFound) aren't errors here: we delete the
|
|
142
|
+
* stale row locally and keep going. Only unrelated errors (network,
|
|
143
|
+
* auth, rate limits) count against the error budget, and the budget is
|
|
144
|
+
* generous so a few transient failures don't kill the whole run. */
|
|
141
145
|
private prefetchBodies;
|
|
142
146
|
/** Get the body store for direct access */
|
|
143
147
|
getBodyStore(): FileMessageStore;
|
|
@@ -1402,34 +1402,60 @@ export class ImapManager extends EventEmitter {
|
|
|
1402
1402
|
return null;
|
|
1403
1403
|
}
|
|
1404
1404
|
}
|
|
1405
|
-
/** Background body prefetch — download bodies for messages that don't have them
|
|
1405
|
+
/** Background body prefetch — download bodies for messages that don't have them.
|
|
1406
|
+
* Server-side deletions (isNotFound) aren't errors here: we delete the
|
|
1407
|
+
* stale row locally and keep going. Only unrelated errors (network,
|
|
1408
|
+
* auth, rate limits) count against the error budget, and the budget is
|
|
1409
|
+
* generous so a few transient failures don't kill the whole run. */
|
|
1406
1410
|
async prefetchBodies(accountId) {
|
|
1407
|
-
// Fetch ALL missing bodies in one pass — don't wait for next sync cycle
|
|
1408
1411
|
let totalFetched = 0;
|
|
1412
|
+
let deleted = 0;
|
|
1409
1413
|
let errors = 0;
|
|
1414
|
+
const ERROR_BUDGET = 20;
|
|
1410
1415
|
while (true) {
|
|
1411
1416
|
const missing = this.db.getMessagesWithoutBody(accountId, 100);
|
|
1412
1417
|
if (missing.length === 0)
|
|
1413
1418
|
break;
|
|
1414
|
-
if (totalFetched === 0)
|
|
1419
|
+
if (totalFetched === 0 && deleted === 0)
|
|
1415
1420
|
console.log(` [prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
|
|
1421
|
+
let madeProgress = false;
|
|
1416
1422
|
for (const msg of missing) {
|
|
1417
1423
|
try {
|
|
1418
1424
|
const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
|
|
1419
|
-
if (result)
|
|
1425
|
+
if (result) {
|
|
1420
1426
|
totalFetched++;
|
|
1427
|
+
madeProgress = true;
|
|
1428
|
+
}
|
|
1421
1429
|
}
|
|
1422
1430
|
catch (e) {
|
|
1431
|
+
if (e?.isNotFound) {
|
|
1432
|
+
// Message deleted on the server — drop the stale row so
|
|
1433
|
+
// we stop re-asking. This also moves the loop forward
|
|
1434
|
+
// (next getMessagesWithoutBody call won't return it).
|
|
1435
|
+
try {
|
|
1436
|
+
this.db.deleteMessage(accountId, msg.uid);
|
|
1437
|
+
this.bodyStore.deleteMessage(accountId, msg.folderId, msg.uid).catch(() => { });
|
|
1438
|
+
deleted++;
|
|
1439
|
+
madeProgress = true;
|
|
1440
|
+
}
|
|
1441
|
+
catch { /* ignore */ }
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1423
1444
|
errors++;
|
|
1424
|
-
if (errors >=
|
|
1425
|
-
console.error(` [prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
|
|
1445
|
+
if (errors >= ERROR_BUDGET) {
|
|
1446
|
+
console.error(` [prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached, ${deleted} pruned)`);
|
|
1426
1447
|
return;
|
|
1427
1448
|
}
|
|
1428
1449
|
}
|
|
1429
1450
|
}
|
|
1451
|
+
// Safety: if we made zero progress this iteration, bail — otherwise
|
|
1452
|
+
// we'd loop forever on rows that keep failing without isNotFound.
|
|
1453
|
+
if (!madeProgress)
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
if (totalFetched > 0 || deleted > 0) {
|
|
1457
|
+
console.log(` [prefetch] ${accountId}: ${totalFetched} bodies cached, ${deleted} stale rows pruned (done)`);
|
|
1430
1458
|
}
|
|
1431
|
-
if (totalFetched > 0)
|
|
1432
|
-
console.log(` [prefetch] ${accountId}: ${totalFetched} bodies cached (done)`);
|
|
1433
1459
|
}
|
|
1434
1460
|
/** Get the body store for direct access */
|
|
1435
1461
|
getBodyStore() {
|
|
@@ -2154,12 +2180,16 @@ export class ImapManager extends EventEmitter {
|
|
|
2154
2180
|
this.outboxBackoffDelay.delete(accountId);
|
|
2155
2181
|
}
|
|
2156
2182
|
catch (e) {
|
|
2157
|
-
// Stale-socket errors (Dovecot silently drops idle connections
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2183
|
+
// Stale-socket errors (Dovecot silently drops idle connections,
|
|
2184
|
+
// or the sync path timed out and destroyed the socket): force a
|
|
2185
|
+
// fresh ops client so the next tick doesn't keep hitting the same
|
|
2186
|
+
// dead socket. Without reconnectOps, the dead client stays in the
|
|
2187
|
+
// opsClients map and every subsequent processOutbox call fails
|
|
2188
|
+
// immediately with "Not connected" — forever.
|
|
2160
2189
|
const msg = String(e?.message || e);
|
|
2161
2190
|
if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
|
|
2162
|
-
|
|
2191
|
+
this.reconnectOps(accountId).catch(() => { });
|
|
2192
|
+
console.error(` [outbox] Stale connection for ${accountId}: ${msg} — reconnecting`);
|
|
2163
2193
|
}
|
|
2164
2194
|
else {
|
|
2165
2195
|
// Exponential backoff: 60s → 120s → 300s (max 5min)
|
|
@@ -16,6 +16,7 @@ import { WebMessageStore } from "./web-message-store.js";
|
|
|
16
16
|
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
|
+
import { ImapWebProvider } from "./imap-web-provider.js";
|
|
19
20
|
// ── State ──
|
|
20
21
|
let db;
|
|
21
22
|
let bodyStore;
|
|
@@ -70,8 +71,29 @@ class AndroidSyncManager {
|
|
|
70
71
|
console.warn(`[sync] ${account.id}: no token provider`);
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
else if (account.imap?.host && account.imap?.user) {
|
|
75
|
+
// Generic IMAP account — use BridgeTransport through MAUI's TCP bridge
|
|
76
|
+
try {
|
|
77
|
+
const provider = new ImapWebProvider({
|
|
78
|
+
server: account.imap.host,
|
|
79
|
+
port: account.imap.port || 993,
|
|
80
|
+
username: account.imap.user,
|
|
81
|
+
password: account.imap.password,
|
|
82
|
+
inactivityTimeout: 300000, // 300s for slow Dovecot
|
|
83
|
+
fetchChunkSize: 10,
|
|
84
|
+
fetchChunkSizeMax: 100,
|
|
85
|
+
});
|
|
86
|
+
this.providers.set(account.id, provider);
|
|
87
|
+
vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`);
|
|
88
|
+
console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`);
|
|
92
|
+
console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
73
95
|
else {
|
|
74
|
-
vlog(`addAccount ${account.id}:
|
|
96
|
+
vlog(`addAccount ${account.id}: no imap config, skipping`);
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
99
|
setTokenProvider(accountId, provider) {
|
|
@@ -508,6 +530,12 @@ export async function initAndroid() {
|
|
|
508
530
|
// Wait for C# to inject the native bridge (TCP/FS/HTTP + OAuth)
|
|
509
531
|
await waitForNativeBridge();
|
|
510
532
|
console.log(`[android] Native bridge: ${window._nativeBridge ? "ready" : "timeout"}`);
|
|
533
|
+
// iflow-direct's BridgeTransport expects a global `msgapi` with a .tcp
|
|
534
|
+
// subobject. Our MAUI shell exposes the same API under `_nativeBridge`, so
|
|
535
|
+
// alias it. Must happen before any IMAP client is constructed.
|
|
536
|
+
if (window._nativeBridge && !window.msgapi) {
|
|
537
|
+
window.msgapi = window._nativeBridge;
|
|
538
|
+
}
|
|
511
539
|
db = new WebMailxDB("mailx");
|
|
512
540
|
await db.waitReady();
|
|
513
541
|
bodyStore = new WebMessageStore();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMAP web provider — implements MailProvider using CompatImapClient + BridgeTransport.
|
|
3
|
+
* Used for non-Gmail accounts on Android/WebView where Node.js isn't available.
|
|
4
|
+
*
|
|
5
|
+
* The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it
|
|
6
|
+
* to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects
|
|
7
|
+
* that global name.
|
|
8
|
+
*/
|
|
9
|
+
import { type ImapClientConfig } from "@bobfrankston/iflow-direct";
|
|
10
|
+
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./provider-types.js";
|
|
11
|
+
export declare class ImapWebProvider implements MailProvider {
|
|
12
|
+
private client;
|
|
13
|
+
private specialFolders;
|
|
14
|
+
private folderListCache;
|
|
15
|
+
constructor(config: ImapClientConfig);
|
|
16
|
+
listFolders(): Promise<ProviderFolder[]>;
|
|
17
|
+
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
18
|
+
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
19
|
+
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
20
|
+
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
21
|
+
getUids(folder: string): Promise<number[]>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=imap-web-provider.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMAP web provider — implements MailProvider using CompatImapClient + BridgeTransport.
|
|
3
|
+
* Used for non-Gmail accounts on Android/WebView where Node.js isn't available.
|
|
4
|
+
*
|
|
5
|
+
* The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it
|
|
6
|
+
* to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects
|
|
7
|
+
* that global name.
|
|
8
|
+
*/
|
|
9
|
+
import { CompatImapClient, BridgeTransport } from "@bobfrankston/iflow-direct";
|
|
10
|
+
/**
|
|
11
|
+
* Convert a NativeFolder (from iflow-direct) into a ProviderFolder,
|
|
12
|
+
* detecting special-use from the IMAP flags.
|
|
13
|
+
*/
|
|
14
|
+
function toProviderFolder(f, special) {
|
|
15
|
+
const flagsLower = (f.flags || []).map(x => x.toLowerCase());
|
|
16
|
+
let specialUse = "";
|
|
17
|
+
if (f.path === special.inbox || flagsLower.includes("\\inbox") || f.path.toUpperCase() === "INBOX")
|
|
18
|
+
specialUse = "inbox";
|
|
19
|
+
else if (f.path === special.sent || flagsLower.includes("\\sent"))
|
|
20
|
+
specialUse = "sent";
|
|
21
|
+
else if (f.path === special.trash || flagsLower.includes("\\trash"))
|
|
22
|
+
specialUse = "trash";
|
|
23
|
+
else if (f.path === special.drafts || flagsLower.includes("\\drafts"))
|
|
24
|
+
specialUse = "drafts";
|
|
25
|
+
else if (f.path === special.spam || f.path === special.junk || flagsLower.includes("\\junk"))
|
|
26
|
+
specialUse = "junk";
|
|
27
|
+
else if (f.path === special.archive || flagsLower.includes("\\archive"))
|
|
28
|
+
specialUse = "archive";
|
|
29
|
+
// Leaf name = last path segment after delimiter
|
|
30
|
+
const leaf = f.delimiter ? f.path.split(f.delimiter).pop() || f.path : f.path;
|
|
31
|
+
return {
|
|
32
|
+
path: f.path,
|
|
33
|
+
name: leaf,
|
|
34
|
+
delimiter: f.delimiter || "/",
|
|
35
|
+
specialUse,
|
|
36
|
+
flags: f.flags || [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function toProviderMessage(m) {
|
|
40
|
+
return {
|
|
41
|
+
uid: m.uid,
|
|
42
|
+
messageId: m.messageId || "",
|
|
43
|
+
providerId: "",
|
|
44
|
+
date: m.date || null,
|
|
45
|
+
subject: m.subject || "",
|
|
46
|
+
from: m.from || [],
|
|
47
|
+
to: m.to || [],
|
|
48
|
+
cc: m.cc || [],
|
|
49
|
+
seen: !!m.seen,
|
|
50
|
+
flagged: !!m.flagged,
|
|
51
|
+
answered: !!m.answered,
|
|
52
|
+
draft: !!m.draft,
|
|
53
|
+
size: m.size || 0,
|
|
54
|
+
source: m.source || "",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export class ImapWebProvider {
|
|
58
|
+
client;
|
|
59
|
+
specialFolders = {};
|
|
60
|
+
folderListCache = null;
|
|
61
|
+
constructor(config) {
|
|
62
|
+
const transportFactory = () => new BridgeTransport();
|
|
63
|
+
this.client = new CompatImapClient(config, transportFactory);
|
|
64
|
+
}
|
|
65
|
+
async listFolders() {
|
|
66
|
+
const native = await this.client.getFolderList();
|
|
67
|
+
const special = this.client.getSpecialFolders(native);
|
|
68
|
+
this.specialFolders = special;
|
|
69
|
+
const result = native.map(f => toProviderFolder(f, this.specialFolders));
|
|
70
|
+
this.folderListCache = result;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
async fetchSince(folder, sinceUid, options) {
|
|
74
|
+
const msgs = await this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source });
|
|
75
|
+
return msgs.map(toProviderMessage);
|
|
76
|
+
}
|
|
77
|
+
async fetchByDate(folder, since, before, options, onChunk) {
|
|
78
|
+
const wrappedChunk = onChunk ? (raw) => onChunk(raw.map(toProviderMessage)) : undefined;
|
|
79
|
+
const msgs = await this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk);
|
|
80
|
+
return msgs.map(toProviderMessage);
|
|
81
|
+
}
|
|
82
|
+
async fetchByUids(folder, uids, options) {
|
|
83
|
+
if (!uids.length)
|
|
84
|
+
return [];
|
|
85
|
+
const range = uids.join(",");
|
|
86
|
+
const msgs = await this.client.fetchMessages(folder, range, { source: !!options?.source });
|
|
87
|
+
return msgs.map(toProviderMessage);
|
|
88
|
+
}
|
|
89
|
+
async fetchOne(folder, uid, options) {
|
|
90
|
+
const msg = await this.client.fetchMessageByUid(folder, uid, { source: !!options?.source });
|
|
91
|
+
return msg ? toProviderMessage(msg) : null;
|
|
92
|
+
}
|
|
93
|
+
async getUids(folder) {
|
|
94
|
+
return this.client.getUids(folder);
|
|
95
|
+
}
|
|
96
|
+
async close() {
|
|
97
|
+
try {
|
|
98
|
+
await this.client.logout();
|
|
99
|
+
}
|
|
100
|
+
catch { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=imap-web-provider.js.map
|