@bobfrankston/mailx 1.0.395 → 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 +11 -1
- package/client/app.js +29 -4
- package/client/components/alarms.js +286 -0
- package/client/components/calendar-sidebar.js +43 -7
- package/client/components/message-list.js +159 -16
- package/client/compose/compose.js +24 -0
- package/client/index.html +11 -1
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +183 -0
- 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
|
@@ -24,15 +24,46 @@ let touchWasScroll = false;
|
|
|
24
24
|
// (text columns default asc, date defaults desc).
|
|
25
25
|
let currentSort = "date";
|
|
26
26
|
let currentSortDir = "desc";
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
27
|
+
/** S56 refactor slice — per-row focus/unfocus.
|
|
28
|
+
*
|
|
29
|
+
* Every rendered message row has an associated Row record tracking the DOM
|
|
30
|
+
* element + account/msg pair. `focusRow` runs the atomic transition:
|
|
31
|
+
*
|
|
32
|
+
* 1. unfocus the previously-focused row (clears `.selected` class)
|
|
33
|
+
* 2. mark the new row `.selected`
|
|
34
|
+
* 3. update shared state.selected + notify viewer
|
|
35
|
+
*
|
|
36
|
+
* The viewer's `showMessageGeneration` token still cancels stale body
|
|
37
|
+
* fetches during the transition — that's the "async cancellation" piece of
|
|
38
|
+
* S56. The "atomic DOM transition" piece is now owned here.
|
|
39
|
+
*
|
|
40
|
+
* Full abort-signal plumbing through getMessage → fetchMessageBody is a
|
|
41
|
+
* separate follow-up; for now the gen token does the job. */
|
|
42
|
+
let currentFocusedRow = null;
|
|
43
|
+
function focusRow(row, accountId, msg) {
|
|
44
|
+
if (currentFocusedRow && currentFocusedRow !== row) {
|
|
45
|
+
// Unfocus the previous atomically — clearing .selected AND
|
|
46
|
+
// triggering the viewer's stale-fetch cancel (via the bump inside
|
|
47
|
+
// the next showMessage() call).
|
|
48
|
+
currentFocusedRow.classList.remove("selected");
|
|
49
|
+
}
|
|
50
|
+
if (!row.classList.contains("selected"))
|
|
51
|
+
row.classList.add("selected");
|
|
52
|
+
currentFocusedRow = row;
|
|
53
|
+
state.select(msg);
|
|
54
|
+
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
55
|
+
}
|
|
56
|
+
/** Back-compat shim — some call sites still use `focusMessage(accountId, msg)`
|
|
57
|
+
* without a DOM row (e.g. thread-popup click). Fall back to the previous
|
|
58
|
+
* behavior (state + viewer only) so nothing regresses. */
|
|
33
59
|
function focusMessage(accountId, msg) {
|
|
34
60
|
state.select(msg);
|
|
35
61
|
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
62
|
+
// Clear the focused-row reference since we don't have a DOM row here
|
|
63
|
+
// — the next row click will unfocus whatever was selected anyway.
|
|
64
|
+
if (currentFocusedRow)
|
|
65
|
+
currentFocusedRow.classList.remove("selected");
|
|
66
|
+
currentFocusedRow = null;
|
|
36
67
|
}
|
|
37
68
|
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
38
69
|
* Called from the bodyCached service event — covers both background prefetch
|
|
@@ -64,6 +95,10 @@ function clearSelection() {
|
|
|
64
95
|
const body = document.getElementById("ml-body");
|
|
65
96
|
if (body)
|
|
66
97
|
body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
|
|
98
|
+
// S56 seam: the focused-row invariant is "the Row whose .selected is
|
|
99
|
+
// currently mine". clearSelection wipes all .selected, so the invariant
|
|
100
|
+
// would break if we kept a stale pointer.
|
|
101
|
+
currentFocusedRow = null;
|
|
67
102
|
}
|
|
68
103
|
/** Exit multi-select mode (entered via touch long-press). Clears selection
|
|
69
104
|
* and the sticky body flag so subsequent taps open messages again. */
|
|
@@ -73,6 +108,28 @@ function exitMultiSelect() {
|
|
|
73
108
|
return;
|
|
74
109
|
body.classList.remove("multi-select-on");
|
|
75
110
|
clearSelection();
|
|
111
|
+
updateBulkBar();
|
|
112
|
+
}
|
|
113
|
+
/** Refresh the bulk-actions bar visibility + "N selected" label. Called
|
|
114
|
+
* whenever selection or mode changes. Visible either when 2+ rows are
|
|
115
|
+
* selected (desktop Ctrl/Shift-click multi-selection) OR when touch
|
|
116
|
+
* multi-select mode is active (even with a single row, so the user sees
|
|
117
|
+
* the bar as the mode indicator). */
|
|
118
|
+
function updateBulkBar() {
|
|
119
|
+
const body = document.getElementById("ml-body");
|
|
120
|
+
const bar = document.getElementById("ml-bulkbar");
|
|
121
|
+
const count = document.getElementById("ml-bulk-count");
|
|
122
|
+
if (!body || !bar || !count)
|
|
123
|
+
return;
|
|
124
|
+
const active = body.classList.contains("multi-select-on");
|
|
125
|
+
const n = body.querySelectorAll(".ml-row.selected").length;
|
|
126
|
+
if (n >= 2 || (active && n > 0)) {
|
|
127
|
+
bar.hidden = false;
|
|
128
|
+
count.textContent = `${n} selected`;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
bar.hidden = true;
|
|
132
|
+
}
|
|
76
133
|
}
|
|
77
134
|
// Escape key + click-outside-list exit multi-select mode. Attached once
|
|
78
135
|
// (idempotent because document only has one listener scope per handler).
|
|
@@ -88,10 +145,89 @@ if (!window.__mailxMultiSelectWired) {
|
|
|
88
145
|
return;
|
|
89
146
|
const target = e.target;
|
|
90
147
|
// A tap on a row is handled by the row's own click listener; only
|
|
91
|
-
// exit when the tap is on neutral ground (outside the list entirely
|
|
92
|
-
|
|
148
|
+
// exit when the tap is on neutral ground (outside the list entirely
|
|
149
|
+
// and not on the bulk bar).
|
|
150
|
+
if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
|
|
93
151
|
exitMultiSelect();
|
|
94
152
|
}, true);
|
|
153
|
+
// Wire bulk-bar buttons — each delegates to the single-message handlers
|
|
154
|
+
// but iterates over every .ml-row.selected in the body.
|
|
155
|
+
document.addEventListener("click", async (e) => {
|
|
156
|
+
const target = e.target;
|
|
157
|
+
if (target.closest("#ml-bulk-cancel")) {
|
|
158
|
+
exitMultiSelect();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const btn = target.closest(".ml-bulk-btn");
|
|
162
|
+
if (!btn)
|
|
163
|
+
return;
|
|
164
|
+
const op = btn.dataset.bulk;
|
|
165
|
+
const body = document.getElementById("ml-body");
|
|
166
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
167
|
+
return;
|
|
168
|
+
const selected = getSelectedMessages();
|
|
169
|
+
if (selected.length === 0)
|
|
170
|
+
return;
|
|
171
|
+
try {
|
|
172
|
+
if (op === "delete") {
|
|
173
|
+
// Delegate to the existing Del-key handler so the same
|
|
174
|
+
// tombstone/undo logic runs for bulk deletes.
|
|
175
|
+
document.dispatchEvent(new CustomEvent("mailx-delete"));
|
|
176
|
+
}
|
|
177
|
+
else if (op === "flag") {
|
|
178
|
+
for (const m of selected) {
|
|
179
|
+
const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
|
|
180
|
+
if (!row)
|
|
181
|
+
continue;
|
|
182
|
+
const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
|
|
183
|
+
if (!msgData)
|
|
184
|
+
continue;
|
|
185
|
+
const isFlagged = msgData.flags?.includes("\\Flagged");
|
|
186
|
+
const newFlags = isFlagged
|
|
187
|
+
? msgData.flags.filter((f) => f !== "\\Flagged")
|
|
188
|
+
: [...(msgData.flags || []), "\\Flagged"];
|
|
189
|
+
await updateFlags(m.accountId, m.uid, newFlags);
|
|
190
|
+
msgData.flags = newFlags;
|
|
191
|
+
state.updateMessageFlags(m.accountId, m.uid, newFlags);
|
|
192
|
+
row.classList.toggle("flagged");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (op === "markread") {
|
|
196
|
+
for (const m of selected) {
|
|
197
|
+
const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
|
|
198
|
+
if (!row)
|
|
199
|
+
continue;
|
|
200
|
+
const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
|
|
201
|
+
if (!msgData)
|
|
202
|
+
continue;
|
|
203
|
+
if (msgData.flags?.includes("\\Seen"))
|
|
204
|
+
continue;
|
|
205
|
+
const newFlags = [...(msgData.flags || []), "\\Seen"];
|
|
206
|
+
await updateFlags(m.accountId, m.uid, newFlags);
|
|
207
|
+
msgData.flags = newFlags;
|
|
208
|
+
state.updateMessageFlags(m.accountId, m.uid, newFlags);
|
|
209
|
+
row.classList.remove("unread");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (op === "move") {
|
|
213
|
+
// Use the first selection's account/folder for the picker scope.
|
|
214
|
+
const { accountId, folderId } = selected[0];
|
|
215
|
+
const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
|
|
216
|
+
if (!pick)
|
|
217
|
+
return;
|
|
218
|
+
const uids = selected.map(s => s.uid);
|
|
219
|
+
await apiMoveMessages(accountId, uids, pick.folderId);
|
|
220
|
+
state.removeMessages(uids.map(u => ({ accountId, uid: u })));
|
|
221
|
+
}
|
|
222
|
+
else if (op === "spam") {
|
|
223
|
+
document.getElementById("btn-spam")?.click();
|
|
224
|
+
}
|
|
225
|
+
exitMultiSelect();
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
alert(`Bulk ${op} failed: ${err.message || err}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
95
231
|
}
|
|
96
232
|
function selectRange(from, to) {
|
|
97
233
|
const body = document.getElementById("ml-body");
|
|
@@ -380,6 +516,10 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
380
516
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
381
517
|
searchMode = false;
|
|
382
518
|
unifiedMode = false;
|
|
519
|
+
// Folder switch clears any in-progress multi-select — carrying a "3
|
|
520
|
+
// selected" state across folders would lie about what rows the bulk
|
|
521
|
+
// buttons would act on.
|
|
522
|
+
exitMultiSelect();
|
|
383
523
|
// specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
|
|
384
524
|
// folder path lowercased (folder-tree fallback when tag is missing — common
|
|
385
525
|
// on Dovecot which doesn't advertise \Sent). Match both cases.
|
|
@@ -671,22 +811,28 @@ function appendMessages(body, accountId, items) {
|
|
|
671
811
|
if (body?.classList.contains("multi-select-on")) {
|
|
672
812
|
row.classList.toggle("selected");
|
|
673
813
|
lastClickedRow = row;
|
|
814
|
+
updateBulkBar();
|
|
674
815
|
return;
|
|
675
816
|
}
|
|
676
817
|
if (e.shiftKey && lastClickedRow) {
|
|
677
818
|
clearSelection();
|
|
678
819
|
selectRange(lastClickedRow, row);
|
|
820
|
+
lastClickedRow = row;
|
|
821
|
+
row.classList.remove("unread");
|
|
822
|
+
focusMessage(msgAccountId, msg);
|
|
679
823
|
}
|
|
680
824
|
else if (e.ctrlKey || e.metaKey) {
|
|
681
825
|
row.classList.toggle("selected");
|
|
826
|
+
lastClickedRow = row;
|
|
682
827
|
}
|
|
683
828
|
else {
|
|
829
|
+
// Atomic unfocus-previous + focus-this.
|
|
684
830
|
clearSelection();
|
|
685
|
-
row
|
|
831
|
+
focusRow(row, msgAccountId, msg);
|
|
832
|
+
lastClickedRow = row;
|
|
833
|
+
row.classList.remove("unread");
|
|
686
834
|
}
|
|
687
|
-
|
|
688
|
-
row.classList.remove("unread");
|
|
689
|
-
focusMessage(msgAccountId, msg);
|
|
835
|
+
updateBulkBar();
|
|
690
836
|
});
|
|
691
837
|
// Q64: double-click → pop out the message in a floating overlay so
|
|
692
838
|
// the user can read it without losing the selected list context.
|
|
@@ -756,6 +902,7 @@ function appendMessages(body, accountId, items) {
|
|
|
756
902
|
body?.classList.add("multi-select-on");
|
|
757
903
|
}
|
|
758
904
|
lastClickedRow = row;
|
|
905
|
+
updateBulkBar();
|
|
759
906
|
// Haptic hint if the platform supports it (Android WebView does).
|
|
760
907
|
try {
|
|
761
908
|
navigator.vibrate?.(20);
|
|
@@ -859,10 +1006,6 @@ function appendMessages(body, accountId, items) {
|
|
|
859
1006
|
label: "⚠ Mark as spam",
|
|
860
1007
|
action: () => document.getElementById("btn-spam")?.click(),
|
|
861
1008
|
},
|
|
862
|
-
{
|
|
863
|
-
label: "🚫 Report spam",
|
|
864
|
-
action: () => document.getElementById("btn-spam-report")?.click(),
|
|
865
|
-
},
|
|
866
1009
|
{ label: "", action: () => { }, separator: true },
|
|
867
1010
|
{
|
|
868
1011
|
label: "Copy Message-ID",
|
|
@@ -361,6 +361,30 @@ function applyInit(init) {
|
|
|
361
361
|
if (ccBtn)
|
|
362
362
|
ccBtn.classList.add("active");
|
|
363
363
|
}
|
|
364
|
+
else if (init.to && init.to.length === 1) {
|
|
365
|
+
// Q49: heuristic auto-expand — when replying/composing to a single
|
|
366
|
+
// recipient, check sent-history. If the user has previously Cc'd
|
|
367
|
+
// anyone on a message to this recipient, expand the Cc row (empty,
|
|
368
|
+
// just visible) so they're prompted to fill it. Fire-and-forget; if
|
|
369
|
+
// the service call fails or the user starts typing Cc manually
|
|
370
|
+
// before it resolves, the answer doesn't matter.
|
|
371
|
+
const firstEmail = init.to[0]?.address || "";
|
|
372
|
+
if (firstEmail) {
|
|
373
|
+
import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
|
|
374
|
+
.then(res => {
|
|
375
|
+
if (!res?.hasCc)
|
|
376
|
+
return;
|
|
377
|
+
const ccRowEl = document.getElementById("compose-cc-row");
|
|
378
|
+
const ccBtn = document.getElementById("btn-toggle-cc");
|
|
379
|
+
// Only expand if user hasn't already interacted
|
|
380
|
+
if (ccRowEl?.hidden && !ccInput.value) {
|
|
381
|
+
ccRowEl.hidden = false;
|
|
382
|
+
ccBtn?.classList.add("active");
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
.catch(() => { }));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
364
388
|
// C42: append the account's signature (if configured) BEFORE rendering
|
|
365
389
|
// the body. For new mode: just signature. For reply/forward: appended
|
|
366
390
|
// after the quoted block. Drafts are skipped — the signature is already
|
package/client/index.html
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
46
46
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
47
47
|
<hr class="tb-menu-sep">
|
|
48
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
48
|
+
<label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
49
49
|
<label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
|
|
50
50
|
<label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
51
51
|
<hr class="tb-menu-sep">
|
|
@@ -123,6 +123,16 @@
|
|
|
123
123
|
<span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
|
|
124
124
|
<span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
|
|
125
125
|
</div>
|
|
126
|
+
<div class="ml-bulkbar" id="ml-bulkbar" hidden>
|
|
127
|
+
<button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
|
|
128
|
+
<span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
|
|
129
|
+
<span style="flex:1"></span>
|
|
130
|
+
<button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
|
|
131
|
+
<button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
|
|
132
|
+
<button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
|
|
133
|
+
<button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
|
|
134
|
+
<button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
|
|
135
|
+
</div>
|
|
126
136
|
<div class="ml-body" id="ml-body">
|
|
127
137
|
<div class="ml-empty">Select a folder to view messages</div>
|
|
128
138
|
</div>
|
package/client/lib/api-client.js
CHANGED
|
@@ -191,6 +191,9 @@ export function cancelQueuedOutgoing(p) {
|
|
|
191
191
|
export function searchContacts(query) {
|
|
192
192
|
return ipc().searchContacts(query);
|
|
193
193
|
}
|
|
194
|
+
export function hasCcHistoryTo(email) {
|
|
195
|
+
return ipc().hasCcHistoryTo(email);
|
|
196
|
+
}
|
|
194
197
|
export function listContacts(query, page = 1, pageSize = 100) {
|
|
195
198
|
return ipc().listContacts(query, page, pageSize);
|
|
196
199
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -181,6 +181,7 @@
|
|
|
181
181
|
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
182
182
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
183
183
|
reauthGoogleScopes: function() { return callNode("reauthGoogleScopes"); },
|
|
184
|
+
hasCcHistoryTo: function(email) { return callNode("hasCcHistoryTo", { email: email }); },
|
|
184
185
|
|
|
185
186
|
// Bulk operations
|
|
186
187
|
deleteMessages: function(accountId, uids) {
|
|
@@ -900,6 +900,189 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
900
900
|
list empty when the selection is a singleton thread. */
|
|
901
901
|
.ml-row.thread-filter-hidden { display: none; }
|
|
902
902
|
|
|
903
|
+
/* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
|
|
904
|
+
idea but better when we have a second-stage plan strategy". Touch users
|
|
905
|
+
still reach the menu via long-press → multi-select → bulk-bar, or by
|
|
906
|
+
using a stylus/mouse for contextmenu. Revisit when we've decided what
|
|
907
|
+
additional per-message actions belong here. */
|
|
908
|
+
|
|
909
|
+
/* ── Alarm popup (P17 / Q104) ── */
|
|
910
|
+
/* Fullscreen backdrop + centered panel; mirrors .mailx-modal* patterns but
|
|
911
|
+
kept in its own namespace so alarm behavior (snooze/dismiss) can evolve
|
|
912
|
+
without dragging the whole modal system along. */
|
|
913
|
+
.alarm-overlay {
|
|
914
|
+
position: fixed;
|
|
915
|
+
inset: 0;
|
|
916
|
+
background: rgba(0, 0, 0, 0.45);
|
|
917
|
+
z-index: 9000;
|
|
918
|
+
display: flex;
|
|
919
|
+
align-items: center;
|
|
920
|
+
justify-content: center;
|
|
921
|
+
}
|
|
922
|
+
.alarm-panel {
|
|
923
|
+
background: var(--color-bg);
|
|
924
|
+
color: var(--color-text);
|
|
925
|
+
border: 1px solid var(--color-border);
|
|
926
|
+
border-radius: 8px;
|
|
927
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
|
928
|
+
min-width: 380px;
|
|
929
|
+
max-width: 560px;
|
|
930
|
+
width: 90vw;
|
|
931
|
+
display: flex;
|
|
932
|
+
flex-direction: column;
|
|
933
|
+
max-height: 80vh;
|
|
934
|
+
}
|
|
935
|
+
.alarm-head {
|
|
936
|
+
display: flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
gap: var(--gap-sm);
|
|
939
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
940
|
+
border-bottom: 1px solid var(--color-border);
|
|
941
|
+
font-weight: 600;
|
|
942
|
+
}
|
|
943
|
+
.alarm-icon { font-size: 1.4em; }
|
|
944
|
+
.alarm-title { flex: 1; }
|
|
945
|
+
.alarm-close {
|
|
946
|
+
border: 0;
|
|
947
|
+
background: transparent;
|
|
948
|
+
color: var(--color-text-muted);
|
|
949
|
+
cursor: pointer;
|
|
950
|
+
font-size: 1.4em;
|
|
951
|
+
line-height: 1;
|
|
952
|
+
padding: 0 0.4em;
|
|
953
|
+
}
|
|
954
|
+
.alarm-close:hover { color: var(--color-text); }
|
|
955
|
+
.alarm-list {
|
|
956
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
957
|
+
overflow-y: auto;
|
|
958
|
+
display: flex;
|
|
959
|
+
flex-direction: column;
|
|
960
|
+
gap: var(--gap-xs);
|
|
961
|
+
}
|
|
962
|
+
.alarm-row {
|
|
963
|
+
display: flex;
|
|
964
|
+
align-items: center;
|
|
965
|
+
gap: var(--gap-sm);
|
|
966
|
+
padding: var(--gap-xs) 0;
|
|
967
|
+
border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
|
|
968
|
+
}
|
|
969
|
+
.alarm-row:last-child { border-bottom: none; }
|
|
970
|
+
.alarm-row-main {
|
|
971
|
+
flex: 1;
|
|
972
|
+
display: flex;
|
|
973
|
+
align-items: center;
|
|
974
|
+
gap: var(--gap-xs);
|
|
975
|
+
min-width: 0;
|
|
976
|
+
}
|
|
977
|
+
.alarm-row-kind { font-size: 1.1em; }
|
|
978
|
+
.alarm-row-title {
|
|
979
|
+
font-weight: 500;
|
|
980
|
+
overflow: hidden;
|
|
981
|
+
text-overflow: ellipsis;
|
|
982
|
+
white-space: nowrap;
|
|
983
|
+
flex: 1;
|
|
984
|
+
}
|
|
985
|
+
.alarm-row-when {
|
|
986
|
+
color: var(--color-text-muted);
|
|
987
|
+
font-size: var(--font-size-sm);
|
|
988
|
+
font-variant-numeric: tabular-nums;
|
|
989
|
+
white-space: nowrap;
|
|
990
|
+
}
|
|
991
|
+
.alarm-row-actions { display: flex; gap: var(--gap-xs); }
|
|
992
|
+
.alarm-row-link,
|
|
993
|
+
.alarm-row-dismiss {
|
|
994
|
+
border: 0;
|
|
995
|
+
background: transparent;
|
|
996
|
+
color: var(--color-text-muted);
|
|
997
|
+
cursor: pointer;
|
|
998
|
+
font-size: 1em;
|
|
999
|
+
padding: 0 0.3em;
|
|
1000
|
+
border-radius: 3px;
|
|
1001
|
+
}
|
|
1002
|
+
.alarm-row-link:hover,
|
|
1003
|
+
.alarm-row-dismiss:hover {
|
|
1004
|
+
background: var(--color-bg-hover);
|
|
1005
|
+
color: var(--color-text);
|
|
1006
|
+
}
|
|
1007
|
+
.alarm-foot {
|
|
1008
|
+
display: flex;
|
|
1009
|
+
align-items: center;
|
|
1010
|
+
gap: var(--gap-sm);
|
|
1011
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
1012
|
+
border-top: 1px solid var(--color-border);
|
|
1013
|
+
background: var(--color-bg-toolbar);
|
|
1014
|
+
border-radius: 0 0 8px 8px;
|
|
1015
|
+
}
|
|
1016
|
+
.alarm-snooze-label {
|
|
1017
|
+
font-size: var(--font-size-sm);
|
|
1018
|
+
color: var(--color-text-muted);
|
|
1019
|
+
display: flex;
|
|
1020
|
+
align-items: center;
|
|
1021
|
+
gap: var(--gap-xs);
|
|
1022
|
+
}
|
|
1023
|
+
.alarm-snooze-sel {
|
|
1024
|
+
background: var(--color-bg);
|
|
1025
|
+
color: var(--color-text);
|
|
1026
|
+
border: 1px solid var(--color-border);
|
|
1027
|
+
border-radius: 4px;
|
|
1028
|
+
padding: 0.25em 0.5em;
|
|
1029
|
+
font-size: var(--font-size-sm);
|
|
1030
|
+
}
|
|
1031
|
+
.alarm-btn {
|
|
1032
|
+
border: 1px solid var(--color-border);
|
|
1033
|
+
background: var(--color-bg);
|
|
1034
|
+
color: var(--color-text);
|
|
1035
|
+
cursor: pointer;
|
|
1036
|
+
padding: 0.4em 1em;
|
|
1037
|
+
border-radius: 4px;
|
|
1038
|
+
font-size: var(--font-size-sm);
|
|
1039
|
+
}
|
|
1040
|
+
.alarm-btn:hover { background: var(--color-bg-hover); }
|
|
1041
|
+
.alarm-btn-primary {
|
|
1042
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1043
|
+
color: #fff;
|
|
1044
|
+
border-color: var(--color-brand, oklch(0.65 0.14 250));
|
|
1045
|
+
}
|
|
1046
|
+
.alarm-btn-primary:hover { filter: brightness(1.1); }
|
|
1047
|
+
|
|
1048
|
+
/* Bulk-actions bar — appears over the list header when multi-select mode is
|
|
1049
|
+
active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
|
|
1050
|
+
buttons + Cancel. Kept in sibling position to ml-header so it uses the
|
|
1051
|
+
same horizontal space. */
|
|
1052
|
+
.ml-bulkbar {
|
|
1053
|
+
display: flex;
|
|
1054
|
+
align-items: center;
|
|
1055
|
+
gap: var(--gap-sm);
|
|
1056
|
+
padding: var(--gap-xs) var(--gap-sm);
|
|
1057
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1058
|
+
color: #fff;
|
|
1059
|
+
font-size: var(--font-size-sm);
|
|
1060
|
+
border-bottom: 1px solid var(--color-border);
|
|
1061
|
+
grid-column: 1 / -1;
|
|
1062
|
+
}
|
|
1063
|
+
.ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
1064
|
+
.ml-bulk-cancel,
|
|
1065
|
+
.ml-bulk-btn {
|
|
1066
|
+
border: 0;
|
|
1067
|
+
background: transparent;
|
|
1068
|
+
color: inherit;
|
|
1069
|
+
cursor: pointer;
|
|
1070
|
+
padding: 0.25em 0.55em;
|
|
1071
|
+
border-radius: 4px;
|
|
1072
|
+
font-size: 1rem;
|
|
1073
|
+
}
|
|
1074
|
+
.ml-bulk-cancel:hover,
|
|
1075
|
+
.ml-bulk-btn:hover {
|
|
1076
|
+
background: oklch(1 0 0 / 0.15);
|
|
1077
|
+
}
|
|
1078
|
+
.ml-bulk-btn.ml-bulk-danger:hover {
|
|
1079
|
+
background: oklch(0.65 0.22 25 / 0.4);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
1083
|
+
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
1084
|
+
in selection mode, not navigation mode. */
|
|
1085
|
+
|
|
903
1086
|
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
904
1087
|
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
905
1088
|
in selection mode, not navigation mode. */
|
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
|