@bobfrankston/mailx 1.0.395 → 1.0.405
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/client/android.html +12 -1
- package/client/app.js +44 -4
- package/client/components/alarms.js +286 -0
- package/client/components/calendar-sidebar.js +43 -7
- package/client/components/message-list.js +215 -16
- package/client/components/message-viewer.js +120 -18
- package/client/compose/compose.js +137 -41
- package/client/index.html +12 -1
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +251 -6
- package/package.json +1 -1
- package/packages/mailx-imap/index.js +18 -2
- package/packages/mailx-server/index.js +29 -0
- package/packages/mailx-service/index.d.ts +4 -0
- package/packages/mailx-service/index.js +6 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +8 -0
- package/packages/mailx-store/db.js +34 -1
- package/packages/mailx-store-web/android-bootstrap.js +193 -93
- package/packages/mailx-store-web/db.d.ts +4 -0
- package/packages/mailx-store-web/db.js +25 -0
- package/packages/mailx-store-web/sync-manager.d.ts +7 -0
- package/packages/mailx-store-web/sync-manager.js +55 -0
- package/packages/mailx-store-web/web-service.d.ts +4 -0
- package/packages/mailx-store-web/web-service.js +7 -0
- package/tdview.cmd +1 -0
- package/unwedge.cmd +1 -0
|
@@ -158,6 +158,10 @@ export declare class MailxService {
|
|
|
158
158
|
}>;
|
|
159
159
|
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
160
160
|
searchContacts(query: string): any[];
|
|
161
|
+
/** Q49: boolean hint for compose to auto-expand Cc when replying to this
|
|
162
|
+
* address. True when at least one past sent message to the same recipient
|
|
163
|
+
* had a non-empty Cc field. */
|
|
164
|
+
hasCcHistoryTo(email: string): boolean;
|
|
161
165
|
syncGoogleContacts(): Promise<void>;
|
|
162
166
|
seedContacts(): number;
|
|
163
167
|
/** Explicit add to address book — used by the right-click "Add to contacts"
|
|
@@ -1351,6 +1351,12 @@ export class MailxService {
|
|
|
1351
1351
|
return [];
|
|
1352
1352
|
return this.db.searchContacts(query);
|
|
1353
1353
|
}
|
|
1354
|
+
/** Q49: boolean hint for compose to auto-expand Cc when replying to this
|
|
1355
|
+
* address. True when at least one past sent message to the same recipient
|
|
1356
|
+
* had a non-empty Cc field. */
|
|
1357
|
+
hasCcHistoryTo(email) {
|
|
1358
|
+
return this.db.hasCcHistoryTo(email);
|
|
1359
|
+
}
|
|
1354
1360
|
async syncGoogleContacts() {
|
|
1355
1361
|
await this.imapManager.syncAllContacts();
|
|
1356
1362
|
}
|
|
@@ -140,6 +140,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
140
140
|
return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
|
|
141
141
|
case "searchContacts":
|
|
142
142
|
return svc.searchContacts(p.query);
|
|
143
|
+
case "hasCcHistoryTo":
|
|
144
|
+
return { hasCc: svc.hasCcHistoryTo(p.email) };
|
|
143
145
|
case "addContact":
|
|
144
146
|
return { ok: svc.addContact(p.name, p.email) };
|
|
145
147
|
case "listContacts":
|
|
@@ -19,6 +19,14 @@ export declare class MailxDB {
|
|
|
19
19
|
hasSentMessage(messageId: string): boolean;
|
|
20
20
|
/** Record a successfully sent message so future attempts are skipped. */
|
|
21
21
|
recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
|
|
22
|
+
/** Q49 heuristic: has the user ever sent a message to `recipientEmail`
|
|
23
|
+
* that had a non-empty Cc field? Used by compose to auto-expand the Cc
|
|
24
|
+
* input when replying to someone who customarily gets Cc'd with others.
|
|
25
|
+
* Query scans only Sent folders (special_use='sent') and matches the
|
|
26
|
+
* recipient's address inside `to_json` via LIKE. No special index — the
|
|
27
|
+
* Sent folder's row count is typically a few thousand at most; acceptable
|
|
28
|
+
* on the compose-open path. */
|
|
29
|
+
hasCcHistoryTo(recipientEmail: string): boolean;
|
|
22
30
|
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
23
31
|
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
24
32
|
* can't check against future sync results anyway. */
|
|
@@ -338,6 +338,32 @@ export class MailxDB {
|
|
|
338
338
|
console.error(` [sent_log] failed to record ${messageId}: ${e.message}`);
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
|
+
/** Q49 heuristic: has the user ever sent a message to `recipientEmail`
|
|
342
|
+
* that had a non-empty Cc field? Used by compose to auto-expand the Cc
|
|
343
|
+
* input when replying to someone who customarily gets Cc'd with others.
|
|
344
|
+
* Query scans only Sent folders (special_use='sent') and matches the
|
|
345
|
+
* recipient's address inside `to_json` via LIKE. No special index — the
|
|
346
|
+
* Sent folder's row count is typically a few thousand at most; acceptable
|
|
347
|
+
* on the compose-open path. */
|
|
348
|
+
hasCcHistoryTo(recipientEmail) {
|
|
349
|
+
const email = (recipientEmail || "").trim().toLowerCase();
|
|
350
|
+
if (!email)
|
|
351
|
+
return false;
|
|
352
|
+
try {
|
|
353
|
+
const row = this.db.prepare(`
|
|
354
|
+
SELECT 1 FROM messages m
|
|
355
|
+
JOIN folders f ON m.folder_id = f.id
|
|
356
|
+
WHERE f.special_use = 'sent'
|
|
357
|
+
AND lower(m.to_json) LIKE ?
|
|
358
|
+
AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
|
|
359
|
+
LIMIT 1
|
|
360
|
+
`).get(`%"${email}"%`);
|
|
361
|
+
return !!row;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
341
367
|
// ── Tombstones (local-delete record so server echo can't resurrect) ──
|
|
342
368
|
/** Mark a Message-ID as locally-deleted for an account. No-op if messageId
|
|
343
369
|
* is empty (e.g. provider stripped the header) — without a stable id we
|
|
@@ -853,7 +879,9 @@ export class MailxDB {
|
|
|
853
879
|
const rows = this.db.prepare(`SELECT m.*, EXISTS(
|
|
854
880
|
SELECT 1 FROM sync_actions sa
|
|
855
881
|
WHERE sa.account_id = m.account_id AND sa.uid = m.uid
|
|
856
|
-
) AS pending
|
|
882
|
+
) AS pending,
|
|
883
|
+
(SELECT COUNT(DISTINCT account_id) FROM messages m2
|
|
884
|
+
WHERE m2.message_id = m.message_id AND m.message_id != '') AS dupeCount
|
|
857
885
|
FROM messages m WHERE m.folder_id IN (${placeholders})
|
|
858
886
|
ORDER BY m.date DESC LIMIT ? OFFSET ?`).all(...folderIds, pageSize, offset);
|
|
859
887
|
const items = rows.map(r => ({
|
|
@@ -876,6 +904,11 @@ export class MailxDB {
|
|
|
876
904
|
preview: r.preview,
|
|
877
905
|
bodyPath: r.body_path || "",
|
|
878
906
|
pending: !!r.pending,
|
|
907
|
+
// >=2 means the same message-id exists under another account in
|
|
908
|
+
// the local DB (delivered to both accounts, or a mailing-list
|
|
909
|
+
// Bcc). The unified-inbox UI shows a small ⇆ badge on these
|
|
910
|
+
// rows so the user knows "this is a copy of the same message".
|
|
911
|
+
dupeCount: r.dupeCount | 0,
|
|
879
912
|
}));
|
|
880
913
|
return { items, total, page, pageSize };
|
|
881
914
|
}
|
|
@@ -174,9 +174,19 @@ class AndroidSyncManager {
|
|
|
174
174
|
const THROTTLE_MS = 150;
|
|
175
175
|
const RATE_LIMIT_PAUSE_MS = 30000;
|
|
176
176
|
const ERROR_BUDGET = 10;
|
|
177
|
+
const CONCURRENCY = 2; // S62: 2 in-flight per account
|
|
177
178
|
let totalFetched = 0;
|
|
178
179
|
let errors = 0;
|
|
179
180
|
let announced = false;
|
|
181
|
+
// S62: INBOX always first. Within each folder the DB returns rows
|
|
182
|
+
// most-recent-first (PRIMARY KEY order), so newest unfetched INBOX
|
|
183
|
+
// mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't
|
|
184
|
+
// starve INBOX any more.
|
|
185
|
+
const folderPriority = (folderId) => {
|
|
186
|
+
const f = this.db.getFolders(accountId).find((x) => x.id === folderId);
|
|
187
|
+
return f?.specialUse === "inbox" ? 0 : 1;
|
|
188
|
+
};
|
|
189
|
+
let rateLimitCooldownUntil = 0;
|
|
180
190
|
while (true) {
|
|
181
191
|
const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
|
|
182
192
|
if (missing.length === 0)
|
|
@@ -186,46 +196,70 @@ class AndroidSyncManager {
|
|
|
186
196
|
vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
|
|
187
197
|
announced = true;
|
|
188
198
|
}
|
|
199
|
+
// Sort this batch INBOX-first. getMessagesWithoutBody doesn't
|
|
200
|
+
// know the priority, and re-querying per folder would multiply
|
|
201
|
+
// the SELECTs. One in-memory sort is cheap.
|
|
202
|
+
missing.sort((a, b) => folderPriority(a.folderId) - folderPriority(b.folderId));
|
|
189
203
|
let progressedThisBatch = false;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
let batchAborted = false;
|
|
205
|
+
// Bounded-concurrency worker pool. Each worker pulls the next
|
|
206
|
+
// unclaimed item from `missing`. Shared flags (errors,
|
|
207
|
+
// rateLimitCooldownUntil, progressedThisBatch) are updated
|
|
208
|
+
// inside the loop — sql.js is single-threaded so there's no
|
|
209
|
+
// actual race on reads/writes.
|
|
210
|
+
let cursor = 0;
|
|
211
|
+
const worker = async () => {
|
|
212
|
+
while (cursor < missing.length) {
|
|
213
|
+
if (batchAborted)
|
|
214
|
+
return;
|
|
215
|
+
if (errors >= ERROR_BUDGET)
|
|
216
|
+
return;
|
|
217
|
+
const idx = cursor++;
|
|
218
|
+
const m = missing[idx];
|
|
219
|
+
// Honor rate-limit cooldown across workers.
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
if (rateLimitCooldownUntil > now) {
|
|
222
|
+
await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now));
|
|
204
223
|
}
|
|
205
|
-
|
|
206
|
-
|
|
224
|
+
if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
|
|
225
|
+
this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
|
|
226
|
+
progressedThisBatch = true;
|
|
227
|
+
continue;
|
|
207
228
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
229
|
+
try {
|
|
230
|
+
const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
|
|
231
|
+
if (result) {
|
|
232
|
+
totalFetched++;
|
|
233
|
+
progressedThisBatch = true;
|
|
234
|
+
emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
errors++;
|
|
238
|
+
}
|
|
215
239
|
}
|
|
216
|
-
|
|
217
|
-
|
|
240
|
+
catch (e) {
|
|
241
|
+
errors++;
|
|
242
|
+
const msg = String(e?.message || "");
|
|
243
|
+
if (/429|rate|too many/i.test(msg)) {
|
|
244
|
+
console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
|
|
245
|
+
rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
|
|
249
|
+
}
|
|
218
250
|
}
|
|
251
|
+
// Throttle kept per-request to spread load on flaky
|
|
252
|
+
// phone networks; concurrency-2 means effective request
|
|
253
|
+
// rate is ~1 per THROTTLE_MS/2.
|
|
254
|
+
await new Promise(r => setTimeout(r, THROTTLE_MS));
|
|
219
255
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
256
|
+
};
|
|
257
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker()));
|
|
258
|
+
if (errors >= ERROR_BUDGET) {
|
|
259
|
+
console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
|
|
260
|
+
vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
|
|
261
|
+
return;
|
|
226
262
|
}
|
|
227
|
-
// If a full batch made no progress, bail out to avoid an infinite
|
|
228
|
-
// loop on messages the server can't deliver.
|
|
229
263
|
if (!progressedThisBatch) {
|
|
230
264
|
console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
|
|
231
265
|
break;
|
|
@@ -447,6 +481,58 @@ class AndroidSyncManager {
|
|
|
447
481
|
async undeleteMessage(accountId, uid, folderId) {
|
|
448
482
|
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
449
483
|
}
|
|
484
|
+
/** Q112: drain queued move/flag/trash actions to the provider. Android is
|
|
485
|
+
* standalone — it pushes state changes directly to Gmail (or other
|
|
486
|
+
* provider) the same way desktop does. Called from the periodic 2-min
|
|
487
|
+
* tick above. `send` actions drain separately via `processSendQueue`. */
|
|
488
|
+
async processSyncActions(accountId) {
|
|
489
|
+
const provider = this.providers.get(accountId);
|
|
490
|
+
if (!provider)
|
|
491
|
+
return;
|
|
492
|
+
const pending = this.db.getPendingSyncActions(accountId)
|
|
493
|
+
.filter((a) => a.action !== "send");
|
|
494
|
+
if (pending.length === 0)
|
|
495
|
+
return;
|
|
496
|
+
const folders = this.db.getFolders(accountId);
|
|
497
|
+
const folderPath = (id) => {
|
|
498
|
+
const f = folders.find((x) => x.id === id);
|
|
499
|
+
return f?.path || null;
|
|
500
|
+
};
|
|
501
|
+
for (const p of pending) {
|
|
502
|
+
const path = folderPath(p.folderId);
|
|
503
|
+
if (!path) {
|
|
504
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
if (p.action === "flags" && typeof provider.setFlags === "function") {
|
|
509
|
+
await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
|
|
510
|
+
}
|
|
511
|
+
else if (p.action === "trash" && typeof provider.trashMessage === "function") {
|
|
512
|
+
await provider.trashMessage(path, p.uid);
|
|
513
|
+
}
|
|
514
|
+
else if (p.action === "move" && typeof provider.moveMessage === "function") {
|
|
515
|
+
const toId = p.targetFolderId;
|
|
516
|
+
const toPath = folderPath(toId);
|
|
517
|
+
if (!toPath) {
|
|
518
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
await provider.moveMessage(path, p.uid, toPath);
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
this.db.completeSyncActionByUid(accountId, p.action, p.uid);
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
const msg = e?.message || String(e);
|
|
531
|
+
console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
|
|
532
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
450
536
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
451
537
|
// Local-first: PERSIST to sync_actions before attempting the network
|
|
452
538
|
// send, so a crash / offline / process kill between now and SMTP ACK
|
|
@@ -858,76 +944,87 @@ export async function initAndroid() {
|
|
|
858
944
|
}
|
|
859
945
|
await syncManager.addAccount(account);
|
|
860
946
|
}
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const folderId = await findGDriveMailxFolder(gmailTokenProvider);
|
|
867
|
-
if (!folderId) {
|
|
868
|
-
console.warn("[android] GDrive mailx folder not found");
|
|
869
|
-
}
|
|
870
|
-
else {
|
|
871
|
-
setGDriveFolderId(folderId);
|
|
872
|
-
console.log(`[android] GDrive mailx folder: ${folderId}`);
|
|
873
|
-
// DEBUG: list all files in the folder
|
|
874
|
-
try {
|
|
875
|
-
const tk = await gmailTokenProvider();
|
|
876
|
-
const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
|
|
877
|
-
if (lr.ok) {
|
|
878
|
-
const ld = await lr.json();
|
|
879
|
-
const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
|
|
880
|
-
console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
|
|
881
|
-
}
|
|
882
|
-
else {
|
|
883
|
-
console.warn(`[android] List folder failed: ${lr.status}`);
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
catch (e) {
|
|
887
|
-
console.warn(`[android] List debug: ${e.message}`);
|
|
888
|
-
}
|
|
889
|
-
// Read accounts directly from GDrive (bypass IndexedDB cache)
|
|
890
|
-
console.log("[android] Reading accounts.jsonc from GDrive...");
|
|
891
|
-
const gdriveAccounts = await loadAccountsFromCloud();
|
|
892
|
-
console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
|
|
893
|
-
if (gdriveAccounts.length > 0) {
|
|
894
|
-
// Use canonical GDrive accounts (upsert handles overwrites)
|
|
895
|
-
accounts = gdriveAccounts;
|
|
896
|
-
for (const account of accounts) {
|
|
897
|
-
vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
|
|
898
|
-
if (!account.enabled) {
|
|
899
|
-
vlog(`init: ${account.id} disabled, skipping`);
|
|
900
|
-
continue;
|
|
901
|
-
}
|
|
902
|
-
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
903
|
-
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
904
|
-
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
905
|
-
}
|
|
906
|
-
await syncManager.addAccount(account);
|
|
907
|
-
}
|
|
908
|
-
console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
|
|
909
|
-
}
|
|
910
|
-
// Register this Android device in clients.jsonc
|
|
911
|
-
await registerDeviceInGDrive(gmailTokenProvider, folderId, accounts.map(a => a.id));
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
catch (e) {
|
|
915
|
-
console.warn(`[android] GDrive access failed: ${e.message}`);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
947
|
+
// Install the mailxapi bridge + drain pending queues IMMEDIATELY using
|
|
948
|
+
// the local-cache account list. UI shouldn't wait on GDrive (which can
|
|
949
|
+
// be slow on cold network) before becoming actionable. GDrive
|
|
950
|
+
// reconciliation (below) runs in the background and re-registers fresh
|
|
951
|
+
// accounts when it returns.
|
|
918
952
|
installBridge();
|
|
919
|
-
// Drain any stranded send-queue entries BEFORE first sync. A message
|
|
920
|
-
// queued in a prior session (offline, crashed mid-send, process killed)
|
|
921
|
-
// gets a retry as soon as we have accounts registered. Desktop parity.
|
|
922
953
|
for (const account of accounts) {
|
|
923
954
|
if (!account.enabled)
|
|
924
955
|
continue;
|
|
925
956
|
syncManager.processSendQueue(account.id)
|
|
926
957
|
.catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
|
|
958
|
+
syncManager.processSyncActions(account.id)
|
|
959
|
+
.catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
|
|
927
960
|
}
|
|
961
|
+
// First sync from local accounts on a tiny delay so the UI gets to paint.
|
|
928
962
|
setTimeout(() => {
|
|
929
963
|
syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
|
|
930
964
|
}, 1000);
|
|
965
|
+
// GDrive reconciliation runs in the background — accounts.jsonc on the
|
|
966
|
+
// shared cloud may have been edited from another device, so we re-pull
|
|
967
|
+
// and re-register if it differs from the cached copy. The user can
|
|
968
|
+
// already see and use mail by the time this resolves.
|
|
969
|
+
if (gmailTokenProvider) {
|
|
970
|
+
const tp = gmailTokenProvider;
|
|
971
|
+
(async () => {
|
|
972
|
+
setGDriveTokenProvider(tp);
|
|
973
|
+
try {
|
|
974
|
+
console.log("[android] Looking up GDrive mailx folder…");
|
|
975
|
+
const folderId = await findGDriveMailxFolder(tp);
|
|
976
|
+
if (!folderId) {
|
|
977
|
+
console.warn("[android] GDrive mailx folder not found");
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
setGDriveFolderId(folderId);
|
|
981
|
+
console.log(`[android] GDrive mailx folder: ${folderId}`);
|
|
982
|
+
// DEBUG: list all files in the folder
|
|
983
|
+
try {
|
|
984
|
+
const tk = await tp();
|
|
985
|
+
const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
|
|
986
|
+
if (lr.ok) {
|
|
987
|
+
const ld = await lr.json();
|
|
988
|
+
const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
|
|
989
|
+
console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
console.warn(`[android] List folder failed: ${lr.status}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
catch (e) {
|
|
996
|
+
console.warn(`[android] List debug: ${e.message}`);
|
|
997
|
+
}
|
|
998
|
+
// Read accounts directly from GDrive (bypass IndexedDB cache)
|
|
999
|
+
console.log("[android] Reading accounts.jsonc from GDrive...");
|
|
1000
|
+
const gdriveAccounts = await loadAccountsFromCloud();
|
|
1001
|
+
console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
|
|
1002
|
+
if (gdriveAccounts.length > 0) {
|
|
1003
|
+
// Use canonical GDrive accounts (upsert handles overwrites)
|
|
1004
|
+
accounts = gdriveAccounts;
|
|
1005
|
+
for (const account of accounts) {
|
|
1006
|
+
vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
|
|
1007
|
+
if (!account.enabled) {
|
|
1008
|
+
vlog(`init: ${account.id} disabled, skipping`);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
const domain = account.email?.split("@")[1]?.toLowerCase() || "";
|
|
1012
|
+
if (domain === "gmail.com" || domain === "googlemail.com") {
|
|
1013
|
+
syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
|
|
1014
|
+
}
|
|
1015
|
+
await syncManager.addAccount(account);
|
|
1016
|
+
}
|
|
1017
|
+
console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
|
|
1018
|
+
}
|
|
1019
|
+
// Register this Android device in clients.jsonc
|
|
1020
|
+
await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
catch (e) {
|
|
1024
|
+
console.warn(`[android] GDrive access failed: ${e.message}`);
|
|
1025
|
+
}
|
|
1026
|
+
})();
|
|
1027
|
+
}
|
|
931
1028
|
// Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
|
|
932
1029
|
const SYNC_INTERVAL_MS = 2 * 60 * 1000;
|
|
933
1030
|
setInterval(() => {
|
|
@@ -937,6 +1034,8 @@ export async function initAndroid() {
|
|
|
937
1034
|
for (const account of db.getAccounts()) {
|
|
938
1035
|
syncManager.processSendQueue(account.id)
|
|
939
1036
|
.catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
|
|
1037
|
+
syncManager.processSyncActions(account.id)
|
|
1038
|
+
.catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
|
|
940
1039
|
}
|
|
941
1040
|
syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
|
|
942
1041
|
}, SYNC_INTERVAL_MS);
|
|
@@ -998,6 +1097,7 @@ function installBridge() {
|
|
|
998
1097
|
},
|
|
999
1098
|
searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
|
|
1000
1099
|
searchContacts: (query) => service.searchContacts(query),
|
|
1100
|
+
hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
|
|
1001
1101
|
syncAll: async () => { await service.syncAll(); return { ok: true }; },
|
|
1002
1102
|
syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
|
|
1003
1103
|
getSyncPending: () => service.getSyncPending(),
|
|
@@ -94,6 +94,10 @@ export declare class WebMailxDB {
|
|
|
94
94
|
source: string;
|
|
95
95
|
useCount: number;
|
|
96
96
|
}[];
|
|
97
|
+
/** Q49 heuristic: has the user ever sent to `recipientEmail` with a
|
|
98
|
+
* non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
|
|
99
|
+
* the Cc row on reply to a frequent-Cc'd recipient. */
|
|
100
|
+
hasCcHistoryTo(recipientEmail: string): boolean;
|
|
97
101
|
searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
|
|
98
102
|
queueSyncAction(accountId: string, action: string, uid: number, folderId: number, extra?: {
|
|
99
103
|
targetFolderId?: number;
|
|
@@ -483,11 +483,36 @@ export class WebMailxDB {
|
|
|
483
483
|
return added;
|
|
484
484
|
}
|
|
485
485
|
searchContacts(query, limit = 10) {
|
|
486
|
+
query = (query || "").trim();
|
|
487
|
+
if (!query)
|
|
488
|
+
return [];
|
|
486
489
|
const q = `%${query}%`;
|
|
487
490
|
return this.all(`SELECT name, email, source, use_count as useCount FROM contacts
|
|
488
491
|
WHERE email LIKE ? OR name LIKE ?
|
|
489
492
|
ORDER BY use_count DESC, last_used DESC LIMIT ?`, [q, q, limit]);
|
|
490
493
|
}
|
|
494
|
+
/** Q49 heuristic: has the user ever sent to `recipientEmail` with a
|
|
495
|
+
* non-empty Cc? Scans Sent folder(s). Used by compose to auto-expand
|
|
496
|
+
* the Cc row on reply to a frequent-Cc'd recipient. */
|
|
497
|
+
hasCcHistoryTo(recipientEmail) {
|
|
498
|
+
const email = (recipientEmail || "").trim().toLowerCase();
|
|
499
|
+
if (!email)
|
|
500
|
+
return false;
|
|
501
|
+
try {
|
|
502
|
+
const row = this.get(`
|
|
503
|
+
SELECT 1 FROM messages m
|
|
504
|
+
JOIN folders f ON m.folder_id = f.id
|
|
505
|
+
WHERE f.special_use = 'sent'
|
|
506
|
+
AND lower(m.to_json) LIKE ?
|
|
507
|
+
AND m.cc_json IS NOT NULL AND m.cc_json != '[]' AND m.cc_json != ''
|
|
508
|
+
LIMIT 1
|
|
509
|
+
`, [`%"${email}"%`]);
|
|
510
|
+
return !!row;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
491
516
|
// ── Search ──
|
|
492
517
|
searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
|
|
493
518
|
const offset = (page - 1) * pageSize;
|
|
@@ -48,6 +48,13 @@ export declare class SyncManager implements WebSyncManager {
|
|
|
48
48
|
}[], targetFolderId: number): Promise<void>;
|
|
49
49
|
moveMessageCrossAccount(): Promise<void>;
|
|
50
50
|
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
51
|
+
/** Q112: drain queued move/flag/trash actions to the provider. Android is
|
|
52
|
+
* standalone — it pushes state changes to Gmail (or other provider) the
|
|
53
|
+
* same way desktop does, so local actions propagate without needing a
|
|
54
|
+
* desktop to relay them. Called from android-bootstrap on startup and
|
|
55
|
+
* every 2-min sync tick. `send` actions are drained separately by
|
|
56
|
+
* processSendQueue. */
|
|
57
|
+
processSyncActions(accountId: string): Promise<void>;
|
|
51
58
|
markFolderRead(folderId: number): Promise<void>;
|
|
52
59
|
emptyFolder(accountId: string, folderId: number): Promise<void>;
|
|
53
60
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
@@ -329,6 +329,61 @@ export class SyncManager {
|
|
|
329
329
|
async undeleteMessage(accountId, uid, folderId) {
|
|
330
330
|
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
331
331
|
}
|
|
332
|
+
/** Q112: drain queued move/flag/trash actions to the provider. Android is
|
|
333
|
+
* standalone — it pushes state changes to Gmail (or other provider) the
|
|
334
|
+
* same way desktop does, so local actions propagate without needing a
|
|
335
|
+
* desktop to relay them. Called from android-bootstrap on startup and
|
|
336
|
+
* every 2-min sync tick. `send` actions are drained separately by
|
|
337
|
+
* processSendQueue. */
|
|
338
|
+
async processSyncActions(accountId) {
|
|
339
|
+
const provider = this.getProvider(accountId);
|
|
340
|
+
if (!provider)
|
|
341
|
+
return;
|
|
342
|
+
const pending = this.db.getPendingSyncActions(accountId)
|
|
343
|
+
.filter((a) => a.action !== "send");
|
|
344
|
+
if (pending.length === 0)
|
|
345
|
+
return;
|
|
346
|
+
const folders = this.db.getFolders(accountId);
|
|
347
|
+
const folderPath = (id) => {
|
|
348
|
+
const f = folders.find((x) => x.id === id);
|
|
349
|
+
return f?.path || null;
|
|
350
|
+
};
|
|
351
|
+
for (const p of pending) {
|
|
352
|
+
const path = folderPath(p.folderId);
|
|
353
|
+
if (!path) {
|
|
354
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
if (p.action === "flags" && typeof provider.setFlags === "function") {
|
|
359
|
+
await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
|
|
360
|
+
}
|
|
361
|
+
else if (p.action === "trash" && typeof provider.trashMessage === "function") {
|
|
362
|
+
await provider.trashMessage(path, p.uid);
|
|
363
|
+
}
|
|
364
|
+
else if (p.action === "move" && typeof provider.moveMessage === "function") {
|
|
365
|
+
const toId = p.targetFolderId;
|
|
366
|
+
const toPath = folderPath(toId);
|
|
367
|
+
if (!toPath) {
|
|
368
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
await provider.moveMessage(path, p.uid, toPath);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// Unsupported action for this provider — don't loop forever.
|
|
375
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
this.db.completeSyncActionByUid(accountId, p.action, p.uid);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
const msg = e?.message || String(e);
|
|
382
|
+
console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
|
|
383
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
332
387
|
async markFolderRead(folderId) {
|
|
333
388
|
this.db.markFolderRead(folderId);
|
|
334
389
|
}
|
|
@@ -71,6 +71,10 @@ export declare class WebMailxService {
|
|
|
71
71
|
}>;
|
|
72
72
|
deleteDraft(accountId: string, draftUid: number): Promise<void>;
|
|
73
73
|
searchContacts(query: string): any[];
|
|
74
|
+
/** Q49 heuristic mirror: true if the user has ever sent a message to
|
|
75
|
+
* `recipientEmail` that had a non-empty Cc field. Compose uses this to
|
|
76
|
+
* decide whether to auto-expand the Cc row on reply. */
|
|
77
|
+
hasCcHistoryTo(recipientEmail: string): boolean;
|
|
74
78
|
getSettings(): Promise<any>;
|
|
75
79
|
saveSettingsData(settings: any): Promise<void>;
|
|
76
80
|
getStorageInfo(): {
|
|
@@ -437,10 +437,17 @@ export class WebMailxService {
|
|
|
437
437
|
}
|
|
438
438
|
// ── Contacts ──
|
|
439
439
|
searchContacts(query) {
|
|
440
|
+
query = (query || "").trim();
|
|
440
441
|
if (query.length < 1)
|
|
441
442
|
return [];
|
|
442
443
|
return this.db.searchContacts(query);
|
|
443
444
|
}
|
|
445
|
+
/** Q49 heuristic mirror: true if the user has ever sent a message to
|
|
446
|
+
* `recipientEmail` that had a non-empty Cc field. Compose uses this to
|
|
447
|
+
* decide whether to auto-expand the Cc row on reply. */
|
|
448
|
+
hasCcHistoryTo(recipientEmail) {
|
|
449
|
+
return this.db.hasCcHistoryTo?.(recipientEmail) ?? false;
|
|
450
|
+
}
|
|
444
451
|
// ── Settings ──
|
|
445
452
|
async getSettings() {
|
|
446
453
|
return loadSettings();
|
package/tdview.cmd
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
call mdview todo.md -pos 100,100,1 -size 900,1400
|
package/unwedge.cmd
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rmdir C:\Users\Bob\.claude\session-env\5a053d1d-7856-4e74-9b2b-23a4e3262aed /s /q
|