@bobfrankston/mailx 1.0.74 → 1.0.83

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 +5 -5
  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
package/bin/mailx.js CHANGED
@@ -31,7 +31,7 @@ const testMode = hasFlag("test");
31
31
  const rebuildMode = hasFlag("rebuild");
32
32
 
33
33
  // Validate arguments
34
- const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild"];
34
+ const knownFlags = ["server", "no-browser", "verbose", "external", "kill", "v", "version", "setup", "add", "test", "rebuild", "native-imap"];
35
35
  for (const arg of args) {
36
36
  const flag = arg.replace(/^--?/, "");
37
37
  if (arg.startsWith("-") && !knownFlags.includes(flag)) {
package/client/app.js CHANGED
@@ -801,14 +801,99 @@ async function checkServer() {
801
801
  }
802
802
  catch {
803
803
  const startupStatus = document.getElementById("startup-status");
804
- if (isApp) {
804
+ if (isApp && mailxapi?.ensureServer) {
805
+ // Desktop native app — start the embedded server
805
806
  if (startupStatus)
806
807
  startupStatus.textContent = "Starting server...";
807
808
  await mailxapi.ensureServer();
808
809
  location.reload();
809
810
  }
811
+ else if (isApp) {
812
+ // MAUI Android app — runs via bridge, no HTTP server
813
+ const overlay = document.getElementById("startup-overlay");
814
+ const content = overlay?.querySelector(".startup-content");
815
+ if (content) {
816
+ content.innerHTML = `
817
+ <div style="text-align:center;max-width:400px">
818
+ <h2 style="margin-bottom:1rem;color:var(--color-text)">mailx</h2>
819
+ <p style="color:var(--color-text-muted);font-size:0.9rem" id="bridge-status">
820
+ Initializing...
821
+ </p>
822
+ </div>`;
823
+ }
824
+ if (overlay)
825
+ overlay.hidden = false;
826
+ // Initialize local service
827
+ try {
828
+ const localService = await import("./lib/local-service.js");
829
+ const statusEl = document.getElementById("bridge-status");
830
+ // Check for saved settings
831
+ let savedSettings = localService.getSettings();
832
+ if (!savedSettings || !savedSettings.accounts?.length) {
833
+ // No settings — prompt for account
834
+ if (statusEl)
835
+ statusEl.textContent = "No accounts configured";
836
+ if (content) {
837
+ content.innerHTML = `
838
+ <div style="text-align:center;max-width:400px">
839
+ <h2 style="margin-bottom:1rem;color:var(--color-text)">mailx — Setup</h2>
840
+ <p style="color:var(--color-text-muted);font-size:0.9rem;margin-bottom:1rem">
841
+ Enter your email account to get started.
842
+ </p>
843
+ <input type="email" id="setup-email" placeholder="you@example.com" style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:4px;background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.5rem">
844
+ <input type="text" id="setup-imap" placeholder="IMAP server (e.g. imap.example.com)" style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:4px;background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.5rem">
845
+ <input type="password" id="setup-pass" placeholder="Password" style="width:100%;padding:0.5rem;border:1px solid var(--color-border);border-radius:4px;background:var(--color-bg);color:var(--color-text);font-size:1rem;margin-bottom:0.75rem">
846
+ <button id="setup-go" style="padding:0.5rem 1.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;font-size:1rem;cursor:pointer">Connect</button>
847
+ <p id="setup-error" style="margin-top:0.75rem;color:oklch(0.65 0.2 25);font-size:0.85rem" hidden></p>
848
+ </div>`;
849
+ }
850
+ document.getElementById("setup-go")?.addEventListener("click", async () => {
851
+ const email = document.getElementById("setup-email").value.trim();
852
+ const imap = document.getElementById("setup-imap").value.trim();
853
+ const pass = document.getElementById("setup-pass").value;
854
+ const errEl = document.getElementById("setup-error");
855
+ if (!email || !pass) {
856
+ errEl.textContent = "Email and password required";
857
+ errEl.hidden = false;
858
+ return;
859
+ }
860
+ const domain = email.split("@")[1] || "";
861
+ const host = imap || `imap.${domain}`;
862
+ const id = domain.replace(/\./g, "");
863
+ const newSettings = {
864
+ accounts: [{
865
+ id, name: email.split("@")[0], email,
866
+ imap: { host, port: 993, user: email, password: pass, auth: "password" },
867
+ smtp: { host: `smtp.${domain}`, port: 587, user: email, password: pass, auth: "password" },
868
+ enabled: true,
869
+ }],
870
+ ui: { theme: "system" },
871
+ sync: { intervalMinutes: 5, historyDays: 100 },
872
+ };
873
+ errEl.hidden = true;
874
+ document.getElementById("setup-go").textContent = "Connecting...";
875
+ await localService.initialize(newSettings);
876
+ if (overlay)
877
+ overlay.hidden = true;
878
+ });
879
+ }
880
+ else {
881
+ // Settings exist — initialize and sync
882
+ if (statusEl)
883
+ statusEl.textContent = "Loading accounts...";
884
+ await localService.initialize();
885
+ if (overlay)
886
+ overlay.hidden = true;
887
+ }
888
+ }
889
+ catch (e) {
890
+ const statusEl = document.getElementById("bridge-status");
891
+ if (statusEl)
892
+ statusEl.textContent = `Error: ${e.message}`;
893
+ }
894
+ }
810
895
  else {
811
- // Show server URL prompt
896
+ // Browser mode — prompt for server URL
812
897
  promptForServer();
813
898
  }
814
899
  }
@@ -189,9 +189,40 @@ const httpRoutes = {
189
189
  sendMessage: (p) => ({ path: "/send", options: { method: "POST", body: JSON.stringify(p.body) } }),
190
190
  saveDraft: (p) => ({ path: "/draft", options: { method: "POST", body: JSON.stringify(p.body) } }),
191
191
  };
192
+ // ── Local Transport (MAUI Android — runs IMAP in WebView via bridge) ──
193
+ class LocalTransport {
194
+ handlers = [];
195
+ localService = null;
196
+ async call(method, params) {
197
+ if (!this.localService) {
198
+ this.localService = await import("./local-service.js");
199
+ }
200
+ return this.localService.handleCall(method, params || {});
201
+ }
202
+ onEvent(handler) {
203
+ this.handlers.push(handler);
204
+ }
205
+ async connect() {
206
+ if (!this.localService) {
207
+ this.localService = await import("./local-service.js");
208
+ }
209
+ // Forward events from local service to transport handlers
210
+ this.localService.onEvent((event) => {
211
+ for (const h of this.handlers)
212
+ h(event);
213
+ });
214
+ }
215
+ }
192
216
  // ── Select transport ──
193
217
  const hasIPC = typeof mailxapi !== "undefined" && mailxapi?.isApp;
194
- const transport = hasIPC ? new IpcTransport() : new HttpTransport();
218
+ const hasBridge = typeof mailxapi !== "undefined" && mailxapi?.tcp !== undefined;
219
+ const hasEnsureServer = typeof mailxapi !== "undefined" && mailxapi?.ensureServer !== undefined;
220
+ // MAUI bridge (has tcp but no ensureServer) → LocalTransport
221
+ // Desktop native (has ensureServer) → IpcTransport
222
+ // Browser → HttpTransport
223
+ const transport = hasBridge && !hasEnsureServer
224
+ ? new LocalTransport()
225
+ : hasIPC ? new IpcTransport() : new HttpTransport();
195
226
  // ── Public API (unchanged signatures for all callers) ──
196
227
  export function getAccounts() {
197
228
  return transport.call("getAccounts");
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Local mail service — runs entirely in the WebView.
3
+ * Uses mailxapi.tcp bridge for IMAP, IndexedDB for storage.
4
+ * Implements the same MailxTransport interface so the client doesn't know the difference.
5
+ *
6
+ * This is the Android equivalent of the Express server + MailxService.
7
+ */
8
+ import * as store from "./local-store.js";
9
+ let settings = null;
10
+ const eventHandlers = [];
11
+ function emit(event) {
12
+ for (const h of eventHandlers) {
13
+ try {
14
+ h(event);
15
+ }
16
+ catch { /* ignore */ }
17
+ }
18
+ }
19
+ // ── IMAP via Bridge ──
20
+ /** Minimal IMAP client that works through the mailxapi.tcp bridge */
21
+ class BridgeImapClient {
22
+ streamId = null;
23
+ buffer = "";
24
+ dataResolve = null;
25
+ tagCounter = 0;
26
+ async connect(host, port, tls) {
27
+ this.streamId = await mailxapi.tcp.connect(host, port, tls);
28
+ mailxapi.tcp.onData(this.streamId, (data) => {
29
+ this.buffer += data;
30
+ if (this.dataResolve && this.buffer.includes("\r\n")) {
31
+ const resolve = this.dataResolve;
32
+ this.dataResolve = null;
33
+ resolve(this.buffer);
34
+ }
35
+ });
36
+ // Read greeting
37
+ return this.readLine();
38
+ }
39
+ nextTag() {
40
+ return `A${++this.tagCounter}`;
41
+ }
42
+ async readLine() {
43
+ // Check buffer first
44
+ const idx = this.buffer.indexOf("\r\n");
45
+ if (idx >= 0) {
46
+ const line = this.buffer.substring(0, idx);
47
+ this.buffer = this.buffer.substring(idx + 2);
48
+ return line;
49
+ }
50
+ // Wait for data
51
+ return new Promise(resolve => {
52
+ this.dataResolve = () => {
53
+ const idx = this.buffer.indexOf("\r\n");
54
+ if (idx >= 0) {
55
+ const line = this.buffer.substring(0, idx);
56
+ this.buffer = this.buffer.substring(idx + 2);
57
+ resolve(line);
58
+ }
59
+ };
60
+ });
61
+ }
62
+ /** Read all responses until we get the tagged response */
63
+ async readUntilTag(tag) {
64
+ const lines = [];
65
+ const timeout = setTimeout(() => {
66
+ // Force resolve on timeout
67
+ if (this.dataResolve) {
68
+ this.dataResolve("");
69
+ this.dataResolve = null;
70
+ }
71
+ }, 30000);
72
+ while (true) {
73
+ const line = await this.readLine();
74
+ lines.push(line);
75
+ if (line.startsWith(tag + " ")) {
76
+ clearTimeout(timeout);
77
+ return lines;
78
+ }
79
+ }
80
+ }
81
+ async command(cmd) {
82
+ const tag = this.nextTag();
83
+ const full = `${tag} ${cmd}\r\n`;
84
+ if (this.streamId == null)
85
+ throw new Error("Not connected");
86
+ await mailxapi.tcp.write(this.streamId, full);
87
+ return this.readUntilTag(tag);
88
+ }
89
+ async login(user, pass) {
90
+ const resp = await this.command(`LOGIN "${user}" "${pass}"`);
91
+ return resp.some(l => l.includes(" OK "));
92
+ }
93
+ async xoauth2(user, token) {
94
+ const authStr = btoa(`user=${user}\x01auth=Bearer ${token}\x01\x01`);
95
+ const resp = await this.command(`AUTHENTICATE XOAUTH2 ${authStr}`);
96
+ return resp.some(l => l.includes(" OK "));
97
+ }
98
+ async list() {
99
+ const resp = await this.command('LIST "" "*"');
100
+ const folders = [];
101
+ for (const line of resp) {
102
+ const m = line.match(/^\* LIST \(([^)]*)\) "([^"]*)" (?:"([^"]+)"|(\S+))$/);
103
+ if (m) {
104
+ folders.push({
105
+ flags: m[1] ? m[1].split(/\s+/).filter(Boolean) : [],
106
+ delimiter: m[2] || ".",
107
+ path: m[3] || m[4] || "",
108
+ });
109
+ }
110
+ }
111
+ return folders;
112
+ }
113
+ async select(mailbox) {
114
+ const resp = await this.command(`SELECT "${mailbox}"`);
115
+ let exists = 0;
116
+ for (const line of resp) {
117
+ const m = line.match(/^\* (\d+) EXISTS/);
118
+ if (m)
119
+ exists = parseInt(m[1]);
120
+ }
121
+ return { exists };
122
+ }
123
+ async status(mailbox) {
124
+ const resp = await this.command(`STATUS "${mailbox}" (MESSAGES)`);
125
+ for (const line of resp) {
126
+ const m = line.match(/MESSAGES\s+(\d+)/);
127
+ if (m)
128
+ return { messages: parseInt(m[1]) };
129
+ }
130
+ return { messages: 0 };
131
+ }
132
+ async fetchHeaders(range) {
133
+ const resp = await this.command(`UID FETCH ${range} (UID FLAGS ENVELOPE RFC822.SIZE INTERNALDATE)`);
134
+ const messages = [];
135
+ for (const line of resp) {
136
+ if (!line.startsWith("* ") || !line.includes("FETCH"))
137
+ continue;
138
+ const uid = line.match(/UID\s+(\d+)/)?.[1];
139
+ const flags = line.match(/FLAGS\s+\(([^)]*)\)/)?.[1] || "";
140
+ const size = line.match(/RFC822\.SIZE\s+(\d+)/)?.[1] || "0";
141
+ const dateMatch = line.match(/INTERNALDATE\s+"([^"]+)"/);
142
+ // Simplified envelope parsing — extract subject from the ENVELOPE
143
+ const envMatch = line.match(/ENVELOPE\s+\(/);
144
+ let subject = "", fromName = "", fromAddr = "", messageId = "";
145
+ if (envMatch) {
146
+ // Very simplified — real parsing would need the full tokenizer
147
+ const subMatch = line.match(/ENVELOPE\s+\("[^"]*"\s+"([^"]*)"/);
148
+ if (subMatch)
149
+ subject = subMatch[1];
150
+ }
151
+ if (uid) {
152
+ messages.push({
153
+ uid: parseInt(uid),
154
+ flags: flags.split(/\s+/).filter(Boolean),
155
+ size: parseInt(size),
156
+ date: dateMatch ? new Date(dateMatch[1]).getTime() : Date.now(),
157
+ subject,
158
+ fromName, fromAddr, messageId,
159
+ });
160
+ }
161
+ }
162
+ return messages;
163
+ }
164
+ async fetchBody(uid) {
165
+ const tag = this.nextTag();
166
+ const cmd = `${tag} UID FETCH ${uid} (BODY.PEEK[])\r\n`;
167
+ if (this.streamId == null)
168
+ throw new Error("Not connected");
169
+ await mailxapi.tcp.write(this.streamId, cmd);
170
+ // Read response including literal data
171
+ let allData = "";
172
+ const deadline = Date.now() + 30000;
173
+ while (Date.now() < deadline) {
174
+ const chunk = await this.readLine();
175
+ allData += chunk + "\r\n";
176
+ if (chunk.startsWith(tag + " "))
177
+ break;
178
+ }
179
+ return allData;
180
+ }
181
+ async logout() {
182
+ try {
183
+ await this.command("LOGOUT");
184
+ }
185
+ catch { /* ignore */ }
186
+ if (this.streamId != null) {
187
+ mailxapi.tcp.close(this.streamId);
188
+ this.streamId = null;
189
+ }
190
+ }
191
+ async close() {
192
+ try {
193
+ await this.command("CLOSE");
194
+ }
195
+ catch { /* ignore */ }
196
+ }
197
+ async storeFlags(uid, action, flags) {
198
+ await this.command(`UID STORE ${uid} ${action} (${flags.join(" ")})`);
199
+ }
200
+ async copy(uid, dest) {
201
+ await this.command(`UID COPY ${uid} "${dest}"`);
202
+ }
203
+ async expunge() {
204
+ await this.command("EXPUNGE");
205
+ }
206
+ async search(criteria) {
207
+ const resp = await this.command(`UID SEARCH ${criteria}`);
208
+ for (const line of resp) {
209
+ if (line.startsWith("* SEARCH")) {
210
+ return line.substring(9).trim().split(/\s+/).map(Number).filter(n => !isNaN(n));
211
+ }
212
+ }
213
+ return [];
214
+ }
215
+ }
216
+ // ── Service Methods ──
217
+ async function loadSettingsFromStorage() {
218
+ // Try IndexedDB meta first
219
+ const saved = await store.getMeta("settings");
220
+ if (saved)
221
+ return saved;
222
+ // Default empty
223
+ return { accounts: [] };
224
+ }
225
+ async function saveSettingsToStorage(s) {
226
+ settings = s;
227
+ await store.setMeta("settings", s);
228
+ }
229
+ async function syncAccount(account) {
230
+ const client = new BridgeImapClient();
231
+ try {
232
+ const greeting = await client.connect(account.imap.host, account.imap.port || 993, (account.imap.port || 993) === 993);
233
+ console.log(`[local-service] Connected to ${account.imap.host}: ${greeting}`);
234
+ // Authenticate
235
+ let authOk;
236
+ if (account.imap.auth === "oauth2") {
237
+ // TODO: OAuth token via bridge — for now skip
238
+ console.log("[local-service] OAuth not yet supported in bridge mode");
239
+ await client.logout();
240
+ return;
241
+ }
242
+ else {
243
+ authOk = await client.login(account.imap.user, account.imap.password || "");
244
+ }
245
+ if (!authOk) {
246
+ console.error("[local-service] Auth failed");
247
+ await client.logout();
248
+ return;
249
+ }
250
+ emit({ type: "syncProgress", accountId: account.id, phase: "folders", progress: 0 });
251
+ // Get folder list
252
+ const folders = await client.list();
253
+ let nextFolderId = (await store.getMeta("nextFolderId")) || 1;
254
+ for (const f of folders) {
255
+ const existing = (await store.getFolders(account.id)).find(ef => ef.path === f.path);
256
+ if (!existing) {
257
+ const flags = f.flags.map(fl => fl.toLowerCase());
258
+ let specialUse = "";
259
+ if (flags.includes("\\inbox") || f.path.toLowerCase() === "inbox")
260
+ specialUse = "inbox";
261
+ else if (flags.includes("\\sent"))
262
+ specialUse = "sent";
263
+ else if (flags.includes("\\trash"))
264
+ specialUse = "trash";
265
+ else if (flags.includes("\\drafts"))
266
+ specialUse = "drafts";
267
+ else if (flags.includes("\\junk"))
268
+ specialUse = "junk";
269
+ else if (flags.includes("\\archive"))
270
+ specialUse = "archive";
271
+ await store.upsertFolder({
272
+ id: nextFolderId++,
273
+ accountId: account.id,
274
+ path: f.path,
275
+ delimiter: f.delimiter,
276
+ specialUse,
277
+ totalCount: 0,
278
+ unreadCount: 0,
279
+ });
280
+ }
281
+ }
282
+ await store.setMeta("nextFolderId", nextFolderId);
283
+ emit({ type: "syncProgress", accountId: account.id, phase: "folders", progress: 100 });
284
+ // Sync inbox
285
+ const allFolders = await store.getFolders(account.id);
286
+ const inbox = allFolders.find(f => f.specialUse === "inbox");
287
+ if (inbox) {
288
+ await syncFolder(client, account.id, inbox);
289
+ }
290
+ // Emit folder counts
291
+ const updatedFolders = await store.getFolders(account.id);
292
+ const counts = {};
293
+ for (const f of updatedFolders) {
294
+ counts[f.id] = { total: f.totalCount, unread: f.unreadCount };
295
+ }
296
+ emit({ type: "folderCountsChanged", accountId: account.id, counts });
297
+ await client.logout();
298
+ }
299
+ catch (e) {
300
+ console.error(`[local-service] Sync error: ${e.message}`);
301
+ emit({ type: "error", message: `${account.id}: ${e.message}` });
302
+ }
303
+ }
304
+ async function syncFolder(client, accountId, folder) {
305
+ emit({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
306
+ const { exists } = await client.select(folder.path);
307
+ const highestUid = await store.getHighestUid(accountId, folder.id);
308
+ let msgs;
309
+ if (highestUid > 0) {
310
+ msgs = await client.fetchHeaders(`${highestUid + 1}:*`);
311
+ msgs = msgs.filter(m => m.uid > highestUid);
312
+ }
313
+ else {
314
+ // First sync — get recent messages
315
+ const uids = await client.search("ALL");
316
+ // Take last 200 UIDs (most recent)
317
+ const recent = uids.slice(-200);
318
+ if (recent.length > 0) {
319
+ msgs = await client.fetchHeaders(recent.join(","));
320
+ }
321
+ else {
322
+ msgs = [];
323
+ }
324
+ }
325
+ // Store messages
326
+ let newCount = 0;
327
+ for (const msg of msgs) {
328
+ await store.upsertMessage({
329
+ key: `${accountId}:${folder.id}:${msg.uid}`,
330
+ accountId,
331
+ folderId: folder.id,
332
+ uid: msg.uid,
333
+ messageId: msg.messageId || "",
334
+ date: msg.date,
335
+ subject: msg.subject || "(no subject)",
336
+ fromName: msg.fromName || "",
337
+ fromAddress: msg.fromAddr || "",
338
+ toJson: "[]",
339
+ ccJson: "[]",
340
+ flags: msg.flags.join(","),
341
+ size: msg.size,
342
+ hasAttachments: false,
343
+ preview: "",
344
+ bodyPath: "",
345
+ });
346
+ newCount++;
347
+ }
348
+ // Update folder counts
349
+ const allMsgs = await store.getMessages(accountId, folder.id, 1, 999999);
350
+ const total = allMsgs.total;
351
+ const unread = allMsgs.items.filter((m) => !m.flags.includes("\\Seen")).length;
352
+ await store.updateFolderCounts(folder.id, total, unread);
353
+ emit({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
354
+ if (newCount > 0) {
355
+ console.log(`[local-service] ${folder.path}: ${newCount} new messages`);
356
+ emit({ type: "folderCountsChanged", accountId, counts: { [folder.id]: { total, unread } } });
357
+ }
358
+ await client.close();
359
+ }
360
+ export async function handleCall(method, params = {}) {
361
+ switch (method) {
362
+ case "getAccounts": {
363
+ const accounts = await store.getAccounts();
364
+ if (!settings)
365
+ settings = await loadSettingsFromStorage();
366
+ return accounts.map(a => {
367
+ const cfg = settings.accounts.find(s => s.id === a.id);
368
+ return { ...a, label: cfg?.label, defaultSend: cfg?.defaultSend || false };
369
+ });
370
+ }
371
+ case "getFolders":
372
+ return store.getFolders(params.accountId);
373
+ case "getMessages":
374
+ return store.getMessages(params.accountId, params.folderId, params.page, params.pageSize);
375
+ case "getUnifiedInbox":
376
+ return store.getUnifiedInbox(params.page, params.pageSize);
377
+ case "getMessage": {
378
+ const env = await store.getMessageByUid(params.accountId, params.uid, params.folderId);
379
+ if (!env)
380
+ return { error: "not found" };
381
+ // Try to get body from IndexedDB
382
+ const body = await store.getBody(env.accountId, env.folderId, env.uid);
383
+ return { ...env, bodyHtml: "", bodyText: body || "[Body not yet downloaded]", hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: "" };
384
+ }
385
+ case "searchContacts":
386
+ return store.searchContacts(params.query || "");
387
+ case "getSyncPending":
388
+ return { pending: 0 };
389
+ case "triggerSync": {
390
+ if (!settings)
391
+ settings = await loadSettingsFromStorage();
392
+ for (const account of settings.accounts) {
393
+ if (account.enabled !== false) {
394
+ syncAccount(account).catch(e => console.error(`[local-service] ${e.message}`));
395
+ }
396
+ }
397
+ return { ok: true };
398
+ }
399
+ case "updateFlags":
400
+ // TODO: queue IMAP flag update
401
+ return { ok: true };
402
+ case "deleteMessage":
403
+ await store.deleteMessage(params.accountId, params.uid);
404
+ return { ok: true };
405
+ case "moveMessage":
406
+ // TODO: queue IMAP move
407
+ return { ok: true };
408
+ case "undeleteMessage":
409
+ return { ok: true };
410
+ case "searchMessages":
411
+ // Simple local search
412
+ return { items: [], total: 0, page: 1, pageSize: 50 };
413
+ case "allowRemoteContent":
414
+ return { ok: true };
415
+ case "markFolderRead":
416
+ return { ok: true };
417
+ case "restartServer":
418
+ case "rebuildServer":
419
+ return { ok: true };
420
+ case "createFolder":
421
+ case "renameFolder":
422
+ case "deleteFolder":
423
+ case "emptyFolder":
424
+ return { ok: true };
425
+ case "sendMessage":
426
+ case "saveDraft":
427
+ return { ok: true };
428
+ default:
429
+ console.warn(`[local-service] Unknown method: ${method}`);
430
+ return { error: `Unknown: ${method}` };
431
+ }
432
+ }
433
+ export function onEvent(handler) {
434
+ eventHandlers.push(handler);
435
+ }
436
+ /** Initialize the local service — load settings, start sync */
437
+ export async function initialize(initialSettings) {
438
+ await store.init();
439
+ if (initialSettings) {
440
+ await saveSettingsToStorage(initialSettings);
441
+ settings = initialSettings;
442
+ }
443
+ else {
444
+ settings = await loadSettingsFromStorage();
445
+ }
446
+ // Register accounts in IndexedDB
447
+ for (const account of settings.accounts) {
448
+ await store.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
449
+ }
450
+ emit({ type: "connected" });
451
+ // Start initial sync
452
+ for (const account of settings.accounts) {
453
+ if (account.enabled !== false) {
454
+ syncAccount(account).catch(e => console.error(`[local-service] ${e.message}`));
455
+ }
456
+ }
457
+ }
458
+ export function getSettings() {
459
+ return settings;
460
+ }
461
+ //# sourceMappingURL=local-service.js.map