@bobfrankston/mailx 1.0.376 → 1.0.378
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/components/calendar-sidebar.js +7 -3
- package/client/components/message-list.js +37 -1
- package/client/components/message-viewer.js +17 -0
- package/client/index.html +1 -0
- package/client/lib/api-client.js +6 -4
- package/client/lib/mailxapi.js +1 -1
- package/client/styles/components.css +26 -9
- package/client/styles/layout.css +20 -3
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +8 -5
- package/packages/mailx-service/index.js +22 -6
- package/packages/mailx-service/jsonrpc.js +1 -1
- package/packages/mailx-settings/index.js +3 -0
- package/packages/mailx-store/db.d.ts +3 -0
- package/packages/mailx-store/db.js +28 -0
- package/packages/mailx-store-web/android-bootstrap.js +73 -12
- package/packages/mailx-store-web/db.d.ts +6 -0
- package/packages/mailx-store-web/db.js +10 -0
- package/packages/mailx-types/index.d.ts +4 -1
|
@@ -58,7 +58,7 @@ async function fetchUpcoming(from) {
|
|
|
58
58
|
const local = loadLocalEvents();
|
|
59
59
|
let google = [];
|
|
60
60
|
try {
|
|
61
|
-
const primary = await getPrimaryAccount();
|
|
61
|
+
const primary = await getPrimaryAccount("calendar");
|
|
62
62
|
if (primary?.email) {
|
|
63
63
|
// The OAuth token is held by the service-side OAuthTokenManager;
|
|
64
64
|
// calendar fetch goes through a server-side proxy method (not
|
|
@@ -188,11 +188,15 @@ export function hideCalendarSidebar() {
|
|
|
188
188
|
catch { /* */ }
|
|
189
189
|
}
|
|
190
190
|
export function isCalendarSidebarOn() {
|
|
191
|
+
// Default is ON — user-reported 2026-04-23 that the sidebar should be
|
|
192
|
+
// visible by default, not hidden. An explicit "false" in localStorage
|
|
193
|
+
// still hides it (the user's stored preference wins).
|
|
191
194
|
try {
|
|
192
|
-
|
|
195
|
+
const v = localStorage.getItem(SIDEBAR_PREF);
|
|
196
|
+
return v === null ? true : v !== "false";
|
|
193
197
|
}
|
|
194
198
|
catch {
|
|
195
|
-
return
|
|
199
|
+
return true;
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
/** Wire one-time event handlers + restore from localStorage. Safe to call
|
|
@@ -370,7 +370,7 @@ function restoreSelection(body, savedUid) {
|
|
|
370
370
|
/** Show a floating list of all messages in a thread when the pill is clicked.
|
|
371
371
|
* Each entry in the popup selects that message in the viewer when clicked.
|
|
372
372
|
* This is simpler than inline expansion and avoids duplicating the row builder. */
|
|
373
|
-
async function showThreadPopup(pillEl, headMsg) {
|
|
373
|
+
export async function showThreadPopup(pillEl, headMsg) {
|
|
374
374
|
// Remove any existing popup
|
|
375
375
|
document.querySelectorAll(".ml-thread-popup").forEach(el => el.remove());
|
|
376
376
|
let thread = [];
|
|
@@ -464,6 +464,11 @@ function appendMessages(body, accountId, items) {
|
|
|
464
464
|
// action (move/flag/delete) hasn't been ACK'd by the server yet.
|
|
465
465
|
if (msg.pending)
|
|
466
466
|
row.classList.add("pending-reconcile");
|
|
467
|
+
// Reply-row marker: messages with In-Reply-To are replies. Shows a
|
|
468
|
+
// subtle left-border accent so the eye can pick out threaded replies
|
|
469
|
+
// without enabling full thread grouping.
|
|
470
|
+
if (msg.inReplyTo)
|
|
471
|
+
row.classList.add("is-reply");
|
|
467
472
|
row.dataset.uid = String(msg.uid);
|
|
468
473
|
row.dataset.accountId = msgAccountId;
|
|
469
474
|
row.dataset.folderId = String(msg.folderId);
|
|
@@ -600,6 +605,37 @@ function appendMessages(body, accountId, items) {
|
|
|
600
605
|
}
|
|
601
606
|
});
|
|
602
607
|
row.addEventListener("dragend", () => row.classList.remove("dragging"));
|
|
608
|
+
// ── Q66: long-press on touch → context menu ──
|
|
609
|
+
// Mirrors right-click on the phone where right-click isn't a thing.
|
|
610
|
+
// Cancelled by any touchmove or touchend before the threshold.
|
|
611
|
+
let longPressTimer = null;
|
|
612
|
+
const LONG_PRESS_MS = 550;
|
|
613
|
+
row.addEventListener("touchstart", (e) => {
|
|
614
|
+
const t = e.touches[0];
|
|
615
|
+
if (!t)
|
|
616
|
+
return;
|
|
617
|
+
const cx = t.clientX, cy = t.clientY;
|
|
618
|
+
if (longPressTimer)
|
|
619
|
+
clearTimeout(longPressTimer);
|
|
620
|
+
longPressTimer = setTimeout(() => {
|
|
621
|
+
longPressTimer = null;
|
|
622
|
+
// Synthesize a contextmenu event so the existing handler below
|
|
623
|
+
// owns all the menu logic — no per-event duplication.
|
|
624
|
+
const ev = new MouseEvent("contextmenu", {
|
|
625
|
+
clientX: cx, clientY: cy, bubbles: true, cancelable: true,
|
|
626
|
+
});
|
|
627
|
+
row.dispatchEvent(ev);
|
|
628
|
+
}, LONG_PRESS_MS);
|
|
629
|
+
}, { passive: true });
|
|
630
|
+
const cancelLongPress = () => {
|
|
631
|
+
if (longPressTimer) {
|
|
632
|
+
clearTimeout(longPressTimer);
|
|
633
|
+
longPressTimer = null;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
row.addEventListener("touchmove", cancelLongPress, { passive: true });
|
|
637
|
+
row.addEventListener("touchend", cancelLongPress, { passive: true });
|
|
638
|
+
row.addEventListener("touchcancel", cancelLongPress, { passive: true });
|
|
603
639
|
// ── Right-click context menu ──
|
|
604
640
|
row.addEventListener("contextmenu", (e) => {
|
|
605
641
|
e.preventDefault();
|
|
@@ -395,6 +395,23 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
395
395
|
unsubBtn.hidden = true;
|
|
396
396
|
}
|
|
397
397
|
}
|
|
398
|
+
// View Thread button — opens the thread popup from the message list
|
|
399
|
+
// so the user can see all messages in the conversation. Works from
|
|
400
|
+
// the viewer even when thread-grouping is off.
|
|
401
|
+
const threadBtn = document.getElementById("mv-view-thread");
|
|
402
|
+
if (threadBtn) {
|
|
403
|
+
const tid = msg.threadId || "";
|
|
404
|
+
if (tid) {
|
|
405
|
+
threadBtn.hidden = false;
|
|
406
|
+
threadBtn.onclick = async () => {
|
|
407
|
+
const { showThreadPopup } = await import("./message-list.js");
|
|
408
|
+
await showThreadPopup(threadBtn, { accountId, threadId: tid });
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
threadBtn.hidden = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
398
415
|
// View Source button — shows .eml file path
|
|
399
416
|
const srcBtn = document.getElementById("mv-view-source");
|
|
400
417
|
if (srcBtn) {
|
package/client/index.html
CHANGED
|
@@ -134,6 +134,7 @@
|
|
|
134
134
|
<button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
|
|
135
135
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
136
136
|
<button class="tb-btn" id="btn-mark-unread" title="Mark unread (R)">◉</button>
|
|
137
|
+
<button class="tb-btn" id="mv-view-thread" title="View thread (conversation)" hidden>💬</button>
|
|
137
138
|
<span style="flex:1"></span>
|
|
138
139
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
|
139
140
|
<a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
|
package/client/lib/api-client.js
CHANGED
|
@@ -134,10 +134,12 @@ export function getSyncPending() {
|
|
|
134
134
|
export function getDiagnostics() {
|
|
135
135
|
return ipc().getDiagnostics?.() ?? Promise.resolve([]);
|
|
136
136
|
}
|
|
137
|
-
/** Account
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
/** Account that supplies `feature` data (calendar / tasks / contacts).
|
|
138
|
+
* Resolution: per-feature primary flag → catch-all `primary` → first account.
|
|
139
|
+
* Pass e.g. "calendar" to honor `primaryCalendar:true` overrides; omit for
|
|
140
|
+
* back-compat single-flag behavior. */
|
|
141
|
+
export function getPrimaryAccount(feature) {
|
|
142
|
+
return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);
|
|
141
143
|
}
|
|
142
144
|
export function getOutboxStatus() {
|
|
143
145
|
return ipc().getOutboxStatus();
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
getSyncPending: function() { return callNode("getSyncPending"); },
|
|
161
161
|
getOutboxStatus: function() { return callNode("getOutboxStatus"); },
|
|
162
162
|
getDiagnostics: function() { return callNode("getDiagnostics"); },
|
|
163
|
-
getPrimaryAccount: function() { return callNode("getPrimaryAccount"); },
|
|
163
|
+
getPrimaryAccount: function(feature) { return callNode("getPrimaryAccount", { feature: feature }); },
|
|
164
164
|
listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
|
|
165
165
|
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
166
166
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
@@ -788,6 +788,24 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
788
788
|
opacity: 0.5;
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
+
/* Reply marker — messages whose In-Reply-To points at another message get a
|
|
792
|
+
subtle left-edge accent so threaded replies visibly distinguish from top-
|
|
793
|
+
level posts without needing thread-grouping mode on. Works regardless of
|
|
794
|
+
whether thread-grouping is enabled. */
|
|
795
|
+
.ml-row.is-reply::before {
|
|
796
|
+
content: "";
|
|
797
|
+
position: absolute;
|
|
798
|
+
left: 0;
|
|
799
|
+
top: 2px;
|
|
800
|
+
bottom: 2px;
|
|
801
|
+
width: 2px;
|
|
802
|
+
background: oklch(0.70 0.12 250); /* muted blue */
|
|
803
|
+
border-radius: 0 1px 1px 0;
|
|
804
|
+
pointer-events: none;
|
|
805
|
+
opacity: 0.5;
|
|
806
|
+
}
|
|
807
|
+
.ml-row { position: relative; }
|
|
808
|
+
|
|
791
809
|
/* S1 slice C — local action (move/flag/delete) queued but server hasn't
|
|
792
810
|
ACK'd. Reuses the same date-column dot as the download indicator so
|
|
793
811
|
"still on client, not yet on server" sits in the same visual slot as
|
|
@@ -799,25 +817,24 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
799
817
|
}
|
|
800
818
|
|
|
801
819
|
/* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
|
|
802
|
-
Right-docked, fixed width
|
|
820
|
+
Right-docked, fixed width. Visible by default; user hides via View menu.
|
|
803
821
|
Hides automatically on narrow screens (< 1100px) — Android uses the
|
|
804
|
-
native calendar app.
|
|
822
|
+
native calendar app. When hidden, the main grid does NOT reserve a column
|
|
823
|
+
for it, so there's no blank strip on the right. */
|
|
805
824
|
.calendar-sidebar {
|
|
806
825
|
grid-area: cal-side;
|
|
807
|
-
width: 280px;
|
|
808
826
|
border-left: 1px solid var(--color-border);
|
|
809
827
|
background: var(--color-bg);
|
|
810
|
-
display:
|
|
828
|
+
display: none; /* Opt-in: only show when body.calendar-sidebar-on */
|
|
811
829
|
flex-direction: column;
|
|
812
830
|
overflow: hidden;
|
|
813
831
|
font-size: var(--font-size-sm);
|
|
814
832
|
}
|
|
833
|
+
body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
834
|
+
|
|
815
835
|
@media (max-width: 1100px) {
|
|
816
|
-
.calendar-sidebar
|
|
817
|
-
}
|
|
818
|
-
body.calendar-sidebar-on {
|
|
819
|
-
/* Re-flow main grid to make room for the sidebar on the right.
|
|
820
|
-
Folder-tree / message-list / message-viewer share the remaining area. */
|
|
836
|
+
.calendar-sidebar,
|
|
837
|
+
body.calendar-sidebar-on .calendar-sidebar { display: none; }
|
|
821
838
|
}
|
|
822
839
|
.cal-side-head {
|
|
823
840
|
display: flex;
|
package/client/styles/layout.css
CHANGED
|
@@ -25,6 +25,18 @@ body {
|
|
|
25
25
|
background: var(--color-bg);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/* When the calendar sidebar is on, re-flow the grid to reserve a fourth
|
|
29
|
+
column on the right. When off, no column is reserved — the aside is
|
|
30
|
+
display:none and the grid stays three-column. */
|
|
31
|
+
body.calendar-sidebar-on {
|
|
32
|
+
grid-template-columns: var(--rail-width, 48px) var(--folder-width) 1fr var(--cal-side-width, 280px);
|
|
33
|
+
grid-template-areas:
|
|
34
|
+
"toolbar toolbar toolbar toolbar"
|
|
35
|
+
"alert alert alert alert"
|
|
36
|
+
"rail folders main cal-side"
|
|
37
|
+
"status status status status";
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
.toolbar { grid-area: toolbar; }
|
|
29
41
|
.icon-rail { grid-area: rail; }
|
|
30
42
|
.folder-panel { grid-area: folders; display: flex; flex-direction: column; overflow: hidden; }
|
|
@@ -101,9 +113,13 @@ body {
|
|
|
101
113
|
background: var(--color-accent);
|
|
102
114
|
}
|
|
103
115
|
|
|
104
|
-
/* Responsive: mid-width (tablets, foldables) — keep rail + list + viewer; folders overlay
|
|
116
|
+
/* Responsive: mid-width (tablets, foldables) — keep rail + list + viewer; folders overlay.
|
|
117
|
+
Calendar sidebar is hidden in this tier by its own media query, and the
|
|
118
|
+
body grid does NOT include a cal-side column here — the .calendar-sidebar-on
|
|
119
|
+
body class override above is shadowed by this mid-width rule. */
|
|
105
120
|
@media (max-width: 1100px) and (min-width: 769px) {
|
|
106
|
-
body
|
|
121
|
+
body,
|
|
122
|
+
body.calendar-sidebar-on {
|
|
107
123
|
grid-template-columns: var(--rail-width, 48px) 1fr;
|
|
108
124
|
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
|
109
125
|
grid-template-areas:
|
|
@@ -157,7 +173,8 @@ body {
|
|
|
157
173
|
#status-version { display: none; }
|
|
158
174
|
}
|
|
159
175
|
@media (max-width: 768px), (max-height: 600px) {
|
|
160
|
-
body
|
|
176
|
+
body,
|
|
177
|
+
body.calendar-sidebar-on {
|
|
161
178
|
grid-template-columns: 1fr;
|
|
162
179
|
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
|
163
180
|
grid-template-areas:
|
package/package.json
CHANGED
|
@@ -39,11 +39,14 @@ export declare class MailxService {
|
|
|
39
39
|
/** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
|
|
40
40
|
* last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
|
|
41
41
|
getDiagnostics(): any;
|
|
42
|
-
/** Return the account
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
42
|
+
/** Return the account that supplies `feature` data (calendar / tasks /
|
|
43
|
+
* contacts). Resolution order:
|
|
44
|
+
* 1. Any account with `primary<Feature>: true` (per-feature override)
|
|
45
|
+
* 2. Any account with `primary: true` (catch-all default)
|
|
46
|
+
* 3. First account (fallback)
|
|
47
|
+
* Called without `feature` it returns the catch-all primary — same
|
|
48
|
+
* semantics as the original single-flag version for back-compat. */
|
|
49
|
+
getPrimaryAccount(feature?: string): any;
|
|
47
50
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
48
51
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
49
52
|
listQueuedOutgoing(): any[];
|
|
@@ -102,7 +102,14 @@ export class MailxService {
|
|
|
102
102
|
for (const cfg of cfgs) {
|
|
103
103
|
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
104
104
|
if (a)
|
|
105
|
-
ordered.push({
|
|
105
|
+
ordered.push({
|
|
106
|
+
...a, label: cfg.label, defaultSend: cfg.defaultSend || false,
|
|
107
|
+
primary: !!cfg.primary,
|
|
108
|
+
primaryCalendar: !!cfg.primaryCalendar,
|
|
109
|
+
primaryTasks: !!cfg.primaryTasks,
|
|
110
|
+
primaryContacts: !!cfg.primaryContacts,
|
|
111
|
+
identityDomains: cfg.identityDomains || [],
|
|
112
|
+
});
|
|
106
113
|
}
|
|
107
114
|
// Append any DB accounts not in settings
|
|
108
115
|
for (const a of dbAccounts) {
|
|
@@ -421,12 +428,21 @@ export class MailxService {
|
|
|
421
428
|
getDiagnostics() {
|
|
422
429
|
return this.imapManager.getDiagnosticsSnapshot();
|
|
423
430
|
}
|
|
424
|
-
/** Return the account
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
|
|
431
|
+
/** Return the account that supplies `feature` data (calendar / tasks /
|
|
432
|
+
* contacts). Resolution order:
|
|
433
|
+
* 1. Any account with `primary<Feature>: true` (per-feature override)
|
|
434
|
+
* 2. Any account with `primary: true` (catch-all default)
|
|
435
|
+
* 3. First account (fallback)
|
|
436
|
+
* Called without `feature` it returns the catch-all primary — same
|
|
437
|
+
* semantics as the original single-flag version for back-compat. */
|
|
438
|
+
getPrimaryAccount(feature) {
|
|
429
439
|
const all = this.getAccounts();
|
|
440
|
+
if (feature) {
|
|
441
|
+
const perFeatureKey = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
|
|
442
|
+
const perFeature = all.find((a) => a[perFeatureKey]);
|
|
443
|
+
if (perFeature)
|
|
444
|
+
return perFeature;
|
|
445
|
+
}
|
|
430
446
|
return all.find((a) => a.primary) || all[0] || null;
|
|
431
447
|
}
|
|
432
448
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
@@ -97,7 +97,7 @@ async function dispatchAction(svc, action, p) {
|
|
|
97
97
|
case "getDiagnostics":
|
|
98
98
|
return svc.getDiagnostics();
|
|
99
99
|
case "getPrimaryAccount":
|
|
100
|
-
return svc.getPrimaryAccount();
|
|
100
|
+
return svc.getPrimaryAccount(p?.feature);
|
|
101
101
|
case "getOutboxStatus":
|
|
102
102
|
return svc.getOutboxStatus();
|
|
103
103
|
case "listQueuedOutgoing":
|
|
@@ -377,6 +377,9 @@ function normalizeAccount(acct, globalName) {
|
|
|
377
377
|
},
|
|
378
378
|
enabled: acct.enabled ?? true,
|
|
379
379
|
primary: acct.primary,
|
|
380
|
+
primaryCalendar: acct.primaryCalendar,
|
|
381
|
+
primaryTasks: acct.primaryTasks,
|
|
382
|
+
primaryContacts: acct.primaryContacts,
|
|
380
383
|
defaultSend: acct.defaultSend,
|
|
381
384
|
syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
|
|
382
385
|
relayDomains: acct.relayDomains,
|
|
@@ -7,6 +7,9 @@ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery }
|
|
|
7
7
|
export declare class MailxDB {
|
|
8
8
|
private db;
|
|
9
9
|
constructor(dbDir: string);
|
|
10
|
+
/** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
|
|
11
|
+
* runs at startup). The user-facing message names the recovery command. */
|
|
12
|
+
private verifySchema;
|
|
10
13
|
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
11
14
|
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
12
15
|
* first pass. */
|
|
@@ -182,6 +182,34 @@ export class MailxDB {
|
|
|
182
182
|
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
183
183
|
// at our row counts, runs once per DB upgrade.
|
|
184
184
|
this.backfillUuids();
|
|
185
|
+
// Post-migration sanity check: verify the columns we actually read in
|
|
186
|
+
// SELECTs exist. If any migration silently failed (stale driver, DB
|
|
187
|
+
// file locked, permission error), later code would throw cryptic
|
|
188
|
+
// "no such column" errors buried deep in a sync run. Fail loud here
|
|
189
|
+
// with a clear "run mailx -rebuild" message. C32 on Linux was exactly
|
|
190
|
+
// this — old mailx-store that predated thread_id/uuid migrations.
|
|
191
|
+
this.verifySchema();
|
|
192
|
+
}
|
|
193
|
+
/** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
|
|
194
|
+
* runs at startup). The user-facing message names the recovery command. */
|
|
195
|
+
verifySchema() {
|
|
196
|
+
const required = {
|
|
197
|
+
messages: ["thread_id", "provider_id", "uuid"],
|
|
198
|
+
};
|
|
199
|
+
for (const [table, cols] of Object.entries(required)) {
|
|
200
|
+
let actual;
|
|
201
|
+
try {
|
|
202
|
+
actual = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
throw new Error(`[mailx-store] schema check failed for "${table}": ${e.message}. Run 'mailx -rebuild' to rebuild the local store.`);
|
|
206
|
+
}
|
|
207
|
+
const names = new Set(actual.map(r => r.name));
|
|
208
|
+
const missing = cols.filter(c => !names.has(c));
|
|
209
|
+
if (missing.length > 0) {
|
|
210
|
+
throw new Error(`[mailx-store] table "${table}" is missing columns [${missing.join(", ")}] — schema migration did not complete. Run 'mailx -rebuild' to rebuild the local store.`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
185
213
|
}
|
|
186
214
|
/** One-time: assign UUIDs to every `messages` row that's missing one.
|
|
187
215
|
* Runs on every startup but the WHERE clause makes it a no-op after the
|
|
@@ -430,23 +430,35 @@ class AndroidSyncManager {
|
|
|
430
430
|
this.db.queueSyncAction(accountId, "undelete", uid, folderId);
|
|
431
431
|
}
|
|
432
432
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
433
|
+
// Local-first: PERSIST to sync_actions before attempting the network
|
|
434
|
+
// send, so a crash / offline / process kill between now and SMTP ACK
|
|
435
|
+
// doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
|
|
436
|
+
// before calling SMTP; Android writes a sync_actions row to sql.js +
|
|
437
|
+
// IndexedDB (which saveDbToIdb persists on scheduleSave). Unique neg
|
|
438
|
+
// uid = Date.now() to avoid colliding with real message UIDs and to
|
|
439
|
+
// give us a stable tracking key through the async send pipeline.
|
|
440
|
+
//
|
|
441
|
+
// User-flagged 2026-04-23: "sending on android, like on the PC must
|
|
442
|
+
// be queued." Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr`.
|
|
443
|
+
const queueUid = -Date.now();
|
|
444
|
+
this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
|
|
445
|
+
this.attemptSend(accountId, queueUid, rawMessage);
|
|
446
|
+
}
|
|
447
|
+
/** Kick off a send for a message that's already in the queue. Called by
|
|
448
|
+
* queueOutgoingLocal on a fresh submit AND by processSendQueue on
|
|
449
|
+
* startup / periodic tick for anything stranded from a prior run. */
|
|
450
|
+
attemptSend(accountId, queueUid, rawMessage) {
|
|
441
451
|
const provider = this.getProvider(accountId);
|
|
442
452
|
if (provider && typeof provider.sendRaw === "function") {
|
|
443
453
|
provider.sendRaw(rawMessage)
|
|
444
454
|
.then((result) => {
|
|
445
455
|
console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
|
|
456
|
+
this.db.completeSyncActionByUid(accountId, "send", queueUid);
|
|
446
457
|
emitEvent({ type: "sendComplete", accountId, messageId: result.id });
|
|
447
458
|
})
|
|
448
459
|
.catch((e) => {
|
|
449
460
|
console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
|
|
461
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
|
|
450
462
|
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
451
463
|
});
|
|
452
464
|
return;
|
|
@@ -458,8 +470,9 @@ class AndroidSyncManager {
|
|
|
458
470
|
if (!row) {
|
|
459
471
|
const e = "Unknown account";
|
|
460
472
|
console.error(`[send] ${accountId}: ${e}`);
|
|
473
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, e);
|
|
461
474
|
emitEvent({ type: "sendError", accountId, error: e });
|
|
462
|
-
|
|
475
|
+
return;
|
|
463
476
|
}
|
|
464
477
|
let account;
|
|
465
478
|
try {
|
|
@@ -467,26 +480,41 @@ class AndroidSyncManager {
|
|
|
467
480
|
}
|
|
468
481
|
catch {
|
|
469
482
|
const e = "Account config malformed";
|
|
483
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, e);
|
|
470
484
|
emitEvent({ type: "sendError", accountId, error: e });
|
|
471
|
-
|
|
485
|
+
return;
|
|
472
486
|
}
|
|
473
487
|
if (!account.smtp) {
|
|
474
488
|
const e = "No SMTP config for this account";
|
|
475
489
|
console.error(`[send] ${accountId}: ${e}`);
|
|
490
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, e);
|
|
476
491
|
emitEvent({ type: "sendError", accountId, error: e });
|
|
477
|
-
|
|
492
|
+
return;
|
|
478
493
|
}
|
|
479
|
-
// Fire async — same pattern as Gmail path above.
|
|
480
494
|
this.sendViaSmtpDirect(accountId, account, rawMessage)
|
|
481
495
|
.then((result) => {
|
|
482
496
|
console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
|
|
497
|
+
this.db.completeSyncActionByUid(accountId, "send", queueUid);
|
|
483
498
|
emitEvent({ type: "sendComplete", accountId });
|
|
484
499
|
})
|
|
485
500
|
.catch((e) => {
|
|
486
501
|
console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
|
|
502
|
+
this.db.failSyncActionByUid(accountId, "send", queueUid, e.message || String(e));
|
|
487
503
|
emitEvent({ type: "sendError", accountId, error: e.message });
|
|
488
504
|
});
|
|
489
505
|
}
|
|
506
|
+
/** Drain any stranded 'send' queue entries — called at startup and on
|
|
507
|
+
* each periodic sync tick so messages queued while offline or stranded
|
|
508
|
+
* by a crash get a retry. Each row keeps its queueUid as tracking key. */
|
|
509
|
+
async processSendQueue(accountId) {
|
|
510
|
+
const pending = this.db.getPendingSyncActions(accountId).filter(a => a.action === "send" && a.rawMessage);
|
|
511
|
+
if (pending.length === 0)
|
|
512
|
+
return;
|
|
513
|
+
console.log(`[send] ${accountId}: draining ${pending.length} queued message(s)`);
|
|
514
|
+
for (const p of pending) {
|
|
515
|
+
this.attemptSend(accountId, p.uid, p.rawMessage);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
490
518
|
/** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
|
|
491
519
|
async sendViaSmtpDirect(accountId, account, raw) {
|
|
492
520
|
const SMTP_PORT_STARTTLS = 587;
|
|
@@ -870,6 +898,15 @@ export async function initAndroid() {
|
|
|
870
898
|
}
|
|
871
899
|
}
|
|
872
900
|
installBridge();
|
|
901
|
+
// Drain any stranded send-queue entries BEFORE first sync. A message
|
|
902
|
+
// queued in a prior session (offline, crashed mid-send, process killed)
|
|
903
|
+
// gets a retry as soon as we have accounts registered. Desktop parity.
|
|
904
|
+
for (const account of accounts) {
|
|
905
|
+
if (!account.enabled)
|
|
906
|
+
continue;
|
|
907
|
+
syncManager.processSendQueue(account.id)
|
|
908
|
+
.catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
|
|
909
|
+
}
|
|
873
910
|
setTimeout(() => {
|
|
874
911
|
syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
|
|
875
912
|
}, 1000);
|
|
@@ -878,6 +915,11 @@ export async function initAndroid() {
|
|
|
878
915
|
setInterval(() => {
|
|
879
916
|
console.log("[sync] periodic poll");
|
|
880
917
|
vlog("periodic sync poll");
|
|
918
|
+
// Retry any failed/stranded sends every poll tick
|
|
919
|
+
for (const account of db.getAccounts()) {
|
|
920
|
+
syncManager.processSendQueue(account.id)
|
|
921
|
+
.catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
|
|
922
|
+
}
|
|
881
923
|
syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
|
|
882
924
|
}, SYNC_INTERVAL_MS);
|
|
883
925
|
// Immediate sync when app comes back to foreground (e.g. user switches from
|
|
@@ -941,6 +983,25 @@ function installBridge() {
|
|
|
941
983
|
syncAll: async () => { await service.syncAll(); return { ok: true }; },
|
|
942
984
|
syncAccount: async (accountId) => { await service.syncAccount(accountId); return { ok: true }; },
|
|
943
985
|
getSyncPending: () => service.getSyncPending(),
|
|
986
|
+
getPrimaryAccount: (feature) => {
|
|
987
|
+
// Resolve primary account for a feature (calendar/tasks/contacts):
|
|
988
|
+
// per-feature flag → catch-all `primary` → first account.
|
|
989
|
+
const all = db.getAccountConfigs().map(r => {
|
|
990
|
+
try {
|
|
991
|
+
return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) };
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
return { id: r.id, name: r.name, email: r.email };
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
if (feature) {
|
|
998
|
+
const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
|
|
999
|
+
const perFeature = all.find((a) => a[key]);
|
|
1000
|
+
if (perFeature)
|
|
1001
|
+
return perFeature;
|
|
1002
|
+
}
|
|
1003
|
+
return all.find((a) => a.primary) || all[0] || null;
|
|
1004
|
+
},
|
|
944
1005
|
reauthenticate: async (accountId) => ({ ok: await service.reauthenticate(accountId) }),
|
|
945
1006
|
markFolderRead: (_accountId, folderId) => { service.markFolderRead(folderId); return { ok: true }; },
|
|
946
1007
|
createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
|
|
@@ -103,6 +103,12 @@ export declare class WebMailxDB {
|
|
|
103
103
|
getPendingSyncActions(accountId: string): any[];
|
|
104
104
|
completeSyncAction(id: number): void;
|
|
105
105
|
failSyncAction(id: number, error: string): void;
|
|
106
|
+
/** Delete a queued sync action by (accountId, action, uid) — used by the
|
|
107
|
+
* send path which tracks queued sends via a unique negative uid rather
|
|
108
|
+
* than threading the row id through the async send pipeline. */
|
|
109
|
+
completeSyncActionByUid(accountId: string, action: string, uid: number): void;
|
|
110
|
+
/** Mark a send-queue action failed by uid — same tracking-key rationale. */
|
|
111
|
+
failSyncActionByUid(accountId: string, action: string, uid: number, error: string): void;
|
|
106
112
|
getPendingSyncCount(accountId: string): number;
|
|
107
113
|
getTotalPendingSyncCount(): number;
|
|
108
114
|
/** Reset the entire database */
|
|
@@ -478,6 +478,16 @@ export class WebMailxDB {
|
|
|
478
478
|
failSyncAction(id, error) {
|
|
479
479
|
this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE id = ?", [error, id]);
|
|
480
480
|
}
|
|
481
|
+
/** Delete a queued sync action by (accountId, action, uid) — used by the
|
|
482
|
+
* send path which tracks queued sends via a unique negative uid rather
|
|
483
|
+
* than threading the row id through the async send pipeline. */
|
|
484
|
+
completeSyncActionByUid(accountId, action, uid) {
|
|
485
|
+
this.run("DELETE FROM sync_actions WHERE account_id = ? AND action = ? AND uid = ?", [accountId, action, uid]);
|
|
486
|
+
}
|
|
487
|
+
/** Mark a send-queue action failed by uid — same tracking-key rationale. */
|
|
488
|
+
failSyncActionByUid(accountId, action, uid, error) {
|
|
489
|
+
this.run("UPDATE sync_actions SET attempts = attempts + 1, last_error = ? WHERE account_id = ? AND action = ? AND uid = ?", [error, accountId, action, uid]);
|
|
490
|
+
}
|
|
481
491
|
getPendingSyncCount(accountId) {
|
|
482
492
|
const r = this.get("SELECT COUNT(*) as cnt FROM sync_actions WHERE account_id = ?", [accountId]);
|
|
483
493
|
return r?.cnt || 0;
|
|
@@ -29,7 +29,10 @@ export interface AccountConfig {
|
|
|
29
29
|
password?: string;
|
|
30
30
|
};
|
|
31
31
|
enabled: boolean;
|
|
32
|
-
primary?: boolean; /**
|
|
32
|
+
primary?: boolean; /** Catch-all "this is my main account" — default source for Calendar / Tasks / Contacts when no per-feature override set. */
|
|
33
|
+
primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
|
|
34
|
+
primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
|
|
35
|
+
primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
|
|
33
36
|
defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
|
|
34
37
|
syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
|
|
35
38
|
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|