@bobfrankston/mailx 1.0.74 → 1.0.82

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.
Files changed (65) hide show
  1. package/bin/mailx.js +1 -1
  2. package/client/app.js +87 -2
  3. package/client/lib/api-client.js +32 -1
  4. package/client/lib/local-service.js +461 -0
  5. package/client/lib/local-store.js +214 -0
  6. package/package.json +2 -2
  7. package/packages/mailx-imap/index.d.ts +9 -3
  8. package/packages/mailx-imap/index.js +63 -39
  9. package/packages/mailx-server/index.js +4 -0
  10. package/packages/mailx-service/index.js +16 -6
  11. package/android/app/build/.npmkeep +0 -0
  12. package/android/app/build.gradle +0 -54
  13. package/android/app/capacitor.build.gradle +0 -19
  14. package/android/app/proguard-rules.pro +0 -21
  15. package/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +0 -26
  16. package/android/app/src/main/AndroidManifest.xml +0 -41
  17. package/android/app/src/main/java/com/frankston/mailx/MainActivity.java +0 -5
  18. package/android/app/src/main/res/drawable/ic_launcher_background.xml +0 -170
  19. package/android/app/src/main/res/drawable/splash.png +0 -0
  20. package/android/app/src/main/res/drawable-land-hdpi/splash.png +0 -0
  21. package/android/app/src/main/res/drawable-land-mdpi/splash.png +0 -0
  22. package/android/app/src/main/res/drawable-land-xhdpi/splash.png +0 -0
  23. package/android/app/src/main/res/drawable-land-xxhdpi/splash.png +0 -0
  24. package/android/app/src/main/res/drawable-land-xxxhdpi/splash.png +0 -0
  25. package/android/app/src/main/res/drawable-port-hdpi/splash.png +0 -0
  26. package/android/app/src/main/res/drawable-port-mdpi/splash.png +0 -0
  27. package/android/app/src/main/res/drawable-port-xhdpi/splash.png +0 -0
  28. package/android/app/src/main/res/drawable-port-xxhdpi/splash.png +0 -0
  29. package/android/app/src/main/res/drawable-port-xxxhdpi/splash.png +0 -0
  30. package/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +0 -34
  31. package/android/app/src/main/res/layout/activity_main.xml +0 -12
  32. package/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -5
  33. package/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -5
  34. package/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  35. package/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  36. package/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  37. package/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  38. package/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  39. package/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  40. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  41. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  42. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  43. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  44. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  45. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  46. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  47. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  48. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  49. package/android/app/src/main/res/values/ic_launcher_background.xml +0 -4
  50. package/android/app/src/main/res/values/strings.xml +0 -7
  51. package/android/app/src/main/res/values/styles.xml +0 -22
  52. package/android/app/src/main/res/xml/file_paths.xml +0 -5
  53. package/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +0 -18
  54. package/android/build.gradle +0 -29
  55. package/android/capacitor.settings.gradle +0 -3
  56. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  57. package/android/gradle/wrapper/gradle-wrapper.properties +0 -7
  58. package/android/gradle.properties +0 -23
  59. package/android/gradlew +0 -251
  60. package/android/gradlew.bat +0 -94
  61. package/android/settings.gradle +0 -5
  62. package/android/variables.gradle +0 -16
  63. package/download/apks/mailx-debug.apk +0 -0
  64. package/download/index.html +0 -118
  65. package/download/versions.json +0 -19
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Local storage layer for Android/WebView — uses IndexedDB instead of SQLite.
3
+ * Implements the subset of MailxDB that the client needs.
4
+ */
5
+ const DB_NAME = "mailx";
6
+ const DB_VERSION = 1;
7
+ let db = null;
8
+ async function openDB() {
9
+ if (db)
10
+ return db;
11
+ return new Promise((resolve, reject) => {
12
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
13
+ req.onupgradeneeded = () => {
14
+ const d = req.result;
15
+ if (!d.objectStoreNames.contains("accounts")) {
16
+ d.createObjectStore("accounts", { keyPath: "id" });
17
+ }
18
+ if (!d.objectStoreNames.contains("folders")) {
19
+ const fs = d.createObjectStore("folders", { keyPath: "id", autoIncrement: true });
20
+ fs.createIndex("accountId", "accountId");
21
+ }
22
+ if (!d.objectStoreNames.contains("messages")) {
23
+ const ms = d.createObjectStore("messages", { keyPath: "key" });
24
+ ms.createIndex("accountId", "accountId");
25
+ ms.createIndex("folderId", "folderId");
26
+ ms.createIndex("folder", ["accountId", "folderId"]);
27
+ ms.createIndex("date", "date");
28
+ }
29
+ if (!d.objectStoreNames.contains("bodies")) {
30
+ d.createObjectStore("bodies", { keyPath: "key" });
31
+ }
32
+ if (!d.objectStoreNames.contains("contacts")) {
33
+ const cs = d.createObjectStore("contacts", { keyPath: "email" });
34
+ cs.createIndex("name", "name");
35
+ }
36
+ if (!d.objectStoreNames.contains("meta")) {
37
+ d.createObjectStore("meta", { keyPath: "key" });
38
+ }
39
+ };
40
+ req.onsuccess = () => { db = req.result; resolve(db); };
41
+ req.onerror = () => reject(req.error);
42
+ });
43
+ }
44
+ function tx(stores, mode = "readonly") {
45
+ const names = Array.isArray(stores) ? stores : [stores];
46
+ return db.transaction(names, mode);
47
+ }
48
+ function getAll(store, index, key) {
49
+ return new Promise((resolve, reject) => {
50
+ const t = tx(store);
51
+ const s = t.objectStore(store);
52
+ const target = index ? s.index(index) : s;
53
+ const req = key ? target.getAll(key) : target.getAll();
54
+ req.onsuccess = () => resolve(req.result);
55
+ req.onerror = () => reject(req.error);
56
+ });
57
+ }
58
+ function put(store, value) {
59
+ return new Promise((resolve, reject) => {
60
+ const t = tx(store, "readwrite");
61
+ t.objectStore(store).put(value);
62
+ t.oncomplete = () => resolve();
63
+ t.onerror = () => reject(t.error);
64
+ });
65
+ }
66
+ function del(store, key) {
67
+ return new Promise((resolve, reject) => {
68
+ const t = tx(store, "readwrite");
69
+ t.objectStore(store).delete(key);
70
+ t.oncomplete = () => resolve();
71
+ t.onerror = () => reject(t.error);
72
+ });
73
+ }
74
+ // ── Public API (mirrors MailxDB methods used by the client service) ──
75
+ export async function init() {
76
+ await openDB();
77
+ }
78
+ export async function getAccounts() {
79
+ return getAll("accounts");
80
+ }
81
+ export async function upsertAccount(id, name, email, config) {
82
+ await put("accounts", { id, name, email, config, lastSync: 0 });
83
+ }
84
+ export async function getFolders(accountId) {
85
+ return getAll("folders", "accountId", accountId);
86
+ }
87
+ export async function upsertFolder(folder) {
88
+ await put("folders", folder);
89
+ }
90
+ export async function getMessages(accountId, folderId, page = 1, pageSize = 50) {
91
+ const all = await getAll("messages", "folder", [accountId, folderId]);
92
+ // Sort by date descending
93
+ all.sort((a, b) => b.date - a.date);
94
+ const total = all.length;
95
+ const start = (page - 1) * pageSize;
96
+ const items = all.slice(start, start + pageSize).map(toEnvelope);
97
+ return { items, total, page, pageSize };
98
+ }
99
+ export async function getUnifiedInbox(page = 1, pageSize = 50) {
100
+ const folders = await getAll("folders");
101
+ const inboxFolders = folders.filter(f => f.specialUse === "inbox");
102
+ const allMsgs = [];
103
+ for (const f of inboxFolders) {
104
+ const msgs = await getAll("messages", "folder", [f.accountId, f.id]);
105
+ allMsgs.push(...msgs);
106
+ }
107
+ allMsgs.sort((a, b) => b.date - a.date);
108
+ const total = allMsgs.length;
109
+ const start = (page - 1) * pageSize;
110
+ const items = allMsgs.slice(start, start + pageSize).map(toEnvelope);
111
+ return { items, total, page, pageSize };
112
+ }
113
+ export async function getMessageByUid(accountId, uid, folderId) {
114
+ if (folderId != null) {
115
+ const key = `${accountId}:${folderId}:${uid}`;
116
+ return new Promise((resolve, reject) => {
117
+ const t = tx("messages");
118
+ const req = t.objectStore("messages").get(key);
119
+ req.onsuccess = () => resolve(req.result ? toEnvelope(req.result) : null);
120
+ req.onerror = () => reject(req.error);
121
+ });
122
+ }
123
+ // Search all folders
124
+ const all = await getAll("messages", "accountId", accountId);
125
+ const msg = all.find(m => m.uid === uid);
126
+ return msg ? toEnvelope(msg) : null;
127
+ }
128
+ export async function upsertMessage(msg) {
129
+ await put("messages", msg);
130
+ }
131
+ export async function deleteMessage(accountId, uid) {
132
+ const all = await getAll("messages", "accountId", accountId);
133
+ const msg = all.find(m => m.uid === uid);
134
+ if (msg)
135
+ await del("messages", msg.key);
136
+ }
137
+ export async function getHighestUid(accountId, folderId) {
138
+ const msgs = await getAll("messages", "folder", [accountId, folderId]);
139
+ return msgs.reduce((max, m) => Math.max(max, m.uid), 0);
140
+ }
141
+ export async function storeBody(accountId, folderId, uid, body) {
142
+ await put("bodies", { key: `${accountId}:${folderId}:${uid}`, body });
143
+ }
144
+ export async function getBody(accountId, folderId, uid) {
145
+ return new Promise((resolve, reject) => {
146
+ const t = tx("bodies");
147
+ const req = t.objectStore("bodies").get(`${accountId}:${folderId}:${uid}`);
148
+ req.onsuccess = () => resolve(req.result?.body || null);
149
+ req.onerror = () => reject(req.error);
150
+ });
151
+ }
152
+ export async function updateFolderCounts(folderId, total, unread) {
153
+ const folders = await getAll("folders");
154
+ const f = folders.find(f => f.id === folderId);
155
+ if (f) {
156
+ f.totalCount = total;
157
+ f.unreadCount = unread;
158
+ await put("folders", f);
159
+ }
160
+ }
161
+ export async function searchContacts(query) {
162
+ const q = query.toLowerCase();
163
+ const all = await getAll("contacts");
164
+ return all.filter(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q)).slice(0, 10);
165
+ }
166
+ export async function upsertContact(email, name, source) {
167
+ const existing = await new Promise((resolve, reject) => {
168
+ const t = tx("contacts");
169
+ const req = t.objectStore("contacts").get(email);
170
+ req.onsuccess = () => resolve(req.result);
171
+ req.onerror = () => reject(req.error);
172
+ });
173
+ if (existing) {
174
+ existing.useCount++;
175
+ existing.lastUsed = Date.now();
176
+ if (name && !existing.name)
177
+ existing.name = name;
178
+ await put("contacts", existing);
179
+ }
180
+ else {
181
+ await put("contacts", { email, name, source, useCount: 1, lastUsed: Date.now() });
182
+ }
183
+ }
184
+ export async function getMeta(key) {
185
+ return new Promise((resolve, reject) => {
186
+ const t = tx("meta");
187
+ const req = t.objectStore("meta").get(key);
188
+ req.onsuccess = () => resolve(req.result?.value);
189
+ req.onerror = () => reject(req.error);
190
+ });
191
+ }
192
+ export async function setMeta(key, value) {
193
+ await put("meta", { key, value });
194
+ }
195
+ // ── Helpers ──
196
+ function toEnvelope(m) {
197
+ return {
198
+ accountId: m.accountId,
199
+ folderId: m.folderId,
200
+ uid: m.uid,
201
+ messageId: m.messageId,
202
+ date: m.date,
203
+ subject: m.subject,
204
+ from: { name: m.fromName, address: m.fromAddress },
205
+ to: JSON.parse(m.toJson || "[]"),
206
+ cc: JSON.parse(m.ccJson || "[]"),
207
+ flags: m.flags ? m.flags.split(",").filter(Boolean) : [],
208
+ size: m.size,
209
+ hasAttachments: m.hasAttachments,
210
+ preview: m.preview,
211
+ bodyPath: m.bodyPath,
212
+ };
213
+ }
214
+ //# sourceMappingURL=local-store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.74",
3
+ "version": "1.0.82",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,7 +20,7 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.34",
23
+ "@bobfrankston/iflow": "^1.0.37",
24
24
  "@bobfrankston/miscinfo": "^1.0.6",
25
25
  "@bobfrankston/oauthsupport": "^1.0.19",
26
26
  "@bobfrankston/rust-builder": "^0.1.2",
@@ -25,8 +25,12 @@ export declare class ImapManager extends EventEmitter {
25
25
  private db;
26
26
  private bodyStore;
27
27
  private syncIntervals;
28
+ /** Track which accounts have already shown an error banner — only emit once per session */
29
+ private accountErrorShown;
28
30
  private syncing;
29
31
  private inboxSyncing;
32
+ /** Use native IMAP client instead of imapflow. Set to true to enable. */
33
+ useNativeClient: boolean;
30
34
  constructor(db: MailxDB);
31
35
  /** Get OAuth access token for an account (for SMTP auth) */
32
36
  getOAuthToken(accountId: string): Promise<string | null>;
@@ -38,9 +42,10 @@ export declare class ImapManager extends EventEmitter {
38
42
  deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
39
43
  /** Search messages on the IMAP server — returns matching UIDs */
40
44
  searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
41
- /** Create a fresh ImapClient for an account (public access for API endpoints) */
42
- createPublicClient(accountId: string): ImapClient;
43
- /** Create a fresh ImapClient for an account (disposable, single-use) */
45
+ /** Create a fresh IMAP client for an account (public access for API endpoints) */
46
+ createPublicClient(accountId: string): any;
47
+ /** Create a fresh IMAP client for an account (disposable, single-use).
48
+ * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag. */
44
49
  private createClient;
45
50
  /** Register an account */
46
51
  addAccount(account: AccountConfig): Promise<void>;
@@ -56,6 +61,7 @@ export declare class ImapManager extends EventEmitter {
56
61
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
57
62
  * If message count changed, triggers a full inbox sync. */
58
63
  private lastInboxCounts;
64
+ private quickCheckRunning;
59
65
  quickInboxCheck(): Promise<void>;
60
66
  /** Start periodic sync */
61
67
  startPeriodicSync(intervalMinutes: number): void;
@@ -3,7 +3,7 @@
3
3
  * Multi-account IMAP management wrapping iflow.
4
4
  * Syncs messages to local store, emits events for new mail.
5
5
  */
6
- import { ImapClient, createAutoImapConfig } from "@bobfrankston/iflow";
6
+ import { ImapClient, createAutoImapConfig, CompatImapClient, NodeTransport } from "@bobfrankston/iflow";
7
7
  import { FileMessageStore } from "@bobfrankston/mailx-store";
8
8
  import { loadSettings, getStorePath, getConfigDir } from "@bobfrankston/mailx-settings";
9
9
  import { EventEmitter } from "node:events";
@@ -65,8 +65,12 @@ export class ImapManager extends EventEmitter {
65
65
  db;
66
66
  bodyStore;
67
67
  syncIntervals = new Map();
68
+ /** Track which accounts have already shown an error banner — only emit once per session */
69
+ accountErrorShown = new Set();
68
70
  syncing = false;
69
71
  inboxSyncing = false;
72
+ /** Use native IMAP client instead of imapflow. Set to true to enable. */
73
+ useNativeClient = false;
70
74
  constructor(db) {
71
75
  super();
72
76
  this.db = db;
@@ -116,6 +120,7 @@ export class ImapManager extends EventEmitter {
116
120
  const config = this.configs.get(accountId);
117
121
  if (config?.tokenProvider) {
118
122
  console.log(` [reauth] ${accountId}: success`);
123
+ this.accountErrorShown.delete(accountId);
119
124
  this.syncInbox().catch(() => { });
120
125
  return true;
121
126
  }
@@ -155,17 +160,21 @@ export class ImapManager extends EventEmitter {
155
160
  catch { /* ignore */ }
156
161
  }
157
162
  }
158
- /** Create a fresh ImapClient for an account (public access for API endpoints) */
163
+ /** Create a fresh IMAP client for an account (public access for API endpoints) */
159
164
  createPublicClient(accountId) {
160
165
  return this.createClient(accountId);
161
166
  }
162
- /** Create a fresh ImapClient for an account (disposable, single-use) */
167
+ /** Create a fresh IMAP client for an account (disposable, single-use).
168
+ * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag. */
163
169
  createClient(accountId) {
164
170
  if (this.reauthenticating.has(accountId))
165
171
  throw new Error(`Account ${accountId} is re-authenticating`);
166
172
  const config = this.configs.get(accountId);
167
173
  if (!config)
168
174
  throw new Error(`No config for account ${accountId}`);
175
+ if (this.useNativeClient) {
176
+ return new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
177
+ }
169
178
  return new ImapClient(config);
170
179
  }
171
180
  /** Register an account */
@@ -192,7 +201,10 @@ export class ImapManager extends EventEmitter {
192
201
  }
193
202
  catch (e) {
194
203
  console.error(` [auth] ${account.id}: ${imapError(e)}`);
195
- this.emit("accountError", account.id, imapError(e), "Re-authenticate: click the button below or run mailx -setup");
204
+ if (!this.accountErrorShown.has(account.id)) {
205
+ this.accountErrorShown.add(account.id);
206
+ this.emit("accountError", account.id, imapError(e), "Re-authenticate: click the button below or run mailx -setup");
207
+ }
196
208
  }
197
209
  }
198
210
  }
@@ -447,11 +459,14 @@ export class ImapManager extends EventEmitter {
447
459
  catch (e) {
448
460
  this.emit("syncError", accountId, imapError(e));
449
461
  console.error(`Sync error for ${accountId}: ${imapError(e)}`);
450
- // Emit user-facing error always offer re-auth for OAuth accounts
451
- const config = this.configs.get(accountId);
452
- const isOAuth = !!config?.tokenProvider;
453
- const hint = isOAuth ? "Authentication may have expired" : "Check server connectivity";
454
- this.emit("accountError", accountId, imapError(e), hint);
462
+ // Emit user-facing error once per account per session
463
+ if (!this.accountErrorShown.has(accountId)) {
464
+ this.accountErrorShown.add(accountId);
465
+ const config = this.configs.get(accountId);
466
+ const isOAuth = !!config?.tokenProvider;
467
+ const hint = isOAuth ? "Authentication may have expired" : "Check server connectivity";
468
+ this.emit("accountError", accountId, imapError(e), hint);
469
+ }
455
470
  }
456
471
  finally {
457
472
  if (client)
@@ -563,49 +578,58 @@ export class ImapManager extends EventEmitter {
563
578
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
564
579
  * If message count changed, triggers a full inbox sync. */
565
580
  lastInboxCounts = new Map();
581
+ quickCheckRunning = false;
566
582
  async quickInboxCheck() {
567
- for (const [accountId] of this.configs) {
568
- if (this.reauthenticating.has(accountId))
569
- continue;
570
- let client = null;
571
- try {
572
- const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
573
- if (!inbox)
583
+ if (this.quickCheckRunning || this.syncing || this.inboxSyncing)
584
+ return;
585
+ this.quickCheckRunning = true;
586
+ try {
587
+ for (const [accountId] of this.configs) {
588
+ if (this.reauthenticating.has(accountId))
574
589
  continue;
575
- client = this.createClient(accountId);
576
- const count = await client.getMessagesCount("INBOX");
577
- await client.logout();
578
- client = null;
579
- const prev = this.lastInboxCounts.get(accountId) ?? count;
580
- this.lastInboxCounts.set(accountId, count);
581
- if (count !== prev) {
582
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
583
- // New mail detected — do a full inbox sync
590
+ let client = null;
591
+ try {
592
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
593
+ if (!inbox)
594
+ continue;
584
595
  client = this.createClient(accountId);
585
- await this.syncFolder(accountId, inbox.id, client);
596
+ const count = await client.getMessagesCount("INBOX");
586
597
  await client.logout();
587
598
  client = null;
588
- }
589
- }
590
- catch {
591
- // Lightweight check silently ignore errors (full sync will catch up)
592
- }
593
- finally {
594
- if (client)
595
- try {
599
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
600
+ this.lastInboxCounts.set(accountId, count);
601
+ if (count !== prev) {
602
+ console.log(` [check] ${accountId} INBOX: ${prev} ${count}`);
603
+ client = this.createClient(accountId);
604
+ await this.syncFolder(accountId, inbox.id, client);
596
605
  await client.logout();
606
+ client = null;
597
607
  }
598
- catch { /* ignore */ }
608
+ }
609
+ catch {
610
+ // Lightweight check — silently ignore errors
611
+ }
612
+ finally {
613
+ if (client)
614
+ try {
615
+ await client.logout();
616
+ }
617
+ catch { /* ignore */ }
618
+ }
599
619
  }
600
620
  }
621
+ finally {
622
+ this.quickCheckRunning = false;
623
+ }
601
624
  }
602
625
  /** Start periodic sync */
603
626
  startPeriodicSync(intervalMinutes) {
604
627
  this.stopPeriodicSync();
605
- // Quick inbox check every 3 seconds — lightweight STATUS command
628
+ // Quick inbox check every 10 seconds — STATUS command is cheap but TCP setup isn't
629
+ // Guards prevent overlapping with full sync or inbox sync
606
630
  const quickCheck = setInterval(() => {
607
631
  this.quickInboxCheck().catch(() => { });
608
- }, 3000);
632
+ }, 10000);
609
633
  this.syncIntervals.set("quick", quickCheck);
610
634
  // Sync actions (sends + flags/deletes/moves) every 30 seconds
611
635
  const actionsInterval = setInterval(async () => {
@@ -1064,7 +1088,7 @@ export class ImapManager extends EventEmitter {
1064
1088
  for (const uid of uids) {
1065
1089
  // Check flags — skip if already being sent or permanently failed
1066
1090
  const flags = await client.getFlags(outboxFolder.path, uid);
1067
- if (flags.some(f => f.startsWith("$Sending")))
1091
+ if (flags.some((f) => f.startsWith("$Sending")))
1068
1092
  continue;
1069
1093
  if (flags.includes("$PermanentFailure"))
1070
1094
  continue;
@@ -1076,7 +1100,7 @@ export class ImapManager extends EventEmitter {
1076
1100
  await client.addFlags(outboxFolder.path, uid, [sendingFlag]);
1077
1101
  // Re-check — did we win the race?
1078
1102
  const flagsAfter = await client.getFlags(outboxFolder.path, uid);
1079
- const sendingFlags = flagsAfter.filter(f => f.startsWith("$Sending"));
1103
+ const sendingFlags = flagsAfter.filter((f) => f.startsWith("$Sending"));
1080
1104
  if (sendingFlags.length > 1 || (sendingFlags.length === 1 && sendingFlags[0] !== sendingFlag)) {
1081
1105
  // Another machine claimed it — back off
1082
1106
  await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
@@ -57,6 +57,10 @@ if (settings.accounts.length === 0) {
57
57
  const dbDir = getConfigDir();
58
58
  const db = new MailxDB(dbDir);
59
59
  const imapManager = new ImapManager(db);
60
+ if (process.argv.includes("--native-imap") || process.argv.includes("-native-imap")) {
61
+ imapManager.useNativeClient = true;
62
+ console.log(" Using native IMAP client (transport-agnostic)");
63
+ }
60
64
  // ── Express App ──
61
65
  const app = express();
62
66
  app.use(express.json({ limit: "Infinity" }));
@@ -342,9 +342,14 @@ export class MailxService {
342
342
  const newPath = parts.join(folder.delimiter || ".");
343
343
  const client = this.imapManager.createPublicClient(accountId);
344
344
  try {
345
- await client.withConnection(async () => {
346
- await client.client.mailboxRename(folder.path, newPath);
347
- });
345
+ if (client.renameMailbox) {
346
+ await client.renameMailbox(folder.path, newPath);
347
+ }
348
+ else {
349
+ await client.withConnection(async () => {
350
+ await client.client.mailboxRename(folder.path, newPath);
351
+ });
352
+ }
348
353
  await this.imapManager.syncFolders(accountId, client);
349
354
  await client.logout();
350
355
  }
@@ -361,9 +366,14 @@ export class MailxService {
361
366
  throw new Error("Folder not found");
362
367
  const client = this.imapManager.createPublicClient(accountId);
363
368
  try {
364
- await client.withConnection(async () => {
365
- await client.client.mailboxDelete(folder.path);
366
- });
369
+ if (client.deleteMailbox) {
370
+ await client.deleteMailbox(folder.path);
371
+ }
372
+ else {
373
+ await client.withConnection(async () => {
374
+ await client.client.mailboxDelete(folder.path);
375
+ });
376
+ }
367
377
  this.db.deleteFolder(folderId);
368
378
  await client.logout();
369
379
  }
File without changes
@@ -1,54 +0,0 @@
1
- apply plugin: 'com.android.application'
2
-
3
- android {
4
- namespace = "com.frankston.mailx"
5
- compileSdk = rootProject.ext.compileSdkVersion
6
- defaultConfig {
7
- applicationId "com.frankston.mailx"
8
- minSdkVersion rootProject.ext.minSdkVersion
9
- targetSdkVersion rootProject.ext.targetSdkVersion
10
- versionCode 1
11
- versionName "1.0"
12
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13
- aaptOptions {
14
- // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
15
- // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
16
- ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
17
- }
18
- }
19
- buildTypes {
20
- release {
21
- minifyEnabled false
22
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
23
- }
24
- }
25
- }
26
-
27
- repositories {
28
- flatDir{
29
- dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
30
- }
31
- }
32
-
33
- dependencies {
34
- implementation fileTree(include: ['*.jar'], dir: 'libs')
35
- implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
36
- implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
37
- implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
38
- implementation project(':capacitor-android')
39
- testImplementation "junit:junit:$junitVersion"
40
- androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
41
- androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
42
- implementation project(':capacitor-cordova-android-plugins')
43
- }
44
-
45
- apply from: 'capacitor.build.gradle'
46
-
47
- try {
48
- def servicesJSON = file('google-services.json')
49
- if (servicesJSON.text) {
50
- apply plugin: 'com.google.gms.google-services'
51
- }
52
- } catch(Exception e) {
53
- logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
54
- }
@@ -1,19 +0,0 @@
1
- // DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
2
-
3
- android {
4
- compileOptions {
5
- sourceCompatibility JavaVersion.VERSION_21
6
- targetCompatibility JavaVersion.VERSION_21
7
- }
8
- }
9
-
10
- apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
11
- dependencies {
12
-
13
-
14
- }
15
-
16
-
17
- if (hasProperty('postBuildExtras')) {
18
- postBuildExtras()
19
- }
@@ -1,21 +0,0 @@
1
- # Add project specific ProGuard rules here.
2
- # You can control the set of applied configuration files using the
3
- # proguardFiles setting in build.gradle.
4
- #
5
- # For more details, see
6
- # http://developer.android.com/guide/developing/tools/proguard.html
7
-
8
- # If your project uses WebView with JS, uncomment the following
9
- # and specify the fully qualified class name to the JavaScript interface
10
- # class:
11
- #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12
- # public *;
13
- #}
14
-
15
- # Uncomment this to preserve the line number information for
16
- # debugging stack traces.
17
- #-keepattributes SourceFile,LineNumberTable
18
-
19
- # If you keep the line number information, uncomment this to
20
- # hide the original source file name.
21
- #-renamesourcefileattribute SourceFile
@@ -1,26 +0,0 @@
1
- package com.getcapacitor.myapp;
2
-
3
- import static org.junit.Assert.*;
4
-
5
- import android.content.Context;
6
- import androidx.test.ext.junit.runners.AndroidJUnit4;
7
- import androidx.test.platform.app.InstrumentationRegistry;
8
- import org.junit.Test;
9
- import org.junit.runner.RunWith;
10
-
11
- /**
12
- * Instrumented test, which will execute on an Android device.
13
- *
14
- * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
15
- */
16
- @RunWith(AndroidJUnit4.class)
17
- public class ExampleInstrumentedTest {
18
-
19
- @Test
20
- public void useAppContext() throws Exception {
21
- // Context of the app under test.
22
- Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
23
-
24
- assertEquals("com.getcapacitor.app", appContext.getPackageName());
25
- }
26
- }