@bobfrankston/mailx 1.0.237 → 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.
@@ -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-select">From</label>
17
- <select id="compose-from-select"></select>
18
- <input type="text" id="compose-from-custom" placeholder="Custom address..." hidden>
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
- const fromSelect = document.getElementById("compose-from-select");
63
- const fromCustom = document.getElementById("compose-from-custom");
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
- /** Populate the From dropdown with accounts */
78
- function populateFromSelect(accounts, selectedId) {
79
- fromSelect.innerHTML = "";
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.id;
83
- const displayLabel = acct.label || acct.name;
84
- opt.textContent = `${displayLabel}: ${acct.name} <${acct.email}>`;
85
- opt.dataset.email = acct.email;
86
- opt.dataset.name = acct.name;
87
- if (acct.defaultSend)
88
- opt.dataset.defaultSend = "true";
89
- if (acct.id === selectedId)
90
- opt.selected = true;
91
- fromSelect.appendChild(opt);
92
- }
93
- // "Other..." option for custom address
94
- const other = document.createElement("option");
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
- else {
105
- fromCustom.hidden = true;
106
- fromCustom.value = "";
107
- }
108
- });
109
- /** Extract account ID from the From field */
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
- if (fromSelect.value === "__custom__") {
112
- // Custom address — use default send account, fallback to first
113
- const defaultOpt = Array.from(fromSelect.options).find(o => o.dataset.defaultSend === "true");
114
- return defaultOpt?.value || fromSelect.options[0]?.value || "";
115
- }
116
- return fromSelect.value;
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 address string for the message headers */
137
+ /** Get the raw From header string ("Name <addr>"). */
119
138
  function getFromAddress() {
120
- if (fromSelect.value === "__custom__")
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
- return s.split(",").map(part => {
249
- const match = part.trim().match(/^(.+?)\s*<(.+?)>$/);
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.trim() };
274
+ return { name: "", address: part };
253
275
  });
254
276
  }
255
277
  function applyInit(init) {
256
- // If identity domain matched, add as first option in dropdown
257
- const replyAddr = init.fromAddress;
258
- const account = init.accounts.find(a => a.id === init.accountId);
259
- const displayName = account?.name || "";
260
- if (replyAddr) {
261
- // Insert identity address as first option, selected
262
- const idOpt = document.createElement("option");
263
- idOpt.value = init.accountId;
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
- populateFromSelect(accounts);
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.237",
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.299",
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.299",
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",
@@ -2180,12 +2180,16 @@ export class ImapManager extends EventEmitter {
2180
2180
  this.outboxBackoffDelay.delete(accountId);
2181
2181
  }
2182
2182
  catch (e) {
2183
- // Stale-socket errors (Dovecot silently drops idle connections):
2184
- // don't back off just reconnect on the next tick. The 300s
2185
- // backoff is meant for real auth/network failures, not dead sockets.
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.
2186
2189
  const msg = String(e?.message || e);
2187
2190
  if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
2188
- console.error(` [outbox] Stale connection for ${accountId}: ${msg} — will retry next tick`);
2191
+ this.reconnectOps(accountId).catch(() => { });
2192
+ console.error(` [outbox] Stale connection for ${accountId}: ${msg} — reconnecting`);
2189
2193
  }
2190
2194
  else {
2191
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}: NOT a Gmail account — needs IMAP via TCP bridge (not yet implemented)`);
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