@bobfrankston/mailx 1.0.222 → 1.0.224

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/app.js CHANGED
@@ -483,6 +483,7 @@ function forwardBody(msg) {
483
483
  return `<br><div class="reply"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;
484
484
  }
485
485
  let lastDeleted = null;
486
+ let lastMoved = null;
486
487
  let undoTimeout = null;
487
488
  async function deleteSelectedMessages() {
488
489
  const selected = getSelectedMessages();
@@ -548,6 +549,49 @@ async function undoDelete() {
548
549
  console.error(`Undo failed: ${e.message}`);
549
550
  }
550
551
  }
552
+ async function undoMove() {
553
+ if (!lastMoved)
554
+ return;
555
+ const { messages } = lastMoved;
556
+ const statusSync = document.getElementById("status-sync");
557
+ try {
558
+ // Group by (sourceAccountId, sourceFolderId) and move each group back
559
+ const byDest = new Map();
560
+ for (const m of messages) {
561
+ const key = `${m.accountId}:${m.sourceFolderId}`;
562
+ if (!byDest.has(key))
563
+ byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });
564
+ byDest.get(key).uids.push(m.uid);
565
+ }
566
+ const { moveMessages, moveMessage } = await import("./lib/api-client.js");
567
+ for (const group of byDest.values()) {
568
+ if (group.uids.length === 1)
569
+ await moveMessage(group.accountId, group.uids[0], group.folderId);
570
+ else
571
+ await moveMessages(group.accountId, group.uids, group.folderId);
572
+ }
573
+ if (statusSync)
574
+ statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? "s" : ""}`;
575
+ lastMoved = null;
576
+ if (undoTimeout)
577
+ clearTimeout(undoTimeout);
578
+ reloadCurrentFolder();
579
+ }
580
+ catch (e) {
581
+ console.error(`Undo move failed: ${e.message}`);
582
+ if (statusSync)
583
+ statusSync.textContent = `Undo move failed: ${e.message}`;
584
+ }
585
+ }
586
+ // Listen for the "mailx-moved" custom event emitted by folder-tree's drop
587
+ // handler so Ctrl+Z can reverse the most recent move.
588
+ document.addEventListener("mailx-moved", (e) => {
589
+ lastMoved = e.detail;
590
+ lastDeleted = null; // Ctrl+Z undoes whichever came last
591
+ if (undoTimeout)
592
+ clearTimeout(undoTimeout);
593
+ undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
594
+ });
551
595
  document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
552
596
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
553
597
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
@@ -909,9 +953,13 @@ document.addEventListener("keydown", (e) => {
909
953
  e.preventDefault();
910
954
  deleteSelectedMessages();
911
955
  }
912
- // Ctrl+Z = Undo delete
956
+ // Ctrl+Z = Undo the most recent delete or move
913
957
  if (e.ctrlKey && e.key === "z") {
914
- if (lastDeleted) {
958
+ if (lastMoved) {
959
+ e.preventDefault();
960
+ undoMove();
961
+ }
962
+ else if (lastDeleted) {
915
963
  e.preventDefault();
916
964
  undoDelete();
917
965
  }
@@ -325,13 +325,22 @@ function renderNode(node, container, depth) {
325
325
  }
326
326
  const moved = toMove.length;
327
327
  if (statusEl)
328
- statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name}`;
328
+ statusEl.textContent = `Moved ${moved} message${moved > 1 ? "s" : ""} to ${node.name} — Ctrl+Z to undo`;
329
329
  // Remove from shared state — list and viewer update automatically
330
330
  const { removeMessages } = await import("../lib/message-state.js");
331
331
  removeMessages(toMove);
332
332
  const treeContainer = document.getElementById("folder-tree");
333
333
  if (treeContainer)
334
334
  loadFolderTree(treeContainer);
335
+ // Notify app.ts so Ctrl+Z can undo this move. Each entry carries
336
+ // its ORIGINAL folderId/accountId so we know where to move back to.
337
+ document.dispatchEvent(new CustomEvent("mailx-moved", {
338
+ detail: {
339
+ messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),
340
+ targetAccountId: node.accountId,
341
+ targetFolderId: node.id,
342
+ },
343
+ }));
335
344
  }
336
345
  catch (err) {
337
346
  console.error(`Move failed: ${err.message}`);
@@ -2,7 +2,7 @@
2
2
  * Message viewer component -- displays full message in sandboxed iframe.
3
3
  * Subscribes to message-state: clears when selected becomes null.
4
4
  */
5
- import { getMessage, updateFlags, allowRemoteContent, getAttachment } from "../lib/api-client.js";
5
+ import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  import * as state from "../lib/message-state.js";
8
8
  /** Currently displayed message (for reply/forward) */
@@ -173,20 +173,37 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
173
173
  toEl.textContent += ` Cc: ${msg.cc.map(formatAddr).join(", ")}`;
174
174
  }
175
175
  headerEl.querySelector(".mv-subject").textContent = msg.subject;
176
- // Right-click on email addresses in header
177
- const allAddresses = [msg.from, ...(msg.to || []), ...(msg.cc || [])].filter((a) => a?.address);
176
+ // Right-click on email addresses in header: copy name, copy address,
177
+ // copy both, add to contacts, plus reply actions for the whole message.
178
178
  for (const el of [fromEl, toEl]) {
179
179
  el.addEventListener("contextmenu", (e) => {
180
180
  e.preventDefault();
181
181
  const me = e;
182
182
  const items = [];
183
- for (const addr of (el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])])) {
183
+ const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];
184
+ for (const addr of addrs) {
184
185
  if (!addr?.address)
185
186
  continue;
186
- const display = addr.name ? `${addr.name} <${addr.address}>` : addr.address;
187
- items.push({ label: `Copy: ${display}`, action: () => navigator.clipboard.writeText(addr.address) });
187
+ const name = addr.name || "";
188
+ const both = name ? `${name} <${addr.address}>` : addr.address;
189
+ if (name) {
190
+ items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });
191
+ }
192
+ items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });
193
+ if (name) {
194
+ items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
195
+ }
196
+ items.push({
197
+ label: `Add to contacts: ${addr.address}`,
198
+ action: async () => {
199
+ try {
200
+ await addContact(name, addr.address);
201
+ }
202
+ catch { /* ignore */ }
203
+ },
204
+ });
205
+ items.push({ label: "", action: () => { }, separator: true });
188
206
  }
189
- items.push({ label: "", action: () => { }, separator: true });
190
207
  items.push({ label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) });
191
208
  items.push({ label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) });
192
209
  items.push({ label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) });
@@ -294,6 +294,36 @@ body {
294
294
  }
295
295
  .ql-editor, .tt-content .tiptap { position: relative; }
296
296
 
297
+ /* Attachment chips above the editor */
298
+ .compose-attachments {
299
+ padding: var(--gap-xs) var(--gap-md);
300
+ display: flex;
301
+ flex-wrap: wrap;
302
+ gap: var(--gap-xs);
303
+ background: var(--color-bg-surface);
304
+ border-bottom: 1px solid var(--color-border);
305
+ }
306
+ .compose-att-chip {
307
+ display: inline-flex;
308
+ align-items: center;
309
+ gap: 6px;
310
+ padding: 3px 8px;
311
+ border-radius: 999px;
312
+ background: color-mix(in oklch, var(--color-accent) 15%, transparent);
313
+ color: var(--color-text);
314
+ font-size: var(--font-size-sm);
315
+ }
316
+ .compose-att-chip button {
317
+ background: none;
318
+ border: none;
319
+ color: var(--color-text-muted);
320
+ cursor: pointer;
321
+ padding: 0;
322
+ font-size: 1em;
323
+ line-height: 1;
324
+ }
325
+ .compose-att-chip button:hover { color: oklch(0.65 0.2 25); }
326
+
297
327
  /* Link editor modal (Ctrl+K / toolbar link button) */
298
328
  .mailx-modal-backdrop {
299
329
  position: fixed;
@@ -37,9 +37,11 @@
37
37
  <div class="compose-toolbar">
38
38
  <button class="tb-btn" id="btn-send">Send</button>
39
39
  <button class="tb-btn" id="btn-attach">Attach</button>
40
+ <input type="file" id="compose-file" multiple hidden>
40
41
  <button class="tb-btn" id="btn-discard">Discard</button>
41
42
  <span id="compose-status" class="compose-status"></span>
42
43
  </div>
44
+ <div id="compose-attachments" class="compose-attachments" hidden></div>
43
45
  <div id="compose-editor"></div>
44
46
  </body>
45
47
  </html>
@@ -302,6 +302,7 @@ let draftDebounceTimer = null;
302
302
  let lastDraftContent = "";
303
303
  let draftSaving = false; // prevent concurrent saves
304
304
  let draftSaveFailed = false; // surfaced in the compose status tag
305
+ const attachments = [];
305
306
  function showDraftStatus(text, isError) {
306
307
  const status = document.getElementById("compose-status");
307
308
  if (!status)
@@ -418,6 +419,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
418
419
  subject: subjectInput.value,
419
420
  bodyHtml: editor.getHtml(),
420
421
  bodyText: editor.getText(),
422
+ attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
421
423
  };
422
424
  try {
423
425
  await sendMessage(body);
@@ -495,6 +497,68 @@ async function handleCloseRequest() {
495
497
  document.getElementById("btn-discard")?.addEventListener("click", () => {
496
498
  handleCloseRequest();
497
499
  });
500
+ // ── Attachments ──
501
+ const fileInput = document.getElementById("compose-file");
502
+ const attEl = document.getElementById("compose-attachments");
503
+ function renderAttachmentChips() {
504
+ attEl.innerHTML = "";
505
+ if (attachments.length === 0) {
506
+ attEl.hidden = true;
507
+ return;
508
+ }
509
+ attEl.hidden = false;
510
+ for (let i = 0; i < attachments.length; i++) {
511
+ const a = attachments[i];
512
+ const chip = document.createElement("span");
513
+ chip.className = "compose-att-chip";
514
+ chip.innerHTML = `\uD83D\uDCCE ${escapeHtml(a.filename)} (${formatSize(a.size)}) `;
515
+ const rm = document.createElement("button");
516
+ rm.type = "button";
517
+ rm.title = "Remove attachment";
518
+ rm.textContent = "\u2715";
519
+ rm.addEventListener("click", () => {
520
+ attachments.splice(i, 1);
521
+ renderAttachmentChips();
522
+ });
523
+ chip.appendChild(rm);
524
+ attEl.appendChild(chip);
525
+ }
526
+ }
527
+ function escapeHtml(s) {
528
+ return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
529
+ }
530
+ function formatSize(n) {
531
+ if (n < 1024)
532
+ return `${n} B`;
533
+ if (n < 1024 * 1024)
534
+ return `${(n / 1024).toFixed(1)} KB`;
535
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
536
+ }
537
+ document.getElementById("btn-attach")?.addEventListener("click", () => {
538
+ fileInput?.click();
539
+ });
540
+ fileInput?.addEventListener("change", async () => {
541
+ if (!fileInput.files)
542
+ return;
543
+ for (const file of Array.from(fileInput.files)) {
544
+ const buf = await file.arrayBuffer();
545
+ // base64 the whole thing — mailx-service builds the multipart/mixed
546
+ let binary = "";
547
+ const bytes = new Uint8Array(buf);
548
+ for (let i = 0; i < bytes.length; i++)
549
+ binary += String.fromCharCode(bytes[i]);
550
+ const dataBase64 = btoa(binary);
551
+ attachments.push({
552
+ filename: file.name,
553
+ mimeType: file.type || "application/octet-stream",
554
+ size: file.size,
555
+ dataBase64,
556
+ });
557
+ }
558
+ fileInput.value = "";
559
+ renderAttachmentChips();
560
+ scheduleDraftSave();
561
+ });
498
562
  // ── Save and close (X button from parent) ──
499
563
  window.addEventListener("compose-save-and-close", () => {
500
564
  handleCloseRequest();
@@ -146,6 +146,9 @@ export function repairAccounts() {
146
146
  export function deleteDraft(accountId, draftUid, draftId) {
147
147
  return ipc().deleteDraft?.(accountId, draftUid, draftId);
148
148
  }
149
+ export function addContact(name, email) {
150
+ return ipc().addContact?.(name, email);
151
+ }
149
152
  export function setupAccount(name, email, password) {
150
153
  return ipc().setupAccount?.(name, email, password);
151
154
  }
@@ -97,6 +97,9 @@
97
97
  searchMessages: function(query, page, pageSize) {
98
98
  return callNode("searchMessages", { query: query, page: page, pageSize: pageSize });
99
99
  },
100
+ addContact: function(name, email) {
101
+ return callNode("addContact", { name: name, email: email });
102
+ },
100
103
  searchContacts: function(query) {
101
104
  return callNode("searchContacts", { query: query });
102
105
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.222",
3
+ "version": "1.0.224",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,11 +20,11 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.9",
23
+ "@bobfrankston/iflow-direct": "^0.1.10",
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.284",
27
+ "@bobfrankston/msger": "^0.1.286",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -74,11 +74,11 @@
74
74
  },
75
75
  ".transformedSnapshot": {
76
76
  "dependencies": {
77
- "@bobfrankston/iflow-direct": "^0.1.9",
77
+ "@bobfrankston/iflow-direct": "^0.1.10",
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.284",
81
+ "@bobfrankston/msger": "^0.1.286",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -188,9 +188,12 @@ export declare class ImapManager extends EventEmitter {
188
188
  private saveSendingCopy;
189
189
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
190
190
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
191
- /** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
192
- * Do NOT scan sending/<acct>/queued/ that was causing every sent message to be
193
- * re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
191
+ /** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
192
+ * and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
193
+ * double-send bug was caused by queueOutgoing() WRITING a debug copy to
194
+ * sending/queued/ on every send — that write is gone now, so scanning the
195
+ * directory is safe again. Any legitimate files that land there (crash
196
+ * recovery, manual drop) will get sent. */
194
197
  private processLocalQueue;
195
198
  /** Send a raw RFC 2822 message via SMTP for a given account */
196
199
  private sendRawViaSMTP;
@@ -371,24 +371,35 @@ export class ImapManager extends EventEmitter {
371
371
  }
372
372
  const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
373
373
  tokenProvider = async () => {
374
- const result = await authenticateOAuth(credPath, {
374
+ // Wrap authenticateOAuth with a 30s wall-clock timeout. Without this,
375
+ // a hung OAuth server could block the entire sync thread indefinitely.
376
+ const TOKEN_FETCH_TIMEOUT_MS = 30000;
377
+ const authPromise = authenticateOAuth(credPath, {
375
378
  scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/calendar",
376
379
  tokenDirectory: tokenDir,
377
380
  credentialsKey: "installed",
378
381
  loginHint: account.imap.user,
379
382
  });
383
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`OAuth token fetch timeout (${TOKEN_FETCH_TIMEOUT_MS / 1000}s)`)), TOKEN_FETCH_TIMEOUT_MS));
384
+ const result = await Promise.race([authPromise, timeoutPromise]);
380
385
  return result?.access_token || "";
381
386
  };
382
387
  }
388
+ // Non-Gmail accounts (typically Dovecot / generic IMAP) get a smaller
389
+ // fetch chunk size (10 vs 25) and longer inactivity timeout (300s) so
390
+ // multi-body FETCH batches don't trip the connection-dead detector on
391
+ // slow servers. Gmail stays at the defaults since it's fast and has
392
+ // its own rate limits to respect.
393
+ const isGmail = account.imap.host?.includes("gmail") || account.email.endsWith("@gmail.com");
383
394
  const config = createAutoImapConfig({
384
395
  server: account.imap.host,
385
396
  port: account.imap.port,
386
397
  username: account.imap.user,
387
398
  password: account.imap.password,
388
399
  tokenProvider,
389
- // Slow Dovecot servers (e.g. iecc.com) can stall >60s during multi-body FETCH.
390
- // Raise the inactivity timeout so the connection isn't dropped mid-stream.
391
- inactivityTimeout: 180000,
400
+ inactivityTimeout: isGmail ? 60000 : 300000,
401
+ fetchChunkSize: isGmail ? 25 : 10,
402
+ fetchChunkSizeMax: isGmail ? 500 : 100,
392
403
  });
393
404
  this.configs.set(account.id, config);
394
405
  // Register account in DB
@@ -1825,15 +1836,21 @@ export class ImapManager extends EventEmitter {
1825
1836
  fs.writeFileSync(path.join(localQueue, filename), rawMessage);
1826
1837
  console.log(` [outbox] Saved locally: ${filename}`);
1827
1838
  }
1828
- /** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
1829
- * Do NOT scan sending/<acct>/queued/ that was causing every sent message to be
1830
- * re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
1839
+ /** Process local file queue — scan both outbox/ (IMAP-unreachable fallback)
1840
+ * and sending/<acct>/queued/ (manual drop-in, crash recovery). The earlier
1841
+ * double-send bug was caused by queueOutgoing() WRITING a debug copy to
1842
+ * sending/queued/ on every send — that write is gone now, so scanning the
1843
+ * directory is safe again. Any legitimate files that land there (crash
1844
+ * recovery, manual drop) will get sent. */
1831
1845
  async processLocalQueue(accountId) {
1832
1846
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1847
+ const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
1833
1848
  const filesToSend = [];
1834
- if (fs.existsSync(outboxDir)) {
1835
- for (const file of fs.readdirSync(outboxDir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1836
- filesToSend.push({ dir: outboxDir, file });
1849
+ for (const dir of [outboxDir, queuedDir]) {
1850
+ if (!fs.existsSync(dir))
1851
+ continue;
1852
+ for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1853
+ filesToSend.push({ dir, file });
1837
1854
  }
1838
1855
  }
1839
1856
  if (filesToSend.length === 0)
@@ -55,6 +55,10 @@ export declare class MailxService {
55
55
  searchContacts(query: string): any[];
56
56
  syncGoogleContacts(): Promise<void>;
57
57
  seedContacts(): number;
58
+ /** Explicit add to address book — used by the right-click "Add to contacts"
59
+ * action on From/To/Cc addresses in the message viewer. Just calls the same
60
+ * validated upsert path as recordSentAddress. */
61
+ addContact(name: string, email: string): boolean;
58
62
  getSettings(): any;
59
63
  saveSettings(settings: any): void;
60
64
  getStorageInfo(): {
@@ -378,16 +378,51 @@ export class MailxService {
378
378
  // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
379
379
  const domain = account.email.split("@")[1] || "mailx.local";
380
380
  const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
381
- const headers = [
381
+ const hasAttachments = Array.isArray(msg.attachments) && msg.attachments.length > 0;
382
+ const commonHeaders = [
382
383
  `From: ${fromHeader}`, `To: ${to}`,
383
384
  cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
384
385
  `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
385
386
  `Message-ID: ${messageId}`,
386
387
  msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
387
388
  msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
388
- `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
389
- ].filter(h => h !== null).join("\r\n");
390
- const rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
389
+ `MIME-Version: 1.0`,
390
+ ].filter(h => h !== null);
391
+ let rawMessage;
392
+ if (hasAttachments) {
393
+ // multipart/mixed with the body + one base64 attachment part per file.
394
+ // Each attachment chunk is wrapped at 76-char lines per RFC 2045.
395
+ const boundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
396
+ const wrap76 = (s) => s.replace(/.{1,76}/g, m => m).match(/.{1,76}/g)?.join("\r\n") || s;
397
+ const parts = [];
398
+ parts.push(`--${boundary}\r\n` +
399
+ `Content-Type: text/html; charset=UTF-8\r\n` +
400
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
401
+ `${bodyEncoded}\r\n`);
402
+ for (const att of msg.attachments) {
403
+ const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
404
+ const mime = att.mimeType || "application/octet-stream";
405
+ const wrapped = wrap76(att.dataBase64 || "");
406
+ parts.push(`--${boundary}\r\n` +
407
+ `Content-Type: ${mime}; name="${filename}"\r\n` +
408
+ `Content-Disposition: attachment; filename="${filename}"\r\n` +
409
+ `Content-Transfer-Encoding: base64\r\n\r\n` +
410
+ `${wrapped}\r\n`);
411
+ }
412
+ const headers = [
413
+ ...commonHeaders,
414
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
415
+ ].join("\r\n");
416
+ rawMessage = `${headers}\r\n\r\n${parts.join("")}--${boundary}--\r\n`;
417
+ }
418
+ else {
419
+ const headers = [
420
+ ...commonHeaders,
421
+ `Content-Type: text/html; charset=UTF-8`,
422
+ `Content-Transfer-Encoding: quoted-printable`,
423
+ ].join("\r\n");
424
+ rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
425
+ }
391
426
  this.imapManager.queueOutgoingLocal(account.id, rawMessage);
392
427
  console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
393
428
  for (const addr of msg.to)
@@ -615,6 +650,15 @@ export class MailxService {
615
650
  console.log(` Seeded ${added} contacts from message history`);
616
651
  return added;
617
652
  }
653
+ /** Explicit add to address book — used by the right-click "Add to contacts"
654
+ * action on From/To/Cc addresses in the message viewer. Just calls the same
655
+ * validated upsert path as recordSentAddress. */
656
+ addContact(name, email) {
657
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
658
+ return false;
659
+ this.db.recordSentAddress(name || "", email);
660
+ return true;
661
+ }
618
662
  // ── Settings ──
619
663
  getSettings() {
620
664
  return loadSettings();
@@ -94,6 +94,8 @@ async function dispatchAction(svc, action, p) {
94
94
  return svc.search(p.query, p.page, p.pageSize, p.scope, p.accountId, p.folderId);
95
95
  case "searchContacts":
96
96
  return svc.searchContacts(p.query);
97
+ case "addContact":
98
+ return { ok: svc.addContact(p.name, p.email) };
97
99
  // Settings
98
100
  case "getSettings":
99
101
  return svc.getSettings();