@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.
- package/bin/mailx.js +1 -1
- package/client/app.js +87 -2
- package/client/lib/api-client.js +32 -1
- package/client/lib/local-service.js +461 -0
- package/client/lib/local-store.js +214 -0
- package/package.json +2 -2
- package/packages/mailx-imap/index.d.ts +9 -3
- package/packages/mailx-imap/index.js +63 -39
- package/packages/mailx-server/index.js +4 -0
- package/packages/mailx-service/index.js +16 -6
- package/android/app/build/.npmkeep +0 -0
- package/android/app/build.gradle +0 -54
- package/android/app/capacitor.build.gradle +0 -19
- package/android/app/proguard-rules.pro +0 -21
- package/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +0 -26
- package/android/app/src/main/AndroidManifest.xml +0 -41
- package/android/app/src/main/java/com/frankston/mailx/MainActivity.java +0 -5
- package/android/app/src/main/res/drawable/ic_launcher_background.xml +0 -170
- package/android/app/src/main/res/drawable/splash.png +0 -0
- package/android/app/src/main/res/drawable-land-hdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-land-mdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-land-xhdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-land-xxhdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-land-xxxhdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-port-hdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-port-mdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-port-xhdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-port-xxhdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-port-xxxhdpi/splash.png +0 -0
- package/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +0 -34
- package/android/app/src/main/res/layout/activity_main.xml +0 -12
- package/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +0 -5
- package/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +0 -5
- package/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/values/ic_launcher_background.xml +0 -4
- package/android/app/src/main/res/values/strings.xml +0 -7
- package/android/app/src/main/res/values/styles.xml +0 -22
- package/android/app/src/main/res/xml/file_paths.xml +0 -5
- package/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java +0 -18
- package/android/build.gradle +0 -29
- package/android/capacitor.settings.gradle +0 -3
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +0 -7
- package/android/gradle.properties +0 -23
- package/android/gradlew +0 -251
- package/android/gradlew.bat +0 -94
- package/android/settings.gradle +0 -5
- package/android/variables.gradle +0 -16
- package/download/apks/mailx-debug.apk +0 -0
- package/download/index.html +0 -118
- 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.
|
|
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.
|
|
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
|
|
42
|
-
createPublicClient(accountId: string):
|
|
43
|
-
/** Create a fresh
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 =
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
596
|
+
const count = await client.getMessagesCount("INBOX");
|
|
586
597
|
await client.logout();
|
|
587
598
|
client = null;
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
346
|
-
await client.
|
|
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
|
-
|
|
365
|
-
await client.
|
|
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
|
package/android/app/build.gradle
DELETED
|
@@ -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
|
package/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
DELETED
|
@@ -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
|
-
}
|