@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.
- 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 +5 -5
- 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
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
|
-
//
|
|
896
|
+
// Browser mode — prompt for server URL
|
|
812
897
|
promptForServer();
|
|
813
898
|
}
|
|
814
899
|
}
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
|
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
|