@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
|
@@ -11,7 +11,18 @@ logClientEvent("compose-module-loaded", { href: location.href, version: window.m
|
|
|
11
11
|
/** Close compose window */
|
|
12
12
|
function closeCompose() {
|
|
13
13
|
logClientEvent("compose-close");
|
|
14
|
-
window.close()
|
|
14
|
+
// S61: Android WebView's window.close() override is unreliable inside
|
|
15
|
+
// iframes — compose overlay sometimes stays visible after Send. Primary
|
|
16
|
+
// path is a parent postMessage; window.close() is a fallback that also
|
|
17
|
+
// works on desktop/msger where the override DOES fire reliably.
|
|
18
|
+
try {
|
|
19
|
+
parent.postMessage({ type: "mailx-compose-close" }, "*");
|
|
20
|
+
}
|
|
21
|
+
catch { /* */ }
|
|
22
|
+
try {
|
|
23
|
+
window.close();
|
|
24
|
+
}
|
|
25
|
+
catch { /* */ }
|
|
15
26
|
}
|
|
16
27
|
// ── Load editor scripts dynamically ──
|
|
17
28
|
function loadScript(src) {
|
|
@@ -147,20 +158,54 @@ if (appSettings?.autocomplete?.enabled && appSettings.autocomplete.provider !==
|
|
|
147
158
|
function formatAccountFrom(acct) {
|
|
148
159
|
return `${acct.name} <${acct.email}>`;
|
|
149
160
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
const FROM_HISTORY_KEY = "mailx-from-history"; // up to 20 recent manual From entries
|
|
162
|
+
const FROM_HISTORY_MAX = 20;
|
|
163
|
+
function loadFromHistory() {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(localStorage.getItem(FROM_HISTORY_KEY) || "[]");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function recordFromHistory(value) {
|
|
172
|
+
const v = (value || "").trim();
|
|
173
|
+
if (!v)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
const list = loadFromHistory().filter(x => x !== v);
|
|
177
|
+
list.unshift(v);
|
|
178
|
+
localStorage.setItem(FROM_HISTORY_KEY, JSON.stringify(list.slice(0, FROM_HISTORY_MAX)));
|
|
179
|
+
}
|
|
180
|
+
catch { /* private mode */ }
|
|
181
|
+
}
|
|
182
|
+
/** Populate the From <datalist> with one entry per known account plus any
|
|
183
|
+
* manually-typed addresses from localStorage history. Account entries rank
|
|
184
|
+
* first; history entries get an "(used before)" label so the user can tell
|
|
185
|
+
* which ones are real accounts vs free-form aliases. */
|
|
153
186
|
function populateFromOptions(accounts, selectedId) {
|
|
154
187
|
knownAccounts = accounts;
|
|
155
188
|
fromOptions.innerHTML = "";
|
|
189
|
+
const seenValues = new Set();
|
|
156
190
|
for (const acct of accounts) {
|
|
157
191
|
const opt = document.createElement("option");
|
|
158
192
|
opt.value = formatAccountFrom(acct);
|
|
159
|
-
// datalist options can carry a label so the dropdown row shows the
|
|
160
|
-
// friendly account tag ("gmail", "bob.ma") next to the address.
|
|
161
193
|
const tag = acct.label || acct.name;
|
|
162
194
|
opt.label = tag;
|
|
163
195
|
fromOptions.appendChild(opt);
|
|
196
|
+
seenValues.add(opt.value);
|
|
197
|
+
}
|
|
198
|
+
// Custom From history — addresses the user has typed before that don't
|
|
199
|
+
// match any known account (aliases, +tag addresses, one-off identities).
|
|
200
|
+
// Stored in localStorage because they're inherently per-device preferences;
|
|
201
|
+
// moving them to an account profile would be a different feature.
|
|
202
|
+
for (const value of loadFromHistory()) {
|
|
203
|
+
if (seenValues.has(value))
|
|
204
|
+
continue;
|
|
205
|
+
const opt = document.createElement("option");
|
|
206
|
+
opt.value = value;
|
|
207
|
+
opt.label = "(used before)";
|
|
208
|
+
fromOptions.appendChild(opt);
|
|
164
209
|
}
|
|
165
210
|
if (!fromInput.value) {
|
|
166
211
|
const selected = (selectedId && accounts.find(a => a.id === selectedId)) ||
|
|
@@ -249,41 +294,46 @@ function setupAutocomplete(input) {
|
|
|
249
294
|
closeDropdown();
|
|
250
295
|
return;
|
|
251
296
|
}
|
|
252
|
-
debounce = setTimeout(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
297
|
+
debounce = setTimeout(() => {
|
|
298
|
+
// rAF yield before hitting the DB — S60 mitigation, same reason
|
|
299
|
+
// as the draft-save path. The 200 ms timer already deferred past
|
|
300
|
+
// the input burst; this extra frame lets the last keystroke paint.
|
|
301
|
+
requestAnimationFrame(async () => {
|
|
302
|
+
try {
|
|
303
|
+
const results = await searchContacts(token);
|
|
304
|
+
if (results.length === 0) {
|
|
305
|
+
closeDropdown();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
256
308
|
closeDropdown();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
dropdown.appendChild(item);
|
|
309
|
+
dropdown = document.createElement("div");
|
|
310
|
+
dropdown.className = "ac-dropdown";
|
|
311
|
+
activeIndex = 0; // first item highlighted by default
|
|
312
|
+
for (let i = 0; i < results.length; i++) {
|
|
313
|
+
const item = document.createElement("div");
|
|
314
|
+
item.className = `ac-item${i === 0 ? " ac-active" : ""}`;
|
|
315
|
+
const nameEl = document.createElement("span");
|
|
316
|
+
nameEl.className = "ac-item-name";
|
|
317
|
+
nameEl.textContent = results[i].name || results[i].email;
|
|
318
|
+
const emailEl = document.createElement("span");
|
|
319
|
+
emailEl.className = "ac-item-email";
|
|
320
|
+
emailEl.textContent = results[i].email;
|
|
321
|
+
item.appendChild(nameEl);
|
|
322
|
+
if (results[i].name)
|
|
323
|
+
item.appendChild(emailEl);
|
|
324
|
+
item.addEventListener("mousedown", (e) => {
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
const display = results[i].name
|
|
327
|
+
? `${results[i].name} <${results[i].email}>`
|
|
328
|
+
: results[i].email;
|
|
329
|
+
replaceLastToken(display);
|
|
330
|
+
});
|
|
331
|
+
dropdown.appendChild(item);
|
|
332
|
+
}
|
|
333
|
+
input.parentElement.appendChild(dropdown);
|
|
283
334
|
}
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
catch { /* ignore */ }
|
|
335
|
+
catch { /* ignore */ }
|
|
336
|
+
});
|
|
287
337
|
}, 200);
|
|
288
338
|
});
|
|
289
339
|
input.addEventListener("keydown", (e) => {
|
|
@@ -361,6 +411,30 @@ function applyInit(init) {
|
|
|
361
411
|
if (ccBtn)
|
|
362
412
|
ccBtn.classList.add("active");
|
|
363
413
|
}
|
|
414
|
+
else if (init.to && init.to.length === 1) {
|
|
415
|
+
// Q49: heuristic auto-expand — when replying/composing to a single
|
|
416
|
+
// recipient, check sent-history. If the user has previously Cc'd
|
|
417
|
+
// anyone on a message to this recipient, expand the Cc row (empty,
|
|
418
|
+
// just visible) so they're prompted to fill it. Fire-and-forget; if
|
|
419
|
+
// the service call fails or the user starts typing Cc manually
|
|
420
|
+
// before it resolves, the answer doesn't matter.
|
|
421
|
+
const firstEmail = init.to[0]?.address || "";
|
|
422
|
+
if (firstEmail) {
|
|
423
|
+
import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
|
|
424
|
+
.then(res => {
|
|
425
|
+
if (!res?.hasCc)
|
|
426
|
+
return;
|
|
427
|
+
const ccRowEl = document.getElementById("compose-cc-row");
|
|
428
|
+
const ccBtn = document.getElementById("btn-toggle-cc");
|
|
429
|
+
// Only expand if user hasn't already interacted
|
|
430
|
+
if (ccRowEl?.hidden && !ccInput.value) {
|
|
431
|
+
ccRowEl.hidden = false;
|
|
432
|
+
ccBtn?.classList.add("active");
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
.catch(() => { }));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
364
438
|
// C42: append the account's signature (if configured) BEFORE rendering
|
|
365
439
|
// the body. For new mode: just signature. For reply/forward: appended
|
|
366
440
|
// after the quoted block. Drafts are skipped — the signature is already
|
|
@@ -475,12 +549,23 @@ async function saveDraft() {
|
|
|
475
549
|
draftSaving = false;
|
|
476
550
|
}
|
|
477
551
|
}
|
|
478
|
-
/** Schedule a debounced save on user input — fires ~1.5s after the last
|
|
552
|
+
/** Schedule a debounced save on user input — fires ~1.5s after the last
|
|
553
|
+
* keystroke, then yields one animation frame before actually writing so
|
|
554
|
+
* the browser can paint any keystroke in the mean time. S60 mitigation:
|
|
555
|
+
* wa-sqlite writes are synchronous on Android; this keeps the typing
|
|
556
|
+
* experience responsive by never running the write in the same task as
|
|
557
|
+
* an input event. */
|
|
479
558
|
function scheduleDraftSave() {
|
|
480
559
|
markComposeDirty();
|
|
481
560
|
if (draftDebounceTimer)
|
|
482
561
|
clearTimeout(draftDebounceTimer);
|
|
483
|
-
draftDebounceTimer = setTimeout(() => {
|
|
562
|
+
draftDebounceTimer = setTimeout(() => {
|
|
563
|
+
draftDebounceTimer = null;
|
|
564
|
+
// rAF yield — lets any pending keystroke render before we block on
|
|
565
|
+
// the DB write. A no-op when the tab is hidden (rAF is throttled),
|
|
566
|
+
// which is fine because the user isn't typing then either.
|
|
567
|
+
requestAnimationFrame(() => { saveDraft(); });
|
|
568
|
+
}, DRAFT_INPUT_DEBOUNCE_MS);
|
|
484
569
|
}
|
|
485
570
|
// ── Initialize: local-first population.
|
|
486
571
|
//
|
|
@@ -665,6 +750,17 @@ document.getElementById("btn-send")?.addEventListener("click", () => {
|
|
|
665
750
|
.then(() => {
|
|
666
751
|
logClientEvent("compose-send-ipc-resolved", { ms: Date.now() - sendStart });
|
|
667
752
|
console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
|
|
753
|
+
// Record From-address history on successful send. Only manual
|
|
754
|
+
// values worth keeping — skip anything that exactly matches a
|
|
755
|
+
// known account (already in the dropdown), and skip obviously
|
|
756
|
+
// invalid inputs. Populated dropdown surfaces this next time.
|
|
757
|
+
try {
|
|
758
|
+
const raw = fromInput.value.trim();
|
|
759
|
+
const known = knownAccounts.some(a => formatAccountFrom(a) === raw);
|
|
760
|
+
if (raw && !known && /@.+\./.test(raw))
|
|
761
|
+
recordFromHistory(raw);
|
|
762
|
+
}
|
|
763
|
+
catch { /* */ }
|
|
668
764
|
// Stop autosave only after ACK — if send threw we want the draft
|
|
669
765
|
// autosave to keep the message safe.
|
|
670
766
|
if (draftTimer) {
|
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">
|
|
@@ -118,11 +118,22 @@
|
|
|
118
118
|
</search>
|
|
119
119
|
<div class="ml-folder-title" id="ml-folder-title"></div>
|
|
120
120
|
<div class="ml-header" id="ml-header">
|
|
121
|
+
<span class="ml-col ml-col-avatar"></span>
|
|
121
122
|
<span class="ml-col ml-col-flag"></span>
|
|
122
123
|
<span class="ml-col ml-col-from ml-col-sortable" data-sort="from">From</span>
|
|
123
124
|
<span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
|
|
124
125
|
<span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
|
|
125
126
|
</div>
|
|
127
|
+
<div class="ml-bulkbar" id="ml-bulkbar" hidden>
|
|
128
|
+
<button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
|
|
129
|
+
<span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
|
|
130
|
+
<span style="flex:1"></span>
|
|
131
|
+
<button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
|
|
132
|
+
<button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
|
|
133
|
+
<button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
|
|
134
|
+
<button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
|
|
135
|
+
<button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
|
|
136
|
+
</div>
|
|
126
137
|
<div class="ml-body" id="ml-body">
|
|
127
138
|
<div class="ml-empty">Select a folder to view messages</div>
|
|
128
139
|
</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) {
|
|
@@ -299,7 +299,8 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
299
299
|
|
|
300
300
|
.message-list {
|
|
301
301
|
display: grid;
|
|
302
|
-
|
|
302
|
+
/* avatar | flag | from | date | subject */
|
|
303
|
+
grid-template-columns: 28px 1.2em minmax(120px, 200px) auto 1fr;
|
|
303
304
|
grid-template-rows: auto auto 1fr;
|
|
304
305
|
column-gap: var(--gap-sm);
|
|
305
306
|
overflow: hidden;
|
|
@@ -308,7 +309,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
308
309
|
|
|
309
310
|
/* Two-line view */
|
|
310
311
|
.message-list.two-line {
|
|
311
|
-
grid-template-columns: 1.2em 1fr auto;
|
|
312
|
+
grid-template-columns: 28px 1.2em 1fr auto;
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
.message-list.two-line .ml-row {
|
|
@@ -316,11 +317,12 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
316
317
|
grid-template-rows: auto auto;
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
.message-list.two-line .ml-
|
|
320
|
-
.message-list.two-line .ml-
|
|
321
|
-
.message-list.two-line .ml-
|
|
320
|
+
.message-list.two-line .ml-avatar { grid-row: 1 / 3; align-self: center; grid-column: 1; }
|
|
321
|
+
.message-list.two-line .ml-flag { grid-row: 1 / 3; align-self: center; grid-column: 2; }
|
|
322
|
+
.message-list.two-line .ml-from { grid-column: 3; }
|
|
323
|
+
.message-list.two-line .ml-date { grid-column: 4; grid-row: 1; padding-right: var(--gap-md); }
|
|
322
324
|
.message-list.two-line .ml-subject {
|
|
323
|
-
grid-column:
|
|
325
|
+
grid-column: 3 / 5;
|
|
324
326
|
grid-row: 2;
|
|
325
327
|
color: oklch(0.55 0.10 250) !important;
|
|
326
328
|
font-size: var(--font-size-sm);
|
|
@@ -900,6 +902,249 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
900
902
|
list empty when the selection is a singleton thread. */
|
|
901
903
|
.ml-row.thread-filter-hidden { display: none; }
|
|
902
904
|
|
|
905
|
+
/* Sender avatar — Thunderbird-style 24-px circle with the sender's first
|
|
906
|
+
initial. Background color is hashed from the sender's address so the
|
|
907
|
+
same person keeps the same color across rows. In multi-select mode
|
|
908
|
+
(#ml-body.multi-select-on) the avatar swaps to a checkmark per row,
|
|
909
|
+
filled when selected, hollow when not — matches the Thunderbird /
|
|
910
|
+
Gmail / Apple Mail pattern. */
|
|
911
|
+
.ml-avatar {
|
|
912
|
+
width: 24px;
|
|
913
|
+
height: 24px;
|
|
914
|
+
border-radius: 50%;
|
|
915
|
+
background: oklch(0.62 0.14 250);
|
|
916
|
+
color: #fff;
|
|
917
|
+
font-size: 0.78rem;
|
|
918
|
+
font-weight: 600;
|
|
919
|
+
line-height: 24px;
|
|
920
|
+
text-align: center;
|
|
921
|
+
user-select: none;
|
|
922
|
+
flex-shrink: 0;
|
|
923
|
+
align-self: center;
|
|
924
|
+
cursor: pointer;
|
|
925
|
+
}
|
|
926
|
+
#ml-body.multi-select-on .ml-avatar {
|
|
927
|
+
background: transparent !important;
|
|
928
|
+
color: var(--color-text-muted);
|
|
929
|
+
border: 2px solid currentColor;
|
|
930
|
+
line-height: 20px;
|
|
931
|
+
}
|
|
932
|
+
#ml-body.multi-select-on .ml-avatar::before { content: ""; }
|
|
933
|
+
#ml-body.multi-select-on .ml-row.selected .ml-avatar {
|
|
934
|
+
background: var(--color-brand, oklch(0.55 0.18 250)) !important;
|
|
935
|
+
color: #fff;
|
|
936
|
+
border-color: var(--color-brand, oklch(0.55 0.18 250));
|
|
937
|
+
}
|
|
938
|
+
/* Replace the initial with a check when selected in multi-mode. The
|
|
939
|
+
`font-size: 0` trick blanks the text node we set in JS without having
|
|
940
|
+
to clear it; the ::after glyph then fills the space. */
|
|
941
|
+
#ml-body.multi-select-on .ml-avatar { font-size: 0; }
|
|
942
|
+
#ml-body.multi-select-on .ml-avatar::after { content: ""; }
|
|
943
|
+
#ml-body.multi-select-on .ml-row.selected .ml-avatar::after {
|
|
944
|
+
content: "✓";
|
|
945
|
+
font-size: 0.95rem;
|
|
946
|
+
line-height: 20px;
|
|
947
|
+
font-weight: 700;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/* Add an avatar slot to the header so columns line up. Header is a sibling
|
|
951
|
+
subgrid; first cell is empty (avatar column). */
|
|
952
|
+
.ml-header { grid-template-columns: subgrid; }
|
|
953
|
+
|
|
954
|
+
/* Duplicate-message-id tag in unified inbox — shown when the same
|
|
955
|
+
Message-ID appears across 2+ accounts (same letter delivered to both,
|
|
956
|
+
mailing-list Bcc, etc.). Subtle teal badge; tooltip explains. */
|
|
957
|
+
.ml-dupe-tag {
|
|
958
|
+
display: inline-block;
|
|
959
|
+
color: oklch(0.55 0.14 195);
|
|
960
|
+
font-weight: 600;
|
|
961
|
+
margin-right: 0.3em;
|
|
962
|
+
user-select: none;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
|
|
966
|
+
idea but better when we have a second-stage plan strategy". Touch users
|
|
967
|
+
still reach the menu via long-press → multi-select → bulk-bar, or by
|
|
968
|
+
using a stylus/mouse for contextmenu. Revisit when we've decided what
|
|
969
|
+
additional per-message actions belong here. */
|
|
970
|
+
|
|
971
|
+
/* ── Alarm popup (P17 / Q104) ── */
|
|
972
|
+
/* Fullscreen backdrop + centered panel; mirrors .mailx-modal* patterns but
|
|
973
|
+
kept in its own namespace so alarm behavior (snooze/dismiss) can evolve
|
|
974
|
+
without dragging the whole modal system along. */
|
|
975
|
+
.alarm-overlay {
|
|
976
|
+
position: fixed;
|
|
977
|
+
inset: 0;
|
|
978
|
+
background: rgba(0, 0, 0, 0.45);
|
|
979
|
+
z-index: 9000;
|
|
980
|
+
display: flex;
|
|
981
|
+
align-items: center;
|
|
982
|
+
justify-content: center;
|
|
983
|
+
}
|
|
984
|
+
.alarm-panel {
|
|
985
|
+
background: var(--color-bg);
|
|
986
|
+
color: var(--color-text);
|
|
987
|
+
border: 1px solid var(--color-border);
|
|
988
|
+
border-radius: 8px;
|
|
989
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
|
990
|
+
min-width: 380px;
|
|
991
|
+
max-width: 560px;
|
|
992
|
+
width: 90vw;
|
|
993
|
+
display: flex;
|
|
994
|
+
flex-direction: column;
|
|
995
|
+
max-height: 80vh;
|
|
996
|
+
}
|
|
997
|
+
.alarm-head {
|
|
998
|
+
display: flex;
|
|
999
|
+
align-items: center;
|
|
1000
|
+
gap: var(--gap-sm);
|
|
1001
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
1002
|
+
border-bottom: 1px solid var(--color-border);
|
|
1003
|
+
font-weight: 600;
|
|
1004
|
+
}
|
|
1005
|
+
.alarm-icon { font-size: 1.4em; }
|
|
1006
|
+
.alarm-title { flex: 1; }
|
|
1007
|
+
.alarm-close {
|
|
1008
|
+
border: 0;
|
|
1009
|
+
background: transparent;
|
|
1010
|
+
color: var(--color-text-muted);
|
|
1011
|
+
cursor: pointer;
|
|
1012
|
+
font-size: 1.4em;
|
|
1013
|
+
line-height: 1;
|
|
1014
|
+
padding: 0 0.4em;
|
|
1015
|
+
}
|
|
1016
|
+
.alarm-close:hover { color: var(--color-text); }
|
|
1017
|
+
.alarm-list {
|
|
1018
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
1019
|
+
overflow-y: auto;
|
|
1020
|
+
display: flex;
|
|
1021
|
+
flex-direction: column;
|
|
1022
|
+
gap: var(--gap-xs);
|
|
1023
|
+
}
|
|
1024
|
+
.alarm-row {
|
|
1025
|
+
display: flex;
|
|
1026
|
+
align-items: center;
|
|
1027
|
+
gap: var(--gap-sm);
|
|
1028
|
+
padding: var(--gap-xs) 0;
|
|
1029
|
+
border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
|
|
1030
|
+
}
|
|
1031
|
+
.alarm-row:last-child { border-bottom: none; }
|
|
1032
|
+
.alarm-row-main {
|
|
1033
|
+
flex: 1;
|
|
1034
|
+
display: flex;
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
gap: var(--gap-xs);
|
|
1037
|
+
min-width: 0;
|
|
1038
|
+
}
|
|
1039
|
+
.alarm-row-kind { font-size: 1.1em; }
|
|
1040
|
+
.alarm-row-title {
|
|
1041
|
+
font-weight: 500;
|
|
1042
|
+
overflow: hidden;
|
|
1043
|
+
text-overflow: ellipsis;
|
|
1044
|
+
white-space: nowrap;
|
|
1045
|
+
flex: 1;
|
|
1046
|
+
}
|
|
1047
|
+
.alarm-row-when {
|
|
1048
|
+
color: var(--color-text-muted);
|
|
1049
|
+
font-size: var(--font-size-sm);
|
|
1050
|
+
font-variant-numeric: tabular-nums;
|
|
1051
|
+
white-space: nowrap;
|
|
1052
|
+
}
|
|
1053
|
+
.alarm-row-actions { display: flex; gap: var(--gap-xs); }
|
|
1054
|
+
.alarm-row-link,
|
|
1055
|
+
.alarm-row-dismiss {
|
|
1056
|
+
border: 0;
|
|
1057
|
+
background: transparent;
|
|
1058
|
+
color: var(--color-text-muted);
|
|
1059
|
+
cursor: pointer;
|
|
1060
|
+
font-size: 1em;
|
|
1061
|
+
padding: 0 0.3em;
|
|
1062
|
+
border-radius: 3px;
|
|
1063
|
+
}
|
|
1064
|
+
.alarm-row-link:hover,
|
|
1065
|
+
.alarm-row-dismiss:hover {
|
|
1066
|
+
background: var(--color-bg-hover);
|
|
1067
|
+
color: var(--color-text);
|
|
1068
|
+
}
|
|
1069
|
+
.alarm-foot {
|
|
1070
|
+
display: flex;
|
|
1071
|
+
align-items: center;
|
|
1072
|
+
gap: var(--gap-sm);
|
|
1073
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
1074
|
+
border-top: 1px solid var(--color-border);
|
|
1075
|
+
background: var(--color-bg-toolbar);
|
|
1076
|
+
border-radius: 0 0 8px 8px;
|
|
1077
|
+
}
|
|
1078
|
+
.alarm-snooze-label {
|
|
1079
|
+
font-size: var(--font-size-sm);
|
|
1080
|
+
color: var(--color-text-muted);
|
|
1081
|
+
display: flex;
|
|
1082
|
+
align-items: center;
|
|
1083
|
+
gap: var(--gap-xs);
|
|
1084
|
+
}
|
|
1085
|
+
.alarm-snooze-sel {
|
|
1086
|
+
background: var(--color-bg);
|
|
1087
|
+
color: var(--color-text);
|
|
1088
|
+
border: 1px solid var(--color-border);
|
|
1089
|
+
border-radius: 4px;
|
|
1090
|
+
padding: 0.25em 0.5em;
|
|
1091
|
+
font-size: var(--font-size-sm);
|
|
1092
|
+
}
|
|
1093
|
+
.alarm-btn {
|
|
1094
|
+
border: 1px solid var(--color-border);
|
|
1095
|
+
background: var(--color-bg);
|
|
1096
|
+
color: var(--color-text);
|
|
1097
|
+
cursor: pointer;
|
|
1098
|
+
padding: 0.4em 1em;
|
|
1099
|
+
border-radius: 4px;
|
|
1100
|
+
font-size: var(--font-size-sm);
|
|
1101
|
+
}
|
|
1102
|
+
.alarm-btn:hover { background: var(--color-bg-hover); }
|
|
1103
|
+
.alarm-btn-primary {
|
|
1104
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1105
|
+
color: #fff;
|
|
1106
|
+
border-color: var(--color-brand, oklch(0.65 0.14 250));
|
|
1107
|
+
}
|
|
1108
|
+
.alarm-btn-primary:hover { filter: brightness(1.1); }
|
|
1109
|
+
|
|
1110
|
+
/* Bulk-actions bar — appears over the list header when multi-select mode is
|
|
1111
|
+
active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
|
|
1112
|
+
buttons + Cancel. Kept in sibling position to ml-header so it uses the
|
|
1113
|
+
same horizontal space. */
|
|
1114
|
+
.ml-bulkbar {
|
|
1115
|
+
display: flex;
|
|
1116
|
+
align-items: center;
|
|
1117
|
+
gap: var(--gap-sm);
|
|
1118
|
+
padding: var(--gap-xs) var(--gap-sm);
|
|
1119
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1120
|
+
color: #fff;
|
|
1121
|
+
font-size: var(--font-size-sm);
|
|
1122
|
+
border-bottom: 1px solid var(--color-border);
|
|
1123
|
+
grid-column: 1 / -1;
|
|
1124
|
+
}
|
|
1125
|
+
.ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
1126
|
+
.ml-bulk-cancel,
|
|
1127
|
+
.ml-bulk-btn {
|
|
1128
|
+
border: 0;
|
|
1129
|
+
background: transparent;
|
|
1130
|
+
color: inherit;
|
|
1131
|
+
cursor: pointer;
|
|
1132
|
+
padding: 0.25em 0.55em;
|
|
1133
|
+
border-radius: 4px;
|
|
1134
|
+
font-size: 1rem;
|
|
1135
|
+
}
|
|
1136
|
+
.ml-bulk-cancel:hover,
|
|
1137
|
+
.ml-bulk-btn:hover {
|
|
1138
|
+
background: oklch(1 0 0 / 0.15);
|
|
1139
|
+
}
|
|
1140
|
+
.ml-bulk-btn.ml-bulk-danger:hover {
|
|
1141
|
+
background: oklch(0.65 0.22 25 / 0.4);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
1145
|
+
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
1146
|
+
in selection mode, not navigation mode. */
|
|
1147
|
+
|
|
903
1148
|
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
904
1149
|
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
905
1150
|
in selection mode, not navigation mode. */
|
package/package.json
CHANGED
|
@@ -2402,14 +2402,19 @@ export class ImapManager extends EventEmitter {
|
|
|
2402
2402
|
async updateFlagsLocal(accountId, uid, folderId, flags) {
|
|
2403
2403
|
this.db.updateMessageFlags(accountId, uid, flags);
|
|
2404
2404
|
this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
|
|
2405
|
-
//
|
|
2406
|
-
//
|
|
2405
|
+
// User-visible pink-dot pending state stays until the action drains.
|
|
2406
|
+
// The 30-second periodic tick was too slow — opening one message to
|
|
2407
|
+
// auto-mark-as-read left it pink for half a minute. Same 1-second
|
|
2408
|
+
// debounce as moves/deletes batches rapid flag churn without the
|
|
2409
|
+
// visual lag.
|
|
2410
|
+
this.debounceSyncActions(accountId);
|
|
2407
2411
|
}
|
|
2408
2412
|
/** Process pending sync actions for an account */
|
|
2409
2413
|
async processSyncActions(accountId) {
|
|
2410
2414
|
const actions = this.db.getPendingSyncActions(accountId);
|
|
2411
2415
|
if (actions.length === 0)
|
|
2412
2416
|
return;
|
|
2417
|
+
const startCount = actions.length;
|
|
2413
2418
|
const folders = this.db.getFolders(accountId);
|
|
2414
2419
|
// Gmail path: push flag/label changes through the REST provider so
|
|
2415
2420
|
// they actually reach the server. Earlier this method always went
|
|
@@ -2475,6 +2480,11 @@ export class ImapManager extends EventEmitter {
|
|
|
2475
2480
|
}
|
|
2476
2481
|
catch { /* */ }
|
|
2477
2482
|
}
|
|
2483
|
+
// Nudge the UI so rows that were pending-reconcile re-query their
|
|
2484
|
+
// pending state (pink dot was sticky until this event fired).
|
|
2485
|
+
const remaining = this.db.getPendingSyncActions(accountId).length;
|
|
2486
|
+
if (remaining < startCount)
|
|
2487
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
2478
2488
|
return;
|
|
2479
2489
|
}
|
|
2480
2490
|
await this.withConnection(accountId, async (client) => {
|
|
@@ -2546,6 +2556,12 @@ export class ImapManager extends EventEmitter {
|
|
|
2546
2556
|
}
|
|
2547
2557
|
}
|
|
2548
2558
|
});
|
|
2559
|
+
// IMAP path: same nudge as the Gmail branch above. Any action that
|
|
2560
|
+
// drained (successful or gave-up-after-5) decrements the pending
|
|
2561
|
+
// count, which flips the pink dot off on the next re-query.
|
|
2562
|
+
const remaining = this.db.getPendingSyncActions(accountId).length;
|
|
2563
|
+
if (remaining < startCount)
|
|
2564
|
+
this.emit("folderCountsChanged", accountId, {});
|
|
2549
2565
|
}
|
|
2550
2566
|
/** Find a folder by specialUse, case-insensitive */
|
|
2551
2567
|
findFolder(accountId, specialUse) {
|
|
@@ -66,6 +66,35 @@ const imapManager = new ImapManager(db, () => new NodeTransport());
|
|
|
66
66
|
// ── Express App ──
|
|
67
67
|
const app = express();
|
|
68
68
|
app.use(express.json({ limit: "Infinity" }));
|
|
69
|
+
// Optional token gate — required whenever the server binds to anything other
|
|
70
|
+
// than a loopback address. Set via `MAILX_SERVER_TOKEN=<secret>` (or the
|
|
71
|
+
// shorter `MAILX_TOKEN`). When the server is loopback-only (the default), the
|
|
72
|
+
// gate is a no-op since nothing outside the machine can reach it. When bound
|
|
73
|
+
// externally (`MAILX_SERVER_HOST=0.0.0.0` or `--external`), connections must
|
|
74
|
+
// present the token in either `?t=<token>` or the `x-mailx-token` header. No
|
|
75
|
+
// token configured + external bind → server refuses to start (safer than
|
|
76
|
+
// serving open).
|
|
77
|
+
const SERVER_TOKEN = process.env.MAILX_SERVER_TOKEN || process.env.MAILX_TOKEN || "";
|
|
78
|
+
const SERVER_HOST = process.env.MAILX_SERVER_HOST || "";
|
|
79
|
+
const IS_EXTERNAL_BIND = SERVER_HOST && SERVER_HOST !== "127.0.0.1" && SERVER_HOST !== "localhost" && SERVER_HOST !== "::1";
|
|
80
|
+
if (IS_EXTERNAL_BIND && !SERVER_TOKEN) {
|
|
81
|
+
console.error("[server] Refusing to bind externally without MAILX_SERVER_TOKEN. Set the env var or drop the external host.");
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
app.use((req, res, next) => {
|
|
85
|
+
if (!IS_EXTERNAL_BIND)
|
|
86
|
+
return next();
|
|
87
|
+
// Allow the bare static file fetch so the login page can render; every
|
|
88
|
+
// /api/* path requires the token.
|
|
89
|
+
if (!req.path.startsWith("/api/"))
|
|
90
|
+
return next();
|
|
91
|
+
const provided = req.query.t || req.header("x-mailx-token") || "";
|
|
92
|
+
if (provided !== SERVER_TOKEN) {
|
|
93
|
+
res.status(401).json({ error: "missing or invalid token" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
next();
|
|
97
|
+
});
|
|
69
98
|
// Request logging
|
|
70
99
|
app.use((req, res, next) => {
|
|
71
100
|
const start = Date.now();
|