@bobfrankston/mailx 1.0.256 → 1.0.260
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 +112 -0
- package/client/.msger-window.json +1 -1
- package/client/components/message-viewer.js +82 -7
- package/package.json +7 -6
- package/packages/mailx-imap/index.d.ts +6 -0
- package/packages/mailx-imap/index.js +100 -33
- package/packages/mailx-imap/package.json +2 -1
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -40
- package/packages/mailx-imap/providers/gmail-api.js +5 -336
- package/packages/mailx-imap/providers/types.d.ts +6 -59
- package/packages/mailx-imap/providers/types.js +5 -2
- package/packages/mailx-service/index.js +16 -2
- package/packages/mailx-store-web/android-bootstrap.js +8 -6
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -37
- package/packages/mailx-store-web/gmail-api-web.js +7 -298
- package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
- package/packages/mailx-store-web/imap-web-provider.js +2 -2
- package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
- package/packages/mailx-store-web/main-thread-host.js +287 -0
- package/packages/mailx-store-web/package.json +2 -1
- package/packages/mailx-store-web/provider-types.d.ts +4 -47
- package/packages/mailx-store-web/provider-types.js +3 -3
- package/packages/mailx-store-web/sync-manager.d.ts +61 -0
- package/packages/mailx-store-web/sync-manager.js +422 -0
- package/packages/mailx-store-web/worker-entry.d.ts +8 -0
- package/packages/mailx-store-web/worker-entry.js +187 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
- package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
|
@@ -1,50 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Back-compat re-export. Canonical types live in @bobfrankston/mailx-sync.
|
|
3
|
+
* Earlier this file was a hand-maintained copy of mailx-imap's types — that
|
|
4
|
+
* arrangement drifted. Now both sides import from one place.
|
|
5
5
|
*/
|
|
6
|
-
export
|
|
7
|
-
path: string;
|
|
8
|
-
name: string;
|
|
9
|
-
delimiter: string;
|
|
10
|
-
specialUse: string;
|
|
11
|
-
flags: string[];
|
|
12
|
-
}
|
|
13
|
-
export interface ProviderMessage {
|
|
14
|
-
uid: number;
|
|
15
|
-
messageId: string;
|
|
16
|
-
providerId: string;
|
|
17
|
-
date: Date | null;
|
|
18
|
-
subject: string;
|
|
19
|
-
from: {
|
|
20
|
-
name?: string;
|
|
21
|
-
address?: string;
|
|
22
|
-
}[];
|
|
23
|
-
to: {
|
|
24
|
-
name?: string;
|
|
25
|
-
address?: string;
|
|
26
|
-
}[];
|
|
27
|
-
cc: {
|
|
28
|
-
name?: string;
|
|
29
|
-
address?: string;
|
|
30
|
-
}[];
|
|
31
|
-
seen: boolean;
|
|
32
|
-
flagged: boolean;
|
|
33
|
-
answered: boolean;
|
|
34
|
-
draft: boolean;
|
|
35
|
-
size: number;
|
|
36
|
-
source: string;
|
|
37
|
-
}
|
|
38
|
-
export interface FetchOptions {
|
|
39
|
-
source?: boolean;
|
|
40
|
-
}
|
|
41
|
-
export interface MailProvider {
|
|
42
|
-
listFolders(): Promise<ProviderFolder[]>;
|
|
43
|
-
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
44
|
-
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
45
|
-
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
46
|
-
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
47
|
-
getUids(folder: string): Promise<number[]>;
|
|
48
|
-
close(): Promise<void>;
|
|
49
|
-
}
|
|
6
|
+
export type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "@bobfrankston/mailx-sync";
|
|
50
7
|
//# sourceMappingURL=provider-types.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Back-compat re-export. Canonical types live in @bobfrankston/mailx-sync.
|
|
3
|
+
* Earlier this file was a hand-maintained copy of mailx-imap's types — that
|
|
4
|
+
* arrangement drifted. Now both sides import from one place.
|
|
5
5
|
*/
|
|
6
6
|
export {};
|
|
7
7
|
//# sourceMappingURL=provider-types.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync manager — extracted from android-bootstrap.ts for use in both
|
|
3
|
+
* main-thread and Worker contexts.
|
|
4
|
+
*
|
|
5
|
+
* Platform-specific dependencies are injected:
|
|
6
|
+
* - emitEvent: posts events to the UI
|
|
7
|
+
* - vlog: verbose remote logging
|
|
8
|
+
* - createTcpTransport: factory for TCP transport (BridgeTcpTransport or WorkerTcpTransport)
|
|
9
|
+
*/
|
|
10
|
+
import type { WebMailxDB } from "./db.js";
|
|
11
|
+
import type { WebMessageStore } from "./web-message-store.js";
|
|
12
|
+
import type { WebSyncManager } from "./web-service.js";
|
|
13
|
+
import type { Folder, AccountConfig } from "@bobfrankston/mailx-types";
|
|
14
|
+
export interface SyncManagerDeps {
|
|
15
|
+
emitEvent: (event: any) => void;
|
|
16
|
+
vlog: (msg: string) => void;
|
|
17
|
+
createTcpTransport: () => any;
|
|
18
|
+
}
|
|
19
|
+
export declare class SyncManager implements WebSyncManager {
|
|
20
|
+
private db;
|
|
21
|
+
private bodyStore;
|
|
22
|
+
private providers;
|
|
23
|
+
private tokenProviders;
|
|
24
|
+
private deps;
|
|
25
|
+
constructor(db: WebMailxDB, bodyStore: WebMessageStore, deps: SyncManagerDeps);
|
|
26
|
+
on(_event: string, _handler: (...args: any[]) => void): void;
|
|
27
|
+
emit(event: string, ...args: any[]): void;
|
|
28
|
+
addAccount(account: AccountConfig): Promise<void>;
|
|
29
|
+
setTokenProvider(accountId: string, provider: () => Promise<string>): void;
|
|
30
|
+
private isGmailAccount;
|
|
31
|
+
private getProvider;
|
|
32
|
+
syncAll(): Promise<void>;
|
|
33
|
+
syncAccount(accountId: string): Promise<void>;
|
|
34
|
+
syncFolders(accountId: string): Promise<Folder[]>;
|
|
35
|
+
syncFolder(accountId: string, folderId: number): Promise<void>;
|
|
36
|
+
private storeProviderMessages;
|
|
37
|
+
fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Uint8Array | null>;
|
|
38
|
+
updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void>;
|
|
39
|
+
trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
40
|
+
trashMessages(accountId: string, messages: {
|
|
41
|
+
uid: number;
|
|
42
|
+
folderId: number;
|
|
43
|
+
}[]): Promise<void>;
|
|
44
|
+
moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise<void>;
|
|
45
|
+
moveMessages(accountId: string, messages: {
|
|
46
|
+
uid: number;
|
|
47
|
+
folderId: number;
|
|
48
|
+
}[], targetFolderId: number): Promise<void>;
|
|
49
|
+
moveMessageCrossAccount(): Promise<void>;
|
|
50
|
+
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
51
|
+
markFolderRead(folderId: number): Promise<void>;
|
|
52
|
+
emptyFolder(accountId: string, folderId: number): Promise<void>;
|
|
53
|
+
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
54
|
+
private sendViaSmtpDirect;
|
|
55
|
+
saveDraft(_accountId: string, _raw: string, _prevUid?: number, _draftId?: string): Promise<number | null>;
|
|
56
|
+
deleteDraft(_accountId: string, _uid: number): Promise<void>;
|
|
57
|
+
reauthenticate(_accountId: string): Promise<boolean>;
|
|
58
|
+
searchOnServer(_accountId: string, _query: string): Promise<any[]>;
|
|
59
|
+
syncAllContacts(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=sync-manager.d.ts.map
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync manager — extracted from android-bootstrap.ts for use in both
|
|
3
|
+
* main-thread and Worker contexts.
|
|
4
|
+
*
|
|
5
|
+
* Platform-specific dependencies are injected:
|
|
6
|
+
* - emitEvent: posts events to the UI
|
|
7
|
+
* - vlog: verbose remote logging
|
|
8
|
+
* - createTcpTransport: factory for TCP transport (BridgeTcpTransport or WorkerTcpTransport)
|
|
9
|
+
*/
|
|
10
|
+
import { GmailApiWebProvider } from "./gmail-api-web.js";
|
|
11
|
+
import { ImapWebProvider } from "./imap-web-provider.js";
|
|
12
|
+
import { SmtpClient } from "@bobfrankston/smtp-direct";
|
|
13
|
+
function toEmailAddress(addr) {
|
|
14
|
+
return { name: addr?.name || "", address: addr?.address || "" };
|
|
15
|
+
}
|
|
16
|
+
export class SyncManager {
|
|
17
|
+
db;
|
|
18
|
+
bodyStore;
|
|
19
|
+
providers = new Map();
|
|
20
|
+
tokenProviders = new Map();
|
|
21
|
+
deps;
|
|
22
|
+
constructor(db, bodyStore, deps) {
|
|
23
|
+
this.db = db;
|
|
24
|
+
this.bodyStore = bodyStore;
|
|
25
|
+
this.deps = deps;
|
|
26
|
+
}
|
|
27
|
+
on(_event, _handler) { }
|
|
28
|
+
emit(event, ...args) { this.deps.emitEvent({ type: event, ...args[0] }); }
|
|
29
|
+
async addAccount(account) {
|
|
30
|
+
this.deps.vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
|
|
31
|
+
this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
|
|
32
|
+
if (this.isGmailAccount(account)) {
|
|
33
|
+
const tokenProvider = this.tokenProviders.get(account.id);
|
|
34
|
+
if (tokenProvider) {
|
|
35
|
+
this.providers.set(account.id, new GmailApiWebProvider(tokenProvider));
|
|
36
|
+
console.log(`[sync] ${account.id}: Gmail API provider registered`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.warn(`[sync] ${account.id}: no token provider`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (account.imap?.host && account.imap?.user) {
|
|
43
|
+
try {
|
|
44
|
+
const provider = new ImapWebProvider({
|
|
45
|
+
server: account.imap.host,
|
|
46
|
+
port: account.imap.port || 993,
|
|
47
|
+
username: account.imap.user,
|
|
48
|
+
password: account.imap.password,
|
|
49
|
+
inactivityTimeout: 300000,
|
|
50
|
+
fetchChunkSize: 10,
|
|
51
|
+
fetchChunkSizeMax: 100,
|
|
52
|
+
}, this.deps.createTcpTransport);
|
|
53
|
+
this.providers.set(account.id, provider);
|
|
54
|
+
this.deps.vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`);
|
|
55
|
+
console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
this.deps.vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`);
|
|
59
|
+
console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
this.deps.vlog(`addAccount ${account.id}: no imap config, skipping`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
setTokenProvider(accountId, provider) {
|
|
67
|
+
this.tokenProviders.set(accountId, provider);
|
|
68
|
+
}
|
|
69
|
+
isGmailAccount(account) {
|
|
70
|
+
return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
|
|
71
|
+
}
|
|
72
|
+
getProvider(accountId) {
|
|
73
|
+
return this.providers.get(accountId) || null;
|
|
74
|
+
}
|
|
75
|
+
async syncAll() {
|
|
76
|
+
const accounts = this.db.getAccounts();
|
|
77
|
+
this.deps.vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map((a) => a.id).join(",")}`);
|
|
78
|
+
// Phase 1: Sync INBOX for every account first — user sees new mail fast.
|
|
79
|
+
for (const account of accounts) {
|
|
80
|
+
if (!this.providers.has(account.id))
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
const folders = await this.syncFolders(account.id);
|
|
84
|
+
const inbox = folders.find((f) => f.specialUse === "inbox");
|
|
85
|
+
if (inbox) {
|
|
86
|
+
await this.syncFolder(account.id, inbox.id);
|
|
87
|
+
this.deps.emitEvent({ type: "syncComplete", accountId: account.id });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
console.error(`[sync] ${account.id} inbox: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Phase 2: Remaining folders.
|
|
95
|
+
for (const account of accounts) {
|
|
96
|
+
if (!this.providers.has(account.id))
|
|
97
|
+
continue;
|
|
98
|
+
try {
|
|
99
|
+
const folders = this.db.getFolders(account.id);
|
|
100
|
+
const remaining = folders.filter((f) => f.specialUse !== "inbox");
|
|
101
|
+
for (const folder of remaining) {
|
|
102
|
+
try {
|
|
103
|
+
await this.syncFolder(account.id, folder.id);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
console.error(`[sync] Skip ${folder.path}: ${e.message}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.db.updateLastSync(account.id, Date.now());
|
|
110
|
+
this.deps.emitEvent({ type: "syncComplete", accountId: account.id });
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
console.error(`[sync] ${account.id}: ${e.message}`);
|
|
114
|
+
this.deps.vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
|
|
115
|
+
this.deps.emitEvent({ type: "syncError", accountId: account.id, error: e.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async syncAccount(accountId) {
|
|
120
|
+
const folders = await this.syncFolders(accountId);
|
|
121
|
+
for (const folder of folders) {
|
|
122
|
+
try {
|
|
123
|
+
await this.syncFolder(accountId, folder.id);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
console.error(`[sync] Skip ${folder.path}: ${e.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.db.updateLastSync(accountId, Date.now());
|
|
130
|
+
this.deps.emitEvent({ type: "syncComplete", accountId });
|
|
131
|
+
}
|
|
132
|
+
async syncFolders(accountId) {
|
|
133
|
+
const provider = this.getProvider(accountId);
|
|
134
|
+
if (!provider) {
|
|
135
|
+
const existing = this.db.getFolders(accountId);
|
|
136
|
+
this.deps.vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
|
|
137
|
+
return existing;
|
|
138
|
+
}
|
|
139
|
+
this.deps.emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
|
|
140
|
+
const providerFolders = await provider.listFolders();
|
|
141
|
+
for (const folder of providerFolders) {
|
|
142
|
+
const flags = folder.flags || [];
|
|
143
|
+
if (flags.some((f) => f.toLowerCase() === "\\noselect"))
|
|
144
|
+
continue;
|
|
145
|
+
this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter);
|
|
146
|
+
}
|
|
147
|
+
this.deps.emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 });
|
|
148
|
+
const dbFolders = this.db.getFolders(accountId);
|
|
149
|
+
this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
150
|
+
return dbFolders;
|
|
151
|
+
}
|
|
152
|
+
async syncFolder(accountId, folderId) {
|
|
153
|
+
const provider = this.getProvider(accountId);
|
|
154
|
+
if (!provider)
|
|
155
|
+
return;
|
|
156
|
+
const folders = this.db.getFolders(accountId);
|
|
157
|
+
const folder = folders.find((f) => f.id === folderId);
|
|
158
|
+
if (!folder)
|
|
159
|
+
return;
|
|
160
|
+
this.deps.emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
|
|
161
|
+
const highestUid = this.db.getHighestUid(accountId, folderId);
|
|
162
|
+
const startDate = new Date(Date.now() - 30 * 86400000);
|
|
163
|
+
let messages;
|
|
164
|
+
if (highestUid > 0) {
|
|
165
|
+
messages = await provider.fetchSince(folder.path, highestUid, { source: false });
|
|
166
|
+
messages = messages.filter((m) => m.uid > highestUid);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const tomorrow = new Date(Date.now() + 86400000);
|
|
170
|
+
messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
|
|
171
|
+
}
|
|
172
|
+
if (messages.length > 0) {
|
|
173
|
+
console.log(`[sync] ${folder.path}: ${messages.length} messages`);
|
|
174
|
+
this.storeProviderMessages(accountId, folderId, messages);
|
|
175
|
+
this.db.recalcFolderCounts(folderId);
|
|
176
|
+
this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
177
|
+
}
|
|
178
|
+
// Reconcile deletions
|
|
179
|
+
try {
|
|
180
|
+
const serverUidsArr = await provider.getUids(folder.path);
|
|
181
|
+
const serverUids = new Set(serverUidsArr);
|
|
182
|
+
const localUids = this.db.getUidsForFolder(accountId, folderId);
|
|
183
|
+
if (serverUidsArr.length === 0 && localUids.length > 0) {
|
|
184
|
+
console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const toDelete = localUids.filter((uid) => !serverUids.has(uid));
|
|
188
|
+
const RECONCILE_DELETE_THRESHOLD = 0.5;
|
|
189
|
+
if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
|
|
190
|
+
console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length}`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
for (const uid of toDelete) {
|
|
194
|
+
this.db.deleteMessage(accountId, uid);
|
|
195
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
196
|
+
}
|
|
197
|
+
if (toDelete.length > 0) {
|
|
198
|
+
console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
|
|
199
|
+
this.db.recalcFolderCounts(folderId);
|
|
200
|
+
this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
|
|
207
|
+
}
|
|
208
|
+
this.deps.emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
|
|
209
|
+
this.deps.emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
|
|
210
|
+
}
|
|
211
|
+
storeProviderMessages(accountId, folderId, messages) {
|
|
212
|
+
this.db.beginTransaction();
|
|
213
|
+
try {
|
|
214
|
+
for (const msg of messages) {
|
|
215
|
+
const flags = [];
|
|
216
|
+
if (msg.seen)
|
|
217
|
+
flags.push("\\Seen");
|
|
218
|
+
if (msg.flagged)
|
|
219
|
+
flags.push("\\Flagged");
|
|
220
|
+
if (msg.answered)
|
|
221
|
+
flags.push("\\Answered");
|
|
222
|
+
if (msg.draft)
|
|
223
|
+
flags.push("\\Draft");
|
|
224
|
+
const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
|
|
225
|
+
this.db.upsertMessage({
|
|
226
|
+
accountId, folderId, uid: msg.uid,
|
|
227
|
+
messageId: msg.messageId || "", inReplyTo: "", references: [],
|
|
228
|
+
date: msg.date ? msg.date.getTime() : Date.now(),
|
|
229
|
+
subject: msg.subject || "",
|
|
230
|
+
from: toEmailAddress(msg.from?.[0]),
|
|
231
|
+
to: msg.to.map((a) => toEmailAddress(a)),
|
|
232
|
+
cc: msg.cc.map((a) => toEmailAddress(a)),
|
|
233
|
+
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
this.db.commitTransaction();
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
this.db.rollbackTransaction();
|
|
240
|
+
console.error(`[sync] storeMessages error: ${e.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async fetchMessageBody(accountId, folderId, uid) {
|
|
244
|
+
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
245
|
+
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
246
|
+
}
|
|
247
|
+
const provider = this.getProvider(accountId);
|
|
248
|
+
if (!provider)
|
|
249
|
+
return null;
|
|
250
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
251
|
+
const bp = envelope?.bodyPath || "";
|
|
252
|
+
let msg = null;
|
|
253
|
+
if (bp.startsWith("gmail:") && provider.fetchById) {
|
|
254
|
+
const providerId = bp.substring(6);
|
|
255
|
+
msg = await provider.fetchById(providerId, { source: true });
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const folders = this.db.getFolders(accountId);
|
|
259
|
+
const folder = folders.find((f) => f.id === folderId);
|
|
260
|
+
if (!folder)
|
|
261
|
+
return null;
|
|
262
|
+
msg = await provider.fetchOne(folder.path, uid, { source: true });
|
|
263
|
+
}
|
|
264
|
+
if (!msg?.source) {
|
|
265
|
+
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const raw = new TextEncoder().encode(msg.source);
|
|
269
|
+
await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
270
|
+
this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
|
|
271
|
+
return raw;
|
|
272
|
+
}
|
|
273
|
+
async updateFlagsLocal(accountId, uid, folderId, flags) {
|
|
274
|
+
this.db.updateMessageFlags(accountId, uid, flags);
|
|
275
|
+
this.db.recalcFolderCounts(folderId);
|
|
276
|
+
this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
|
|
277
|
+
this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
278
|
+
}
|
|
279
|
+
async trashMessage(accountId, folderId, uid) {
|
|
280
|
+
this.db.deleteMessage(accountId, uid);
|
|
281
|
+
this.db.queueSyncAction(accountId, "trash", uid, folderId);
|
|
282
|
+
this.deps.emitEvent({ type: "messageDeleted", accountId, folderId, uid });
|
|
283
|
+
this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
284
|
+
}
|
|
285
|
+
async trashMessages(accountId, messages) {
|
|
286
|
+
for (const m of messages)
|
|
287
|
+
await this.trashMessage(accountId, m.folderId, m.uid);
|
|
288
|
+
}
|
|
289
|
+
async moveMessage(accountId, uid, folderId, targetFolderId) {
|
|
290
|
+
this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId });
|
|
291
|
+
this.deps.emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid });
|
|
292
|
+
}
|
|
293
|
+
async moveMessages(accountId, messages, targetFolderId) {
|
|
294
|
+
for (const m of messages)
|
|
295
|
+
await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId);
|
|
296
|
+
}
|
|
297
|
+
async moveMessageCrossAccount() {
|
|
298
|
+
throw new Error("Cross-account move not supported on mobile");
|
|
299
|
+
}
|
|
300
|
+
async undeleteMessage(accountId, uid, folderId) {
|
|
301
|
+
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
302
|
+
}
|
|
303
|
+
async markFolderRead(folderId) {
|
|
304
|
+
this.db.markFolderRead(folderId);
|
|
305
|
+
}
|
|
306
|
+
async emptyFolder(accountId, folderId) {
|
|
307
|
+
const uids = this.db.getUidsForFolder(accountId, folderId);
|
|
308
|
+
for (const uid of uids) {
|
|
309
|
+
this.db.deleteMessage(accountId, uid);
|
|
310
|
+
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
311
|
+
}
|
|
312
|
+
this.db.recalcFolderCounts(folderId);
|
|
313
|
+
this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
|
|
314
|
+
}
|
|
315
|
+
queueOutgoingLocal(accountId, rawMessage) {
|
|
316
|
+
const provider = this.getProvider(accountId);
|
|
317
|
+
if (provider && typeof provider.sendRaw === "function") {
|
|
318
|
+
provider.sendRaw(rawMessage)
|
|
319
|
+
.then((result) => {
|
|
320
|
+
console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
|
|
321
|
+
this.deps.emitEvent({ type: "sendComplete", accountId, messageId: result.id });
|
|
322
|
+
})
|
|
323
|
+
.catch((e) => {
|
|
324
|
+
console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
|
|
325
|
+
this.deps.emitEvent({ type: "sendError", accountId, error: e.message });
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const accounts = this.db.getAccountConfigs();
|
|
330
|
+
const row = accounts.find((a) => a.id === accountId);
|
|
331
|
+
if (!row) {
|
|
332
|
+
const e = "Unknown account";
|
|
333
|
+
this.deps.emitEvent({ type: "sendError", accountId, error: e });
|
|
334
|
+
throw new Error(e);
|
|
335
|
+
}
|
|
336
|
+
let account;
|
|
337
|
+
try {
|
|
338
|
+
account = JSON.parse(row.configJson);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
const e = "Account config malformed";
|
|
342
|
+
this.deps.emitEvent({ type: "sendError", accountId, error: e });
|
|
343
|
+
throw new Error(e);
|
|
344
|
+
}
|
|
345
|
+
if (!account.smtp) {
|
|
346
|
+
const e = "No SMTP config for this account";
|
|
347
|
+
this.deps.emitEvent({ type: "sendError", accountId, error: e });
|
|
348
|
+
throw new Error(e);
|
|
349
|
+
}
|
|
350
|
+
this.sendViaSmtpDirect(accountId, account, rawMessage)
|
|
351
|
+
.then((result) => {
|
|
352
|
+
console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
|
|
353
|
+
this.deps.emitEvent({ type: "sendComplete", accountId });
|
|
354
|
+
})
|
|
355
|
+
.catch((e) => {
|
|
356
|
+
console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
|
|
357
|
+
this.deps.emitEvent({ type: "sendError", accountId, error: e.message });
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
async sendViaSmtpDirect(accountId, account, raw) {
|
|
361
|
+
const smtp = account.smtp;
|
|
362
|
+
const smtpPort = smtp.port || 587;
|
|
363
|
+
const smtpHost = smtp.host || account.imap?.host;
|
|
364
|
+
if (!smtpHost)
|
|
365
|
+
throw new Error("No SMTP host");
|
|
366
|
+
const smtpUser = smtp.user || account.imap?.user || account.email;
|
|
367
|
+
const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
|
|
368
|
+
let auth;
|
|
369
|
+
if (authType === "password") {
|
|
370
|
+
const pass = smtp.password || account.imap?.password;
|
|
371
|
+
if (!pass)
|
|
372
|
+
throw new Error("SMTP password not configured");
|
|
373
|
+
auth = { method: "PLAIN", user: smtpUser, pass };
|
|
374
|
+
}
|
|
375
|
+
else if (authType === "oauth2") {
|
|
376
|
+
const tp = this.tokenProviders.get(accountId);
|
|
377
|
+
if (!tp)
|
|
378
|
+
throw new Error("OAuth token provider not registered");
|
|
379
|
+
const token = await tp();
|
|
380
|
+
auth = { method: "XOAUTH2", user: smtpUser, token };
|
|
381
|
+
}
|
|
382
|
+
const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
|
|
383
|
+
const toMatch = raw.match(/^To:\s*(.+)$/mi);
|
|
384
|
+
const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
|
|
385
|
+
const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
|
|
386
|
+
const fromMatch = raw.match(/^From:\s*(.+)$/mi);
|
|
387
|
+
const recipients = [
|
|
388
|
+
...(toMatch ? parseAddrs(toMatch[1]) : []),
|
|
389
|
+
...(ccMatch ? parseAddrs(ccMatch[1]) : []),
|
|
390
|
+
...(bccMatch ? parseAddrs(bccMatch[1]) : []),
|
|
391
|
+
];
|
|
392
|
+
const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
|
|
393
|
+
if (recipients.length === 0)
|
|
394
|
+
throw new Error("No recipients");
|
|
395
|
+
const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
|
|
396
|
+
const client = new SmtpClient({
|
|
397
|
+
host: smtpHost,
|
|
398
|
+
port: smtpPort,
|
|
399
|
+
secure: smtpPort === 465,
|
|
400
|
+
auth,
|
|
401
|
+
localname: "mailx-android",
|
|
402
|
+
}, this.deps.createTcpTransport);
|
|
403
|
+
try {
|
|
404
|
+
await client.connect();
|
|
405
|
+
return await client.sendMail({ from: sender, to: recipients }, rawToSend);
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
try {
|
|
409
|
+
await client.quit();
|
|
410
|
+
}
|
|
411
|
+
catch { /* ignore */ }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async saveDraft(_accountId, _raw, _prevUid, _draftId) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
async deleteDraft(_accountId, _uid) { }
|
|
418
|
+
async reauthenticate(_accountId) { return false; }
|
|
419
|
+
async searchOnServer(_accountId, _query) { return []; }
|
|
420
|
+
async syncAllContacts() { }
|
|
421
|
+
}
|
|
422
|
+
//# sourceMappingURL=sync-manager.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker entry point — runs the entire mailx service layer off the main thread.
|
|
3
|
+
*
|
|
4
|
+
* Hosts: wa-sqlite DB, sync manager, providers, service, settings.
|
|
5
|
+
* Main thread only handles UI rendering and proxies TCP/native bridge calls.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=worker-entry.d.ts.map
|