@bobfrankston/mailx 1.0.224 → 1.0.226
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/README.md +39 -5
- package/client/app.js +18 -0
- package/client/components/message-list.js +39 -1
- package/client/compose/compose.css +23 -0
- package/client/compose/compose.html +6 -2
- package/client/compose/compose.js +32 -0
- package/client/index.html +1 -0
- package/client/styles/components.css +13 -0
- package/package.json +3 -3
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-imap/index.js +7 -3
- package/packages/mailx-imap/providers/gmail-api.js +9 -2
- package/packages/mailx-imap/providers/types.d.ts +2 -0
- package/packages/mailx-store/db.d.ts +11 -0
- package/packages/mailx-store/db.js +82 -3
- package/packages/mailx-types/index.d.ts +1 -0
package/README.md
CHANGED
|
@@ -137,18 +137,28 @@ Gmail OAuth requires a one-time Google Cloud setup:
|
|
|
137
137
|
- **Ctrl+R** -- Reply
|
|
138
138
|
- **Ctrl+Shift+R** -- Reply All
|
|
139
139
|
- **Ctrl+F** -- Forward
|
|
140
|
-
- From dropdown lets you pick which account to send from
|
|
140
|
+
- From dropdown lets you pick which account to send from; reply auto-detects which identity to reply from based on which of your addresses the mail was sent to
|
|
141
141
|
- Contact autocomplete searches Google Contacts as you type in To/Cc/Bcc
|
|
142
|
-
-
|
|
142
|
+
- **Cc / Bcc** are hidden by default — click the toggle buttons next to To to show them
|
|
143
|
+
- **Attach** opens a file picker; attachments show as chips with remove buttons
|
|
144
|
+
- Drafts auto-save 1.5s after you stop typing, plus a 5s safety-net interval, plus on window close
|
|
145
|
+
- Compose window close asks Save / Discard / Cancel if there's content
|
|
146
|
+
- Address validation (`local@domain.tld`) runs on To/Cc/Bcc/From before sending — invalid addresses are refused
|
|
147
|
+
- **Editor shortcuts**: Ctrl+K insert link, Ctrl+Shift+K remove link, Ctrl+Shift+X strikethrough, Ctrl+Shift+7/8 ordered/bullet list, Ctrl+]/[ indent/outdent, Ctrl+Shift+C color, Ctrl+\ clear formatting. Native spell-check via WebView2 (right-click to add to dictionary).
|
|
148
|
+
- **Link editor modal**: Ctrl+K opens a two-field dialog (text + URL) with Remove-link button; hovering any link in the editor shows a floating URL preview
|
|
149
|
+
- **Paste URL** auto-links: paste a bare URL over a selection and it wraps it, or paste into empty space to insert as a link
|
|
143
150
|
|
|
144
151
|
### Managing Messages
|
|
145
152
|
|
|
146
153
|
- **Delete** or **Ctrl+D** -- Delete selected messages (moves to Trash)
|
|
147
|
-
- **Ctrl+Z** -- Undo last delete
|
|
154
|
+
- **Ctrl+Z** -- Undo the last **delete or move** (whichever came last, 60s window)
|
|
148
155
|
- **Ctrl+A** -- Select all messages in the list
|
|
149
156
|
- **Drag and drop** -- Move messages to a folder by dragging them
|
|
150
157
|
- Click the **star** column to flag/unflag a message
|
|
151
|
-
- **Unsubscribe** button appears when the message has a List-Unsubscribe header
|
|
158
|
+
- **Unsubscribe** button appears when the message has a List-Unsubscribe header (one-click)
|
|
159
|
+
- **Right-click on a From/To/Cc address** -- Copy name, Copy address, Copy both, Add to contacts, or Reply/Reply All/Forward
|
|
160
|
+
- **Preview pane zoom** -- Ctrl+wheel, Ctrl+= / Ctrl+- / Ctrl+0, or right-click menu (Zoom in/out/reset, Copy, Select all). Persisted across messages.
|
|
161
|
+
- **Cross-folder search results** show the folder name for each hit
|
|
152
162
|
|
|
153
163
|
### Searching
|
|
154
164
|
|
|
@@ -191,11 +201,35 @@ Under **Settings** in the toolbar:
|
|
|
191
201
|
| Ctrl+Shift+R | Reply All |
|
|
192
202
|
| Ctrl+F | Forward |
|
|
193
203
|
| Delete / Ctrl+D | Delete |
|
|
194
|
-
| Ctrl+Z | Undo delete |
|
|
204
|
+
| Ctrl+Z | Undo last delete or move |
|
|
195
205
|
| Ctrl+A | Select all |
|
|
196
206
|
| F5 | Sync all folders |
|
|
197
207
|
| Escape | Clear search / close menus |
|
|
198
208
|
|
|
209
|
+
**In the compose editor:**
|
|
210
|
+
|
|
211
|
+
| Key | Action |
|
|
212
|
+
|-----|--------|
|
|
213
|
+
| Ctrl+K | Insert / edit link (opens dialog with text + URL fields) |
|
|
214
|
+
| Ctrl+Shift+K | Remove link |
|
|
215
|
+
| Ctrl+B / Ctrl+I / Ctrl+U | Bold / Italic / Underline |
|
|
216
|
+
| Ctrl+Shift+X | Strikethrough |
|
|
217
|
+
| Ctrl+Shift+7 / 8 | Ordered / Bullet list |
|
|
218
|
+
| Ctrl+] / Ctrl+[ | Indent / Outdent |
|
|
219
|
+
| Ctrl+Shift+C | Set text color |
|
|
220
|
+
| Ctrl+\ | Clear formatting |
|
|
221
|
+
| Ctrl+Enter | Send |
|
|
222
|
+
| Escape | Close (prompts Save / Discard / Cancel) |
|
|
223
|
+
|
|
224
|
+
**In the preview pane:**
|
|
225
|
+
|
|
226
|
+
| Key | Action |
|
|
227
|
+
|-----|--------|
|
|
228
|
+
| Ctrl+wheel | Zoom in/out |
|
|
229
|
+
| Ctrl+= / Ctrl+- | Zoom in / out |
|
|
230
|
+
| Ctrl+0 | Reset zoom |
|
|
231
|
+
| Delete | Delete message (also works with focus in preview) |
|
|
232
|
+
|
|
199
233
|
## Command Line
|
|
200
234
|
|
|
201
235
|
```
|
package/client/app.js
CHANGED
|
@@ -1019,6 +1019,7 @@ const viewDropdown = document.getElementById("view-dropdown");
|
|
|
1019
1019
|
const optTwoLine = document.getElementById("opt-two-line");
|
|
1020
1020
|
const optPreview = document.getElementById("opt-preview");
|
|
1021
1021
|
const optSnippet = document.getElementById("opt-snippet");
|
|
1022
|
+
const optThreaded = document.getElementById("opt-threaded");
|
|
1022
1023
|
const optFlagged = document.getElementById("opt-flagged");
|
|
1023
1024
|
const optFolderCounts = document.getElementById("opt-folder-counts");
|
|
1024
1025
|
// Toggle dropdown
|
|
@@ -1037,6 +1038,7 @@ document.addEventListener("click", () => {
|
|
|
1037
1038
|
const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
|
|
1038
1039
|
const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
|
|
1039
1040
|
const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
|
|
1041
|
+
const savedThreaded = localStorage.getItem("mailx-threaded") === "true";
|
|
1040
1042
|
const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
|
|
1041
1043
|
const savedFolderCounts = localStorage.getItem("mailx-folder-counts") === "true";
|
|
1042
1044
|
if (optTwoLine)
|
|
@@ -1045,6 +1047,8 @@ if (optPreview)
|
|
|
1045
1047
|
optPreview.checked = savedPreview;
|
|
1046
1048
|
if (optSnippet)
|
|
1047
1049
|
optSnippet.checked = savedSnippet;
|
|
1050
|
+
if (optThreaded)
|
|
1051
|
+
optThreaded.checked = savedThreaded;
|
|
1048
1052
|
if (optFlagged)
|
|
1049
1053
|
optFlagged.checked = savedFlagged;
|
|
1050
1054
|
if (optFolderCounts)
|
|
@@ -1055,6 +1059,8 @@ if (!savedPreview)
|
|
|
1055
1059
|
document.querySelector(".main-area")?.classList.add("no-preview");
|
|
1056
1060
|
if (!savedSnippet)
|
|
1057
1061
|
document.getElementById("message-list")?.classList.add("no-snippets");
|
|
1062
|
+
if (savedThreaded)
|
|
1063
|
+
document.getElementById("ml-body")?.classList.add("threaded");
|
|
1058
1064
|
if (savedFlagged)
|
|
1059
1065
|
document.getElementById("ml-body")?.classList.add("flagged-only");
|
|
1060
1066
|
if (savedFolderCounts)
|
|
@@ -1092,6 +1098,18 @@ optSnippet?.addEventListener("change", () => {
|
|
|
1092
1098
|
}
|
|
1093
1099
|
localStorage.setItem("mailx-snippet", String(optSnippet.checked));
|
|
1094
1100
|
});
|
|
1101
|
+
// Threaded view toggle
|
|
1102
|
+
optThreaded?.addEventListener("change", () => {
|
|
1103
|
+
const body = document.getElementById("ml-body");
|
|
1104
|
+
if (optThreaded.checked) {
|
|
1105
|
+
body?.classList.add("threaded");
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
body?.classList.remove("threaded");
|
|
1109
|
+
}
|
|
1110
|
+
localStorage.setItem("mailx-threaded", String(optThreaded.checked));
|
|
1111
|
+
reloadCurrentFolder();
|
|
1112
|
+
});
|
|
1095
1113
|
// Flagged-only filter
|
|
1096
1114
|
optFlagged?.addEventListener("change", () => {
|
|
1097
1115
|
const body = document.getElementById("ml-body");
|
|
@@ -302,7 +302,33 @@ function restoreSelection(body, savedUid) {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
function appendMessages(body, accountId, items) {
|
|
305
|
-
|
|
305
|
+
// Thread grouping: when the list has the "threaded" class, collapse messages
|
|
306
|
+
// sharing the same threadId to a single row showing the most recent message,
|
|
307
|
+
// with a small pill indicating the thread size. Pre-threading messages have
|
|
308
|
+
// no threadId — those are treated as singletons keyed by their own uid.
|
|
309
|
+
const threaded = body.classList.contains("threaded");
|
|
310
|
+
let rowsToRender = items;
|
|
311
|
+
let threadSize = null;
|
|
312
|
+
if (threaded) {
|
|
313
|
+
const threadMap = new Map(); // threadId → newest msg
|
|
314
|
+
threadSize = new Map();
|
|
315
|
+
for (const msg of items) {
|
|
316
|
+
const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
|
|
317
|
+
const existing = threadMap.get(key);
|
|
318
|
+
if (!existing || (msg.date || 0) > (existing.date || 0)) {
|
|
319
|
+
threadMap.set(key, msg);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Count messages per thread
|
|
323
|
+
for (const msg of items) {
|
|
324
|
+
const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
|
|
325
|
+
const head = threadMap.get(key);
|
|
326
|
+
if (head)
|
|
327
|
+
threadSize.set(head, (threadSize.get(head) || 0) + 1);
|
|
328
|
+
}
|
|
329
|
+
rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));
|
|
330
|
+
}
|
|
331
|
+
for (const msg of rowsToRender) {
|
|
306
332
|
const msgAccountId = msg.accountId || accountId;
|
|
307
333
|
const row = document.createElement("div");
|
|
308
334
|
row.className = "ml-row";
|
|
@@ -347,6 +373,18 @@ function appendMessages(body, accountId, items) {
|
|
|
347
373
|
const subject = document.createElement("span");
|
|
348
374
|
subject.className = "ml-subject";
|
|
349
375
|
subject.innerHTML = escapeHtml(msg.subject);
|
|
376
|
+
// Thread size pill: e.g. "(3)" next to the subject when this row
|
|
377
|
+
// represents a collapsed thread with multiple messages.
|
|
378
|
+
if (threadSize) {
|
|
379
|
+
const n = threadSize.get(msg) || 1;
|
|
380
|
+
if (n > 1) {
|
|
381
|
+
const threadPill = document.createElement("span");
|
|
382
|
+
threadPill.className = "ml-thread-pill";
|
|
383
|
+
threadPill.textContent = String(n);
|
|
384
|
+
threadPill.title = `${n} messages in this thread`;
|
|
385
|
+
subject.prepend(threadPill);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
350
388
|
if (msg.preview) {
|
|
351
389
|
const preview = document.createElement("span");
|
|
352
390
|
preview.className = "ml-preview";
|
|
@@ -324,6 +324,29 @@ body {
|
|
|
324
324
|
}
|
|
325
325
|
.compose-att-chip button:hover { color: oklch(0.65 0.2 25); }
|
|
326
326
|
|
|
327
|
+
/* Cc/Bcc toggle buttons in the To row */
|
|
328
|
+
.compose-recipient-toggle {
|
|
329
|
+
display: inline-flex;
|
|
330
|
+
gap: 4px;
|
|
331
|
+
margin-left: var(--gap-xs);
|
|
332
|
+
}
|
|
333
|
+
.compose-toggle-btn {
|
|
334
|
+
background: transparent;
|
|
335
|
+
border: 1px solid var(--color-border);
|
|
336
|
+
border-radius: var(--radius-sm);
|
|
337
|
+
color: var(--color-text-muted);
|
|
338
|
+
padding: 1px 8px;
|
|
339
|
+
font-size: var(--font-size-sm);
|
|
340
|
+
cursor: pointer;
|
|
341
|
+
font-family: inherit;
|
|
342
|
+
}
|
|
343
|
+
.compose-toggle-btn:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
344
|
+
.compose-toggle-btn.active {
|
|
345
|
+
background: var(--color-accent);
|
|
346
|
+
color: #fff;
|
|
347
|
+
border-color: transparent;
|
|
348
|
+
}
|
|
349
|
+
|
|
327
350
|
/* Link editor modal (Ctrl+K / toolbar link button) */
|
|
328
351
|
.mailx-modal-backdrop {
|
|
329
352
|
position: fixed;
|
|
@@ -20,12 +20,16 @@
|
|
|
20
20
|
<div class="compose-field">
|
|
21
21
|
<label for="compose-to">To</label>
|
|
22
22
|
<input type="text" id="compose-to" autocomplete="off">
|
|
23
|
+
<span class="compose-recipient-toggle">
|
|
24
|
+
<button type="button" class="compose-toggle-btn" id="btn-toggle-cc" title="Show/hide Cc">Cc</button>
|
|
25
|
+
<button type="button" class="compose-toggle-btn" id="btn-toggle-bcc" title="Show/hide Bcc">Bcc</button>
|
|
26
|
+
</span>
|
|
23
27
|
</div>
|
|
24
|
-
<div class="compose-field">
|
|
28
|
+
<div class="compose-field" id="compose-cc-row" hidden>
|
|
25
29
|
<label for="compose-cc">Cc</label>
|
|
26
30
|
<input type="text" id="compose-cc" autocomplete="off">
|
|
27
31
|
</div>
|
|
28
|
-
<div class="compose-field">
|
|
32
|
+
<div class="compose-field" id="compose-bcc-row" hidden>
|
|
29
33
|
<label for="compose-bcc">Bcc</label>
|
|
30
34
|
<input type="text" id="compose-bcc" autocomplete="off">
|
|
31
35
|
</div>
|
|
@@ -275,6 +275,15 @@ function applyInit(init) {
|
|
|
275
275
|
toInput.value = formatAddrs(init.to);
|
|
276
276
|
ccInput.value = formatAddrs(init.cc);
|
|
277
277
|
subjectInput.value = init.subject;
|
|
278
|
+
// Auto-expand Cc row if the init already has Cc content (reply-all, draft-with-cc)
|
|
279
|
+
if (ccInput.value.trim()) {
|
|
280
|
+
const ccRowEl = document.getElementById("compose-cc-row");
|
|
281
|
+
const ccBtn = document.getElementById("btn-toggle-cc");
|
|
282
|
+
if (ccRowEl)
|
|
283
|
+
ccRowEl.hidden = false;
|
|
284
|
+
if (ccBtn)
|
|
285
|
+
ccBtn.classList.add("active");
|
|
286
|
+
}
|
|
278
287
|
if (init.bodyHtml) {
|
|
279
288
|
editor.setHtml(init.bodyHtml);
|
|
280
289
|
editor.setCursor(0);
|
|
@@ -497,6 +506,29 @@ async function handleCloseRequest() {
|
|
|
497
506
|
document.getElementById("btn-discard")?.addEventListener("click", () => {
|
|
498
507
|
handleCloseRequest();
|
|
499
508
|
});
|
|
509
|
+
// ── Cc / Bcc toggle ──
|
|
510
|
+
const ccRow = document.getElementById("compose-cc-row");
|
|
511
|
+
const bccRow = document.getElementById("compose-bcc-row");
|
|
512
|
+
const toggleCcBtn = document.getElementById("btn-toggle-cc");
|
|
513
|
+
const toggleBccBtn = document.getElementById("btn-toggle-bcc");
|
|
514
|
+
function setCcVisible(visible) {
|
|
515
|
+
ccRow.hidden = !visible;
|
|
516
|
+
toggleCcBtn.classList.toggle("active", visible);
|
|
517
|
+
if (visible)
|
|
518
|
+
ccInput.focus();
|
|
519
|
+
else
|
|
520
|
+
ccInput.value = "";
|
|
521
|
+
}
|
|
522
|
+
function setBccVisible(visible) {
|
|
523
|
+
bccRow.hidden = !visible;
|
|
524
|
+
toggleBccBtn.classList.toggle("active", visible);
|
|
525
|
+
if (visible)
|
|
526
|
+
bccInput.focus();
|
|
527
|
+
else
|
|
528
|
+
bccInput.value = "";
|
|
529
|
+
}
|
|
530
|
+
toggleCcBtn?.addEventListener("click", () => setCcVisible(ccRow.hidden));
|
|
531
|
+
toggleBccBtn?.addEventListener("click", () => setBccVisible(bccRow.hidden));
|
|
500
532
|
// ── Attachments ──
|
|
501
533
|
const fileInput = document.getElementById("compose-file");
|
|
502
534
|
const attEl = document.getElementById("compose-attachments");
|
package/client/index.html
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
<label class="tb-menu-item"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
26
26
|
<label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
27
27
|
<label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
28
|
+
<label class="tb-menu-item"><input type="checkbox" id="opt-threaded"> Group by thread</label>
|
|
28
29
|
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
29
30
|
<label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
30
31
|
</div>
|
|
@@ -399,6 +399,19 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
399
399
|
overflow: hidden;
|
|
400
400
|
text-overflow: ellipsis;
|
|
401
401
|
}
|
|
402
|
+
.ml-thread-pill {
|
|
403
|
+
display: inline-block;
|
|
404
|
+
padding: 0 6px;
|
|
405
|
+
font-size: 0.72rem;
|
|
406
|
+
font-weight: 600;
|
|
407
|
+
border-radius: 999px;
|
|
408
|
+
margin-right: 6px;
|
|
409
|
+
color: #fff;
|
|
410
|
+
background: var(--color-accent);
|
|
411
|
+
vertical-align: baseline;
|
|
412
|
+
min-width: 1.5em;
|
|
413
|
+
text-align: center;
|
|
414
|
+
}
|
|
402
415
|
.ml-subject {
|
|
403
416
|
overflow: hidden;
|
|
404
417
|
text-overflow: ellipsis;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.226",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.288",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
79
79
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
80
80
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
81
|
-
"@bobfrankston/msger": "^0.1.
|
|
81
|
+
"@bobfrankston/msger": "^0.1.288",
|
|
82
82
|
"@capacitor/android": "^8.3.0",
|
|
83
83
|
"@capacitor/cli": "^8.3.0",
|
|
84
84
|
"@capacitor/core": "^8.3.0",
|
|
@@ -530,7 +530,9 @@ export class ImapManager extends EventEmitter {
|
|
|
530
530
|
flags.push("\\Draft");
|
|
531
531
|
this.db.upsertMessage({
|
|
532
532
|
accountId, folderId, uid: msg.uid,
|
|
533
|
-
messageId: msg.messageId || "",
|
|
533
|
+
messageId: msg.messageId || "",
|
|
534
|
+
inReplyTo: msg.inReplyTo || "",
|
|
535
|
+
references: [],
|
|
534
536
|
date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
|
|
535
537
|
subject: msg.subject || "",
|
|
536
538
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
@@ -687,7 +689,7 @@ export class ImapManager extends EventEmitter {
|
|
|
687
689
|
folderId,
|
|
688
690
|
uid: msg.uid,
|
|
689
691
|
messageId: msg.messageId || "",
|
|
690
|
-
inReplyTo: "",
|
|
692
|
+
inReplyTo: msg.inReplyTo || "",
|
|
691
693
|
references: [],
|
|
692
694
|
date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
|
|
693
695
|
subject: msg.subject || "",
|
|
@@ -1021,7 +1023,9 @@ export class ImapManager extends EventEmitter {
|
|
|
1021
1023
|
flags.push("\\Draft");
|
|
1022
1024
|
this.db.upsertMessage({
|
|
1023
1025
|
accountId, folderId, uid: msg.uid,
|
|
1024
|
-
messageId: msg.messageId || "",
|
|
1026
|
+
messageId: msg.messageId || "",
|
|
1027
|
+
inReplyTo: msg.inReplyTo || "",
|
|
1028
|
+
references: msg.references || [],
|
|
1025
1029
|
date: msg.date instanceof Date ? msg.date.getTime() : Date.now(),
|
|
1026
1030
|
subject: msg.subject || "",
|
|
1027
1031
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
@@ -135,7 +135,7 @@ export class GmailApiProvider {
|
|
|
135
135
|
for (const id of chunk) {
|
|
136
136
|
const params = new URLSearchParams({ format });
|
|
137
137
|
if (format === "metadata") {
|
|
138
|
-
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
|
|
138
|
+
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date", "In-Reply-To", "References"]) {
|
|
139
139
|
params.append("metadataHeaders", h);
|
|
140
140
|
}
|
|
141
141
|
}
|
|
@@ -164,6 +164,11 @@ export class GmailApiProvider {
|
|
|
164
164
|
const dateRaw = getHeader(headers, "Date") || "";
|
|
165
165
|
const subject = getHeader(headers, "Subject") || msg.snippet || "";
|
|
166
166
|
const messageId = getHeader(headers, "Message-ID") || "";
|
|
167
|
+
const inReplyTo = getHeader(headers, "In-Reply-To") || "";
|
|
168
|
+
const referencesRaw = getHeader(headers, "References") || "";
|
|
169
|
+
const references = referencesRaw.trim()
|
|
170
|
+
? referencesRaw.split(/\s+/).filter(r => r.startsWith("<") && r.endsWith(">"))
|
|
171
|
+
: [];
|
|
167
172
|
return {
|
|
168
173
|
uid: idToUid(msg.id),
|
|
169
174
|
messageId,
|
|
@@ -173,6 +178,8 @@ export class GmailApiProvider {
|
|
|
173
178
|
from: parseAddressList(fromRaw),
|
|
174
179
|
to: parseAddressList(toRaw),
|
|
175
180
|
cc: parseAddressList(ccRaw),
|
|
181
|
+
inReplyTo,
|
|
182
|
+
references,
|
|
176
183
|
seen: !labels.includes("UNREAD"),
|
|
177
184
|
flagged: labels.includes("STARRED"),
|
|
178
185
|
answered: false, // Gmail API doesn't expose this directly
|
|
@@ -218,7 +225,7 @@ export class GmailApiProvider {
|
|
|
218
225
|
const format = options.source ? "raw" : "metadata";
|
|
219
226
|
const params = new URLSearchParams({ format });
|
|
220
227
|
if (format === "metadata") {
|
|
221
|
-
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
|
|
228
|
+
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date", "In-Reply-To", "References"]) {
|
|
222
229
|
params.append("metadataHeaders", h);
|
|
223
230
|
}
|
|
224
231
|
}
|
|
@@ -7,6 +7,17 @@ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery }
|
|
|
7
7
|
export declare class MailxDB {
|
|
8
8
|
private db;
|
|
9
9
|
constructor(dbDir: string);
|
|
10
|
+
/** Idempotently add a column to a table if it's missing. */
|
|
11
|
+
private addColumnIfMissing;
|
|
12
|
+
/** Compute a thread id for an incoming message. Strategy:
|
|
13
|
+
* 1. If any ancestor (in_reply_to or references) is already present in
|
|
14
|
+
* messages with a thread_id, reuse it — this handles the case where
|
|
15
|
+
* replies arrive before / after the root.
|
|
16
|
+
* 2. Otherwise use the oldest ref (first entry in References), or
|
|
17
|
+
* in_reply_to, or the message's own messageId as the thread root. */
|
|
18
|
+
private computeThreadId;
|
|
19
|
+
/** Get all messages in a thread (across folders) for a given account. */
|
|
20
|
+
getThreadMessages(accountId: string, threadId: string): MessageEnvelope[];
|
|
10
21
|
close(): void;
|
|
11
22
|
upsertAccount(id: string, name: string, email: string, configJson: string): void;
|
|
12
23
|
getAccounts(): {
|
|
@@ -37,6 +37,7 @@ const SCHEMA = `
|
|
|
37
37
|
message_id TEXT,
|
|
38
38
|
in_reply_to TEXT,
|
|
39
39
|
refs TEXT,
|
|
40
|
+
thread_id TEXT,
|
|
40
41
|
date INTEGER NOT NULL,
|
|
41
42
|
subject TEXT DEFAULT '',
|
|
42
43
|
from_address TEXT DEFAULT '',
|
|
@@ -58,6 +59,9 @@ const SCHEMA = `
|
|
|
58
59
|
CREATE INDEX IF NOT EXISTS idx_messages_message_id
|
|
59
60
|
ON messages(message_id);
|
|
60
61
|
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread_id
|
|
63
|
+
ON messages(account_id, thread_id);
|
|
64
|
+
|
|
61
65
|
CREATE TABLE IF NOT EXISTS queue (
|
|
62
66
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
67
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
@@ -122,6 +126,73 @@ export class MailxDB {
|
|
|
122
126
|
this.db.exec("PRAGMA journal_mode = WAL");
|
|
123
127
|
this.db.exec("PRAGMA foreign_keys = ON");
|
|
124
128
|
this.db.exec(SCHEMA);
|
|
129
|
+
// Idempotent migrations for older databases that predate new columns.
|
|
130
|
+
// SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so we probe the
|
|
131
|
+
// table schema and add missing columns one by one.
|
|
132
|
+
this.addColumnIfMissing("messages", "thread_id", "TEXT");
|
|
133
|
+
try {
|
|
134
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
|
|
135
|
+
}
|
|
136
|
+
catch { /* already exists */ }
|
|
137
|
+
}
|
|
138
|
+
/** Idempotently add a column to a table if it's missing. */
|
|
139
|
+
addColumnIfMissing(table, column, sqlType) {
|
|
140
|
+
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
141
|
+
if (cols.some(c => c.name === column))
|
|
142
|
+
return;
|
|
143
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${sqlType}`);
|
|
144
|
+
}
|
|
145
|
+
/** Compute a thread id for an incoming message. Strategy:
|
|
146
|
+
* 1. If any ancestor (in_reply_to or references) is already present in
|
|
147
|
+
* messages with a thread_id, reuse it — this handles the case where
|
|
148
|
+
* replies arrive before / after the root.
|
|
149
|
+
* 2. Otherwise use the oldest ref (first entry in References), or
|
|
150
|
+
* in_reply_to, or the message's own messageId as the thread root. */
|
|
151
|
+
computeThreadId(accountId, messageId, inReplyTo, references) {
|
|
152
|
+
const candidates = [];
|
|
153
|
+
if (references && references.length)
|
|
154
|
+
candidates.push(...references);
|
|
155
|
+
if (inReplyTo && !candidates.includes(inReplyTo))
|
|
156
|
+
candidates.push(inReplyTo);
|
|
157
|
+
if (messageId && !candidates.includes(messageId))
|
|
158
|
+
candidates.push(messageId);
|
|
159
|
+
// Look for an existing thread anchored on any of the ancestors
|
|
160
|
+
for (const mid of candidates) {
|
|
161
|
+
if (!mid)
|
|
162
|
+
continue;
|
|
163
|
+
const row = this.db.prepare("SELECT thread_id FROM messages WHERE account_id = ? AND message_id = ? AND thread_id IS NOT NULL LIMIT 1").get(accountId, mid);
|
|
164
|
+
if (row?.thread_id)
|
|
165
|
+
return row.thread_id;
|
|
166
|
+
}
|
|
167
|
+
// No existing thread — seed from the oldest ref, falling back to
|
|
168
|
+
// in_reply_to, then messageId
|
|
169
|
+
return (references && references[0]) || inReplyTo || messageId || `orphan-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
170
|
+
}
|
|
171
|
+
/** Get all messages in a thread (across folders) for a given account. */
|
|
172
|
+
getThreadMessages(accountId, threadId) {
|
|
173
|
+
if (!threadId)
|
|
174
|
+
return [];
|
|
175
|
+
const rows = this.db.prepare(`SELECT * FROM messages WHERE account_id = ? AND thread_id = ? ORDER BY date ASC`).all(accountId, threadId);
|
|
176
|
+
return rows.map(r => ({
|
|
177
|
+
id: r.id,
|
|
178
|
+
accountId: r.account_id,
|
|
179
|
+
folderId: r.folder_id,
|
|
180
|
+
uid: r.uid,
|
|
181
|
+
messageId: r.message_id || "",
|
|
182
|
+
inReplyTo: r.in_reply_to || "",
|
|
183
|
+
references: JSON.parse(r.refs || "[]"),
|
|
184
|
+
threadId: r.thread_id || undefined,
|
|
185
|
+
date: r.date,
|
|
186
|
+
subject: r.subject,
|
|
187
|
+
from: { name: r.from_name, address: r.from_address },
|
|
188
|
+
to: JSON.parse(r.to_json),
|
|
189
|
+
cc: JSON.parse(r.cc_json),
|
|
190
|
+
flags: JSON.parse(r.flags_json),
|
|
191
|
+
size: r.size,
|
|
192
|
+
hasAttachments: !!r.has_attachments,
|
|
193
|
+
preview: r.preview,
|
|
194
|
+
bodyPath: r.body_path || undefined,
|
|
195
|
+
}));
|
|
125
196
|
}
|
|
126
197
|
close() {
|
|
127
198
|
this.db.close();
|
|
@@ -201,13 +272,18 @@ export class MailxDB {
|
|
|
201
272
|
}
|
|
202
273
|
const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");
|
|
203
274
|
const ccText = msg.cc.map(a => `${a.name} ${a.address}`).join(" ");
|
|
275
|
+
// Thread id = oldest ancestor in the reference chain, or the in-reply-to
|
|
276
|
+
// parent, or the message's own Message-ID as a fallback. We also check
|
|
277
|
+
// whether an existing row already has a thread_id for any of the refs,
|
|
278
|
+
// so late-arriving replies latch onto the same thread.
|
|
279
|
+
const threadId = this.computeThreadId(msg.accountId, msg.messageId, msg.inReplyTo, msg.references);
|
|
204
280
|
const result = this.db.prepare(`
|
|
205
281
|
INSERT INTO messages (
|
|
206
|
-
account_id, folder_id, uid, message_id, in_reply_to, refs,
|
|
282
|
+
account_id, folder_id, uid, message_id, in_reply_to, refs, thread_id,
|
|
207
283
|
date, subject, from_address, from_name, to_json, cc_json,
|
|
208
284
|
flags_json, size, has_attachments, preview, body_path, cached_at
|
|
209
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
210
|
-
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now());
|
|
285
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
286
|
+
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now());
|
|
211
287
|
const rowId = Number(result.lastInsertRowid);
|
|
212
288
|
// Index for full-text search
|
|
213
289
|
try {
|
|
@@ -240,6 +316,7 @@ export class MailxDB {
|
|
|
240
316
|
messageId: r.message_id || "",
|
|
241
317
|
inReplyTo: r.in_reply_to || "",
|
|
242
318
|
references: JSON.parse(r.refs || "[]"),
|
|
319
|
+
threadId: r.thread_id || undefined,
|
|
243
320
|
date: r.date,
|
|
244
321
|
subject: r.subject,
|
|
245
322
|
from: { name: r.from_name, address: r.from_address },
|
|
@@ -272,6 +349,7 @@ export class MailxDB {
|
|
|
272
349
|
messageId: r.message_id || "",
|
|
273
350
|
inReplyTo: r.in_reply_to || "",
|
|
274
351
|
references: JSON.parse(r.refs || "[]"),
|
|
352
|
+
threadId: r.thread_id || undefined,
|
|
275
353
|
date: r.date,
|
|
276
354
|
subject: r.subject,
|
|
277
355
|
from: { name: r.from_name, address: r.from_address },
|
|
@@ -301,6 +379,7 @@ export class MailxDB {
|
|
|
301
379
|
messageId: r.message_id || "",
|
|
302
380
|
inReplyTo: r.in_reply_to || "",
|
|
303
381
|
references: JSON.parse(r.refs || "[]"),
|
|
382
|
+
threadId: r.thread_id || undefined,
|
|
304
383
|
date: r.date,
|
|
305
384
|
subject: r.subject,
|
|
306
385
|
from: { name: r.from_name, address: r.from_address },
|
|
@@ -64,6 +64,7 @@ export interface MessageEnvelope {
|
|
|
64
64
|
messageId: string; /** RFC Message-ID header */
|
|
65
65
|
inReplyTo: string; /** For threading */
|
|
66
66
|
references: string[]; /** For threading */
|
|
67
|
+
threadId?: string; /** Computed thread id (root Message-ID of the conversation) */
|
|
67
68
|
date: number; /** Epoch ms */
|
|
68
69
|
subject: string;
|
|
69
70
|
from: EmailAddress;
|