@bobfrankston/mailx 1.0.340 → 1.0.349

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.
@@ -413,6 +413,83 @@ export class MailxService {
413
413
  getOutboxStatus() {
414
414
  return this.imapManager.getOutboxStatus();
415
415
  }
416
+ /** List queued outgoing messages with parsed envelope headers so the UI
417
+ * can render a pink-row "pending" view before IMAP APPEND succeeds. */
418
+ listQueuedOutgoing() {
419
+ const configDir = getConfigDir();
420
+ const outboxRoot = path.join(configDir, "outbox");
421
+ const sendingRoot = path.join(configDir, "sending");
422
+ const out = [];
423
+ const parseEnv = (raw, file, dir, accountId) => {
424
+ const headerEnd = raw.search(/\r?\n\r?\n/);
425
+ const headers = headerEnd >= 0 ? raw.slice(0, headerEnd) : raw;
426
+ const get = (name) => {
427
+ const re = new RegExp(`^${name}:\\s*(.+(?:\\r?\\n\\s+.+)*)`, "mi");
428
+ const m = headers.match(re);
429
+ return m ? m[1].replace(/\r?\n\s+/g, " ").trim() : "";
430
+ };
431
+ const retries = (headers.match(/^X-Mailx-Retry:/gmi) || []).length;
432
+ const st = (() => { try {
433
+ return fs.statSync(path.join(dir, file));
434
+ }
435
+ catch {
436
+ return null;
437
+ } })();
438
+ return {
439
+ accountId,
440
+ file,
441
+ path: path.join(dir, file),
442
+ dir,
443
+ from: get("From"),
444
+ to: get("To"),
445
+ cc: get("Cc"),
446
+ bcc: get("Bcc"),
447
+ subject: get("Subject"),
448
+ date: get("Date"),
449
+ messageId: get("Message-ID"),
450
+ attempts: retries,
451
+ sizeBytes: st?.size || 0,
452
+ createdAt: st?.mtimeMs || 0,
453
+ claimed: /\.sending-[^-]+-\d+$/.test(file),
454
+ };
455
+ };
456
+ const scanDir = (accountId, dir) => {
457
+ if (!fs.existsSync(dir))
458
+ return;
459
+ for (const f of fs.readdirSync(dir)) {
460
+ if (!f.endsWith(".ltr") && !f.endsWith(".eml") && !/\.sending-/.test(f))
461
+ continue;
462
+ try {
463
+ const raw = fs.readFileSync(path.join(dir, f), "utf-8");
464
+ out.push(parseEnv(raw, f, dir, accountId));
465
+ }
466
+ catch { /* unreadable — skip */ }
467
+ }
468
+ };
469
+ try {
470
+ if (fs.existsSync(outboxRoot)) {
471
+ for (const acct of fs.readdirSync(outboxRoot))
472
+ scanDir(acct, path.join(outboxRoot, acct));
473
+ }
474
+ if (fs.existsSync(sendingRoot)) {
475
+ for (const acct of fs.readdirSync(sendingRoot))
476
+ scanDir(acct, path.join(sendingRoot, acct, "queued"));
477
+ }
478
+ }
479
+ catch { /* */ }
480
+ out.sort((a, b) => b.createdAt - a.createdAt);
481
+ return out;
482
+ }
483
+ /** Manually drop a queued message (not yet sent). Removes the .ltr file. */
484
+ cancelQueuedOutgoing(filePath) {
485
+ // Safety: refuse anything outside the ~/.mailx tree.
486
+ const dir = getConfigDir();
487
+ if (!filePath.startsWith(dir))
488
+ throw new Error("path outside mailx data dir");
489
+ if (fs.existsSync(filePath))
490
+ fs.unlinkSync(filePath);
491
+ return { ok: true };
492
+ }
416
493
  async syncAll() {
417
494
  await this.imapManager.syncAll();
418
495
  }
@@ -617,22 +694,19 @@ export class MailxService {
617
694
  /** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
618
695
  * Throws if the account has no spam folder configured or the folder doesn't exist locally. */
619
696
  async markAsSpamMessages(accountId, uids) {
620
- // Cached accounts same reason as send/saveDraft: a stalled GDrive
621
- // mount could turn `Mark as spam` into a 120s IPC timeout.
622
- let account = this.getCachedAccounts().find(a => a.id === accountId);
623
- if (!account) {
624
- this._accountsCache = null;
625
- account = this.getCachedAccounts().find(a => a.id === accountId);
626
- }
627
- if (!account)
628
- throw new Error(`Account ${accountId} not found`);
629
- const spamPath = account.spam;
630
- if (!spamPath)
631
- throw new Error(`Account ${accountId} has no "spam" folder configured`);
697
+ // The spam folder is whatever the provider's getSpecialFolders() said
698
+ // it is. iflow-direct's compat client fills this in from RFC 6154
699
+ // \Junk / \Spam flags (with sensible defaults if the server doesn't
700
+ // advertise them); Gmail has SPAM built in. mailx stores the result
701
+ // as `specialUse: "junk"` on the matching folder row.
702
+ //
703
+ // Earlier versions required an explicit `spam:` field in accounts.jsonc
704
+ // and the button erroring out when that was absent. That's obsolete —
705
+ // the provider knows where spam goes. Just look up the flagged folder.
632
706
  const folders = this.db.getFolders(accountId);
633
- const target = folders.find(f => f.path.toLowerCase() === spamPath.toLowerCase());
707
+ const target = folders.find(f => f.specialUse === "junk");
634
708
  if (!target)
635
- throw new Error(`Spam folder "${spamPath}" not found in ${accountId}`);
709
+ throw new Error(`No \\Junk/\\Spam folder found for ${accountId}`);
636
710
  await this.moveMessages(accountId, uids, target.id);
637
711
  return { targetFolderId: target.id, moved: uids.length };
638
712
  }
@@ -853,6 +927,41 @@ export class MailxService {
853
927
  this.db.recordSentAddress(name || "", email);
854
928
  return true;
855
929
  }
930
+ /** Address-book listing — paginated, filterable. */
931
+ listContacts(query, page = 1, pageSize = 100) {
932
+ return this.db.listContacts(query || "", page, pageSize);
933
+ }
934
+ /** Upsert a contact from the address book UI (edit name). */
935
+ upsertContact(name, email) {
936
+ this.db.upsertContact(name || "", email);
937
+ return { ok: true };
938
+ }
939
+ /** Delete a contact from the address book. */
940
+ deleteContact(email) {
941
+ this.db.deleteContact(email);
942
+ return { ok: true };
943
+ }
944
+ /** Open a configured local path in the OS file explorer. Whitelisted to
945
+ * avoid the UI poking at arbitrary paths. */
946
+ async openLocalPath(which) {
947
+ const dir = getConfigDir();
948
+ let target = dir;
949
+ if (which === "log") {
950
+ const today = new Date();
951
+ const pad = (n) => String(n).padStart(2, "0");
952
+ const fname = `mailx-${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}.log`;
953
+ target = path.join(dir, "logs", fname);
954
+ }
955
+ const { spawn } = await import("child_process");
956
+ const cmd = process.platform === "win32" ? "explorer"
957
+ : process.platform === "darwin" ? "open"
958
+ : "xdg-open";
959
+ const args = process.platform === "win32" && which === "log"
960
+ ? ["/select,", target]
961
+ : [target];
962
+ spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
963
+ return { ok: true, path: target };
964
+ }
856
965
  /** Get all messages in a thread (across folders) for an account. */
857
966
  getThreadMessages(accountId, threadId) {
858
967
  return this.db.getThreadMessages(accountId, threadId);
@@ -71,7 +71,12 @@ async function dispatchAction(svc, action, p) {
71
71
  case "emptyFolder":
72
72
  await svc.emptyFolder(p.accountId, p.folderId);
73
73
  return { ok: true };
74
- // Compose
74
+ // Compose. sendMessage validates synchronously (fast — regex check on
75
+ // recipient strings), then writes to disk synchronously inside the
76
+ // service. Validation errors come back immediately to the user; once
77
+ // we've ACK'd, the message is durable on disk and the worker drains it.
78
+ // The whole call should be sub-100ms; if it ever isn't, the [send]
79
+ // timing log shows which step ate the budget.
75
80
  case "sendMessage":
76
81
  await svc.send(p);
77
82
  return { ok: true };
@@ -91,6 +96,10 @@ async function dispatchAction(svc, action, p) {
91
96
  return svc.getSyncPending();
92
97
  case "getOutboxStatus":
93
98
  return svc.getOutboxStatus();
99
+ case "listQueuedOutgoing":
100
+ return svc.listQueuedOutgoing();
101
+ case "cancelQueuedOutgoing":
102
+ return svc.cancelQueuedOutgoing(p.path);
94
103
  case "reauthenticate":
95
104
  return { ok: await svc.reauthenticate(p.accountId) };
96
105
  // Search & contacts
@@ -100,6 +109,14 @@ async function dispatchAction(svc, action, p) {
100
109
  return svc.searchContacts(p.query);
101
110
  case "addContact":
102
111
  return { ok: svc.addContact(p.name, p.email) };
112
+ case "listContacts":
113
+ return svc.listContacts(p.query || "", p.page || 1, p.pageSize || 100);
114
+ case "upsertContact":
115
+ return svc.upsertContact(p.name, p.email);
116
+ case "deleteContact":
117
+ return svc.deleteContact(p.email);
118
+ case "openLocalPath":
119
+ return await svc.openLocalPath(p.which);
103
120
  case "getThreadMessages":
104
121
  return svc.getThreadMessages(p.accountId, p.threadId);
105
122
  case "readJsoncFile":
@@ -349,13 +349,22 @@ const PROVIDERS = {
349
349
  /** Fill in provider defaults for an account based on email domain */
350
350
  function normalizeAccount(acct, globalName) {
351
351
  const email = acct.email || "";
352
+ const localPart = email.split("@")[0]?.toLowerCase() || "";
352
353
  const domain = email.split("@")[1]?.toLowerCase() || "";
353
354
  const provider = PROVIDERS[domain];
354
355
  const user = acct.imap?.user || acct.user || email;
356
+ // P14: auto-derive id and label so a known-provider account works with just
357
+ // { email, password? } in accounts.jsonc. id defaults to local-part (most
358
+ // accounts have a unique local-part); label defaults to provider name or id.
359
+ // Generic local-parts (info, admin, support, no-reply, ...) fall back to
360
+ // the domain stem to avoid collisions across accounts.
361
+ const GENERIC_LOCALS = new Set(["info", "admin", "support", "no-reply", "noreply", "contact", "hello", "mail", "office"]);
362
+ const domainStem = domain.split(".")[0] || "";
363
+ const autoId = (localPart && !GENERIC_LOCALS.has(localPart)) ? localPart : (domainStem || "account");
355
364
  return {
356
- id: acct.id || domain.split(".")[0] || "account",
357
- name: acct.name || globalName || email.split("@")[0],
358
- label: acct.label || provider?.label,
365
+ id: acct.id || autoId,
366
+ name: acct.name || globalName || localPart,
367
+ label: acct.label || provider?.label || acct.id || autoId,
359
368
  email,
360
369
  imap: {
361
370
  host: acct.imap?.host || provider?.imap.host || `imap.${domain}`,
@@ -386,6 +395,12 @@ function normalizeAccount(acct, globalName) {
386
395
  // that had it configured. `acct.spam` first so a user-set value on
387
396
  // a recognized provider still overrides the default.
388
397
  spam: acct.spam !== undefined ? acct.spam : provider?.spam,
398
+ // `signature` is on AccountConfig in mailx-types but the workspace
399
+ // build order sometimes leaves a stale .d.ts for type-check; using
400
+ // `as any` is the minimum-blast-radius way to add the field without
401
+ // blocking the build. Once mailx-types has rebuilt once (post-field)
402
+ // this cast can be removed.
403
+ ...(acct.signature ? { signature: acct.signature } : {}),
389
404
  };
390
405
  }
391
406
  // ── Defaults ──
@@ -105,6 +105,23 @@ export declare class MailxDB {
105
105
  source: string;
106
106
  useCount: number;
107
107
  }[];
108
+ /** List all contacts (address-book view) with pagination + optional filter. */
109
+ listContacts(query: string, page?: number, pageSize?: number): {
110
+ items: {
111
+ name: string;
112
+ email: string;
113
+ source: string;
114
+ useCount: number;
115
+ lastUsed: number;
116
+ }[];
117
+ total: number;
118
+ page: number;
119
+ pageSize: number;
120
+ };
121
+ /** Update or insert a contact manually (from the address book UI). */
122
+ upsertContact(name: string, email: string): void;
123
+ /** Delete a contact by email (address book UI). */
124
+ deleteContact(email: string): void;
108
125
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
109
126
  searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
110
127
  /** Rebuild FTS index from existing messages */
@@ -589,16 +589,87 @@ export class MailxDB {
589
589
  LIMIT ?`).all(q, q, limit);
590
590
  return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
591
591
  }
592
+ /** List all contacts (address-book view) with pagination + optional filter. */
593
+ listContacts(query, page = 1, pageSize = 100) {
594
+ const hasQuery = !!query.trim();
595
+ const q = `%${query}%`;
596
+ const whereClause = hasQuery ? "WHERE email LIKE ? OR name LIKE ?" : "";
597
+ const params = hasQuery ? [q, q] : [];
598
+ const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
599
+ const offset = (page - 1) * pageSize;
600
+ const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used FROM contacts
601
+ ${whereClause}
602
+ ORDER BY use_count DESC, last_used DESC
603
+ LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
604
+ return {
605
+ items: rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count, lastUsed: r.last_used })),
606
+ total: totalRow?.c || 0,
607
+ page, pageSize,
608
+ };
609
+ }
610
+ /** Update or insert a contact manually (from the address book UI). */
611
+ upsertContact(name, email) {
612
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
613
+ throw new Error(`Invalid email: ${email}`);
614
+ }
615
+ const now = Date.now();
616
+ const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
617
+ if (existing) {
618
+ this.db.prepare("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?").run(name, now, email);
619
+ }
620
+ else {
621
+ this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, ?, 0, ?)").run(name, email, now, now);
622
+ }
623
+ }
624
+ /** Delete a contact by email (address book UI). */
625
+ deleteContact(email) {
626
+ this.db.prepare("DELETE FROM contacts WHERE email = ?").run(email);
627
+ }
592
628
  // ── Search ──
593
629
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
594
630
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
595
- // Parse qualifiers
631
+ // Parse qualifiers (C45: extended set — date:, has:, is:, folder:).
596
632
  let ftsQuery = "";
597
633
  const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
634
+ // Extra SQL where-clauses for qualifiers that don't map to FTS columns.
635
+ const extraWhere = [];
636
+ const extraParams = [];
637
+ // Parse a "1d", "1w", "2024-01-15", "yesterday", "today" etc. into ms epoch.
638
+ const parseRel = (s) => {
639
+ const lower = s.toLowerCase().trim();
640
+ if (lower === "today") {
641
+ const d = new Date();
642
+ d.setHours(0, 0, 0, 0);
643
+ return d.getTime();
644
+ }
645
+ if (lower === "yesterday") {
646
+ const d = new Date();
647
+ d.setHours(0, 0, 0, 0);
648
+ return d.getTime() - 86400_000;
649
+ }
650
+ const rel = lower.match(/^(\d+)([dwmy])$/);
651
+ if (rel) {
652
+ const n = parseInt(rel[1]);
653
+ const unit = rel[2];
654
+ const ms = unit === "d" ? n * 86400_000
655
+ : unit === "w" ? n * 7 * 86400_000
656
+ : unit === "m" ? n * 30 * 86400_000
657
+ : n * 365 * 86400_000;
658
+ return Date.now() - ms;
659
+ }
660
+ const ts = Date.parse(s);
661
+ return isNaN(ts) ? null : ts;
662
+ };
598
663
  for (const part of parts) {
599
664
  const fromMatch = part.match(/^from:(.+)$/i);
600
665
  const toMatch = part.match(/^to:(.+)$/i);
601
666
  const subjectMatch = part.match(/^subject:(.+)$/i);
667
+ const dateMatch = part.match(/^date:([><]?=?)(.+)$/i);
668
+ const afterMatch = part.match(/^after:(.+)$/i);
669
+ const beforeMatch = part.match(/^before:(.+)$/i);
670
+ const hasMatch = part.match(/^has:(.+)$/i);
671
+ const isMatch = part.match(/^is:(.+)$/i);
672
+ const folderMatch = part.match(/^folder:(.+)$/i);
602
673
  if (fromMatch) {
603
674
  const term = fromMatch[1].replace(/"/g, "");
604
675
  ftsQuery += `(from_name:${term} OR from_address:${term}) `;
@@ -611,10 +682,51 @@ export class MailxDB {
611
682
  const term = subjectMatch[1].replace(/"/g, "");
612
683
  ftsQuery += `subject:${term} `;
613
684
  }
685
+ else if (dateMatch || afterMatch || beforeMatch) {
686
+ const op = dateMatch ? (dateMatch[1] || "=") : (afterMatch ? ">" : "<");
687
+ const valStr = dateMatch ? dateMatch[2] : (afterMatch ? afterMatch[1] : beforeMatch[1]);
688
+ const ts = parseRel(valStr.replace(/"/g, ""));
689
+ if (ts !== null) {
690
+ if (op === ">" || op === ">=") {
691
+ extraWhere.push("m.date >= ?");
692
+ extraParams.push(ts);
693
+ }
694
+ else if (op === "<" || op === "<=") {
695
+ extraWhere.push("m.date <= ?");
696
+ extraParams.push(ts);
697
+ }
698
+ else {
699
+ extraWhere.push("m.date >= ? AND m.date < ?");
700
+ extraParams.push(ts, ts + 86400_000);
701
+ }
702
+ }
703
+ }
704
+ else if (hasMatch) {
705
+ const v = hasMatch[1].toLowerCase().replace(/"/g, "");
706
+ if (v === "attachment" || v === "attachments") {
707
+ extraWhere.push("m.has_attachments = 1");
708
+ }
709
+ }
710
+ else if (isMatch) {
711
+ const v = isMatch[1].toLowerCase().replace(/"/g, "");
712
+ if (v === "flagged" || v === "starred")
713
+ extraWhere.push("m.flags_json LIKE '%\\\\Flagged%'");
714
+ else if (v === "unread")
715
+ extraWhere.push("m.flags_json NOT LIKE '%\\\\Seen%'");
716
+ else if (v === "read" || v === "seen")
717
+ extraWhere.push("m.flags_json LIKE '%\\\\Seen%'");
718
+ else if (v === "answered")
719
+ extraWhere.push("m.flags_json LIKE '%\\\\Answered%'");
720
+ else if (v === "draft")
721
+ extraWhere.push("m.flags_json LIKE '%\\\\Draft%'");
722
+ }
723
+ else if (folderMatch) {
724
+ const v = folderMatch[1].replace(/"/g, "");
725
+ extraWhere.push("LOWER(f.name) LIKE ?");
726
+ extraParams.push(`%${v.toLowerCase()}%`);
727
+ }
614
728
  else {
615
729
  // Unqualified — search everything.
616
- // Support "term1|term2" (regex OR) by converting to FTS5 OR.
617
- // Strip surrounding /.../ if user typed regex delimiters.
618
730
  let term = part.replace(/^\/|\/$/g, "");
619
731
  if (term.includes("|")) {
620
732
  const alts = term.split("|").filter(Boolean).map(t => `${t}*`).join(" OR ");
@@ -626,8 +738,10 @@ export class MailxDB {
626
738
  }
627
739
  }
628
740
  ftsQuery = ftsQuery.trim();
741
+ // If the user typed only qualifier-only terms (e.g. "is:flagged after:1w"),
742
+ // FTS query is empty — match-all surrogate.
629
743
  if (!ftsQuery)
630
- return { items: [], total: 0, page, pageSize };
744
+ ftsQuery = "*";
631
745
  const offset = (page - 1) * pageSize;
632
746
  try {
633
747
  let scopeWhere = "";
@@ -640,6 +754,10 @@ export class MailxDB {
640
754
  scopeWhere = " AND m.account_id = ?";
641
755
  scopeParams.push(accountId);
642
756
  }
757
+ if (extraWhere.length > 0) {
758
+ scopeWhere += " AND " + extraWhere.join(" AND ");
759
+ scopeParams.push(...extraParams);
760
+ }
643
761
  const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
644
762
  const total = countRow?.cnt || 0;
645
763
  const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
@@ -35,6 +35,7 @@ export interface AccountConfig {
35
35
  deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
36
36
  identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
37
37
  spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
38
+ signature?: string; /** HTML signature appended to outgoing messages from this account. Plain text or HTML allowed. */
38
39
  }
39
40
  /** Standard IMAP special-use folder types */
40
41
  export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";