@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 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
- - Drafts auto-save every 5 seconds to your Drafts folder
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
- for (const msg of items) {
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.224",
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.286",
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.286",
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",
@@ -56,6 +56,7 @@ export declare function getMessage(params: {
56
56
  messageId: string;
57
57
  inReplyTo: string;
58
58
  references: string[];
59
+ threadId?: string;
59
60
  date: number;
60
61
  subject: string;
61
62
  from: import("@bobfrankston/mailx-types").EmailAddress;
@@ -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 || "", inReplyTo: "", references: [],
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 || "", inReplyTo: "", references: [],
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
  }
@@ -27,6 +27,8 @@ export interface ProviderMessage {
27
27
  name?: string;
28
28
  address?: string;
29
29
  }[];
30
+ inReplyTo?: string;
31
+ references?: string[];
30
32
  seen: boolean;
31
33
  flagged: boolean;
32
34
  answered: boolean;
@@ -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;