@bobfrankston/mailx 1.0.394 → 1.0.399
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 +34 -7
- package/client/app.js +42 -5
- package/client/components/alarms.js +286 -0
- package/client/components/calendar-sidebar.js +43 -7
- package/client/components/message-list.js +223 -16
- package/client/compose/compose.js +24 -0
- package/client/index.html +21 -10
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +200 -0
- package/client/styles/layout.css +40 -14
- package/package.json +1 -1
- 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 +26 -0
- package/packages/mailx-store-web/android-bootstrap.js +60 -0
- 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/client/styles/layout.css
CHANGED
|
@@ -153,10 +153,15 @@ body.calendar-sidebar-on {
|
|
|
153
153
|
"status status";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
/* Folder panel:
|
|
156
|
+
/* Folder panel: FULLY off-screen when closed — previously `calc(48px -
|
|
157
|
+
280px) = -232px` left a 48 px tail showing behind the rail, which on
|
|
158
|
+
Android (where the rail isn't always rendered) leaked a strip of
|
|
159
|
+
folder-row badges at the left edge of the viewport. Now the closed
|
|
160
|
+
panel is flush off-screen at `-280px` and the `.open` slide-in starts
|
|
161
|
+
at the rail's right edge. */
|
|
157
162
|
.folder-panel {
|
|
158
163
|
position: fixed;
|
|
159
|
-
left:
|
|
164
|
+
left: -280px;
|
|
160
165
|
top: var(--toolbar-height);
|
|
161
166
|
bottom: var(--statusbar-height);
|
|
162
167
|
width: 280px;
|
|
@@ -168,14 +173,19 @@ body.calendar-sidebar-on {
|
|
|
168
173
|
}
|
|
169
174
|
.folder-panel.open { left: var(--rail-width, 48px); }
|
|
170
175
|
|
|
171
|
-
/*
|
|
172
|
-
|
|
176
|
+
/* Medium tier has the rail visible permanently — no hamburger needed.
|
|
177
|
+
Show the folder-toggle (📁) so the user can still reveal the folder
|
|
178
|
+
tree on demand. */
|
|
179
|
+
#btn-menu { display: none !important; }
|
|
180
|
+
#btn-folder-toggle { display: inline-flex !important; }
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
/* Responsive: narrow OR short viewport — single panel navigation */
|
|
176
184
|
@media (max-width: 768px), (max-height: 600px) {
|
|
177
|
-
/*
|
|
178
|
-
|
|
185
|
+
/* Preview snippets remain visible on narrow — user-controlled via the
|
|
186
|
+
View menu's "Preview snippets" checkbox. Previously this rule forced
|
|
187
|
+
them hidden on any narrow viewport, so Android users never saw the
|
|
188
|
+
checkbox take effect. */
|
|
179
189
|
/* Column headers (From/Date/Subject) take space without being useful on narrow */
|
|
180
190
|
.ml-header { display: none; }
|
|
181
191
|
/* Current folder name shown above the list, Dovecot-style */
|
|
@@ -208,12 +218,25 @@ body.calendar-sidebar-on {
|
|
|
208
218
|
"status";
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
/* Rail
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
/* Rail on narrow: slide-in drawer triggered by the hamburger (☰). The
|
|
222
|
+
rail is the entry point to Inbox / All-Inboxes / Contacts / Calendar /
|
|
223
|
+
Tasks / Settings — without this drawer, narrow-tier users couldn't
|
|
224
|
+
reach any of those. */
|
|
225
|
+
.icon-rail {
|
|
226
|
+
display: flex;
|
|
227
|
+
position: fixed;
|
|
228
|
+
left: -64px;
|
|
229
|
+
top: var(--toolbar-height);
|
|
230
|
+
bottom: var(--statusbar-height);
|
|
231
|
+
z-index: 60;
|
|
232
|
+
transition: left 0.2s ease;
|
|
233
|
+
box-shadow: 2px 0 8px rgba(0,0,0,0.3);
|
|
234
|
+
}
|
|
235
|
+
.icon-rail.open { left: 0; }
|
|
215
236
|
|
|
216
|
-
/* Folder panel:
|
|
237
|
+
/* Folder panel: slide-in drawer triggered by the folder icon (📁).
|
|
238
|
+
Kept separate from the rail drawer so the user can have folders open
|
|
239
|
+
while the rail is closed, or vice versa — matching the desktop flow. */
|
|
217
240
|
.folder-panel {
|
|
218
241
|
position: fixed;
|
|
219
242
|
left: -280px;
|
|
@@ -246,8 +269,10 @@ body.calendar-sidebar-on {
|
|
|
246
269
|
}
|
|
247
270
|
.message-list.narrow-hidden { display: none; }
|
|
248
271
|
|
|
249
|
-
/*
|
|
272
|
+
/* Hamburger opens the rail drawer; folder-toggle opens the folder drawer.
|
|
273
|
+
Both always visible on narrow so the user can reach either. */
|
|
250
274
|
#btn-menu { display: inline-flex !important; }
|
|
275
|
+
#btn-folder-toggle { display: inline-flex !important; }
|
|
251
276
|
/* Back button: only show when viewer is active (message list hidden) */
|
|
252
277
|
#btn-back { display: none !important; }
|
|
253
278
|
.message-viewer.narrow-active ~ * #btn-back,
|
|
@@ -280,7 +305,8 @@ body.calendar-sidebar-on {
|
|
|
280
305
|
}
|
|
281
306
|
}
|
|
282
307
|
|
|
283
|
-
/* Hide hamburger and back on wide screens (folder panel
|
|
308
|
+
/* Hide hamburger, folder-toggle, and back on wide screens (folder panel
|
|
309
|
+
permanent column, rail permanent column, no drawer toggles needed). */
|
|
284
310
|
@media (min-width: 1101px) {
|
|
285
|
-
#btn-menu, #btn-back { display: none !important; }
|
|
311
|
+
#btn-menu, #btn-folder-toggle, #btn-back { display: none !important; }
|
|
286
312
|
}
|
package/package.json
CHANGED
|
@@ -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
|
|
@@ -447,6 +447,58 @@ class AndroidSyncManager {
|
|
|
447
447
|
async undeleteMessage(accountId, uid, folderId) {
|
|
448
448
|
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
449
449
|
}
|
|
450
|
+
/** Q112: drain queued move/flag/trash actions to the provider. Android is
|
|
451
|
+
* standalone — it pushes state changes directly to Gmail (or other
|
|
452
|
+
* provider) the same way desktop does. Called from the periodic 2-min
|
|
453
|
+
* tick above. `send` actions drain separately via `processSendQueue`. */
|
|
454
|
+
async processSyncActions(accountId) {
|
|
455
|
+
const provider = this.providers.get(accountId);
|
|
456
|
+
if (!provider)
|
|
457
|
+
return;
|
|
458
|
+
const pending = this.db.getPendingSyncActions(accountId)
|
|
459
|
+
.filter((a) => a.action !== "send");
|
|
460
|
+
if (pending.length === 0)
|
|
461
|
+
return;
|
|
462
|
+
const folders = this.db.getFolders(accountId);
|
|
463
|
+
const folderPath = (id) => {
|
|
464
|
+
const f = folders.find((x) => x.id === id);
|
|
465
|
+
return f?.path || null;
|
|
466
|
+
};
|
|
467
|
+
for (const p of pending) {
|
|
468
|
+
const path = folderPath(p.folderId);
|
|
469
|
+
if (!path) {
|
|
470
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
if (p.action === "flags" && typeof provider.setFlags === "function") {
|
|
475
|
+
await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
|
|
476
|
+
}
|
|
477
|
+
else if (p.action === "trash" && typeof provider.trashMessage === "function") {
|
|
478
|
+
await provider.trashMessage(path, p.uid);
|
|
479
|
+
}
|
|
480
|
+
else if (p.action === "move" && typeof provider.moveMessage === "function") {
|
|
481
|
+
const toId = p.targetFolderId;
|
|
482
|
+
const toPath = folderPath(toId);
|
|
483
|
+
if (!toPath) {
|
|
484
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
await provider.moveMessage(path, p.uid, toPath);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
this.db.completeSyncActionByUid(accountId, p.action, p.uid);
|
|
494
|
+
}
|
|
495
|
+
catch (e) {
|
|
496
|
+
const msg = e?.message || String(e);
|
|
497
|
+
console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
|
|
498
|
+
this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
450
502
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
451
503
|
// Local-first: PERSIST to sync_actions before attempting the network
|
|
452
504
|
// send, so a crash / offline / process kill between now and SMTP ACK
|
|
@@ -919,11 +971,16 @@ export async function initAndroid() {
|
|
|
919
971
|
// Drain any stranded send-queue entries BEFORE first sync. A message
|
|
920
972
|
// queued in a prior session (offline, crashed mid-send, process killed)
|
|
921
973
|
// gets a retry as soon as we have accounts registered. Desktop parity.
|
|
974
|
+
// Q112 (2026-04-24): also drain move/flag/trash actions here — Android
|
|
975
|
+
// is standalone, not desktop-dependent, so it pushes state changes
|
|
976
|
+
// directly to the server the same way desktop does.
|
|
922
977
|
for (const account of accounts) {
|
|
923
978
|
if (!account.enabled)
|
|
924
979
|
continue;
|
|
925
980
|
syncManager.processSendQueue(account.id)
|
|
926
981
|
.catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
|
|
982
|
+
syncManager.processSyncActions(account.id)
|
|
983
|
+
.catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
|
|
927
984
|
}
|
|
928
985
|
setTimeout(() => {
|
|
929
986
|
syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
|
|
@@ -937,6 +994,8 @@ export async function initAndroid() {
|
|
|
937
994
|
for (const account of db.getAccounts()) {
|
|
938
995
|
syncManager.processSendQueue(account.id)
|
|
939
996
|
.catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
|
|
997
|
+
syncManager.processSyncActions(account.id)
|
|
998
|
+
.catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
|
|
940
999
|
}
|
|
941
1000
|
syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
|
|
942
1001
|
}, SYNC_INTERVAL_MS);
|
|
@@ -998,6 +1057,7 @@ function installBridge() {
|
|
|
998
1057
|
},
|
|
999
1058
|
searchMessages: (query, page, pageSize) => service.search(query, page, pageSize),
|
|
1000
1059
|
searchContacts: (query) => service.searchContacts(query),
|
|
1060
|
+
hasCcHistoryTo: (email) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
|
|
1001
1061
|
syncAll: async () => { await service.syncAll(); return { ok: true }; },
|
|
1002
1062
|
syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
|
|
1003
1063
|
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
|