@bobfrankston/mailx 1.0.339 → 1.0.348
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/bin/mailx.js +87 -7
- package/client/app.js +413 -32
- package/client/components/address-book.js +199 -0
- package/client/components/calendar.js +217 -0
- package/client/components/folder-tree.js +62 -16
- package/client/components/message-list.js +9 -0
- package/client/components/message-viewer.js +41 -8
- package/client/components/outbox-view.js +104 -0
- package/client/components/tasks.js +256 -0
- package/client/compose/compose.html +2 -2
- package/client/compose/compose.js +87 -39
- package/client/compose/editor.js +67 -0
- package/client/index.html +8 -6
- package/client/lib/api-client.js +21 -0
- package/client/lib/mailxapi.js +15 -0
- package/client/styles/components.css +354 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +24 -0
- package/packages/mailx-imap/index.js +132 -6
- package/packages/mailx-service/index.d.ts +25 -0
- package/packages/mailx-service/index.js +142 -5
- package/packages/mailx-service/jsonrpc.js +20 -1
- package/packages/mailx-settings/index.js +18 -3
- package/packages/mailx-store/db.d.ts +17 -0
- package/packages/mailx-store/db.js +122 -4
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -34,6 +34,15 @@ export declare class MailxService {
|
|
|
34
34
|
getSyncPending(): {
|
|
35
35
|
pending: number;
|
|
36
36
|
};
|
|
37
|
+
/** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
|
|
38
|
+
getOutboxStatus(): any;
|
|
39
|
+
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
40
|
+
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
41
|
+
listQueuedOutgoing(): any[];
|
|
42
|
+
/** Manually drop a queued message (not yet sent). Removes the .ltr file. */
|
|
43
|
+
cancelQueuedOutgoing(filePath: string): {
|
|
44
|
+
ok: true;
|
|
45
|
+
};
|
|
37
46
|
syncAll(): Promise<void>;
|
|
38
47
|
syncAccount(accountId: string): Promise<void>;
|
|
39
48
|
/** Force re-authentication for an account (deletes token, opens browser consent) */
|
|
@@ -73,6 +82,22 @@ export declare class MailxService {
|
|
|
73
82
|
* action on From/To/Cc addresses in the message viewer. Just calls the same
|
|
74
83
|
* validated upsert path as recordSentAddress. */
|
|
75
84
|
addContact(name: string, email: string): boolean;
|
|
85
|
+
/** Address-book listing — paginated, filterable. */
|
|
86
|
+
listContacts(query: string, page?: number, pageSize?: number): any;
|
|
87
|
+
/** Upsert a contact from the address book UI (edit name). */
|
|
88
|
+
upsertContact(name: string, email: string): {
|
|
89
|
+
ok: true;
|
|
90
|
+
};
|
|
91
|
+
/** Delete a contact from the address book. */
|
|
92
|
+
deleteContact(email: string): {
|
|
93
|
+
ok: true;
|
|
94
|
+
};
|
|
95
|
+
/** Open a configured local path in the OS file explorer. Whitelisted to
|
|
96
|
+
* avoid the UI poking at arbitrary paths. */
|
|
97
|
+
openLocalPath(which: "config" | "log"): Promise<{
|
|
98
|
+
ok: true;
|
|
99
|
+
path: string;
|
|
100
|
+
}>;
|
|
76
101
|
/** Get all messages in a thread (across folders) for an account. */
|
|
77
102
|
getThreadMessages(accountId: string, threadId: string): any;
|
|
78
103
|
/** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
|
|
@@ -409,6 +409,87 @@ export class MailxService {
|
|
|
409
409
|
getSyncPending() {
|
|
410
410
|
return { pending: this.db.getTotalPendingSyncCount() };
|
|
411
411
|
}
|
|
412
|
+
/** Outbox queue depth + retry status for the UI status bar. Cheap to call. */
|
|
413
|
+
getOutboxStatus() {
|
|
414
|
+
return this.imapManager.getOutboxStatus();
|
|
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
|
+
}
|
|
412
493
|
async syncAll() {
|
|
413
494
|
await this.imapManager.syncAll();
|
|
414
495
|
}
|
|
@@ -440,6 +521,9 @@ export class MailxService {
|
|
|
440
521
|
// locally. Everything else (contacts recording, IMAP APPEND,
|
|
441
522
|
// SMTP) happens after the IPC ACK. Settings come from cache so
|
|
442
523
|
// a stalled GDrive mount doesn't block the send.
|
|
524
|
+
const t0 = Date.now();
|
|
525
|
+
const lap = (label) => console.log(` [send] +${Date.now() - t0}ms ${label}`);
|
|
526
|
+
console.log(` [send] ENTRY from=${msg?.from} to=${JSON.stringify(msg?.to)} subject="${msg?.subject}" attachments=${msg?.attachments?.length || 0}`);
|
|
443
527
|
const accounts = this.getCachedAccounts();
|
|
444
528
|
let account = accounts.find(a => a.id === msg.from);
|
|
445
529
|
if (!account) {
|
|
@@ -447,8 +531,12 @@ export class MailxService {
|
|
|
447
531
|
this._accountsCache = null;
|
|
448
532
|
account = this.getCachedAccounts().find(a => a.id === msg.from);
|
|
449
533
|
}
|
|
450
|
-
if (!account)
|
|
534
|
+
if (!account) {
|
|
535
|
+
const ids = accounts.map(a => a.id).join(", ");
|
|
536
|
+
console.error(` [send] FAIL: Unknown account "${msg.from}". Known accounts: [${ids}]`);
|
|
451
537
|
throw new Error(`Unknown account: ${msg.from}`);
|
|
538
|
+
}
|
|
539
|
+
lap("account resolved");
|
|
452
540
|
// Vet every recipient address — refuse to send if any field contains a
|
|
453
541
|
// non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
|
|
454
542
|
// autocomplete). This catches garbage BEFORE it hits SMTP, where the
|
|
@@ -531,7 +619,9 @@ export class MailxService {
|
|
|
531
619
|
].join("\r\n");
|
|
532
620
|
rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
533
621
|
}
|
|
622
|
+
lap(`MIME assembled (${rawMessage.length} bytes${hasAttachments ? `, ${msg.attachments.length} attachment(s)` : ""})`);
|
|
534
623
|
this.imapManager.queueOutgoingLocal(account.id, rawMessage);
|
|
624
|
+
lap("queued to disk");
|
|
535
625
|
console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
|
|
536
626
|
// Contacts recording is off the critical path — deferred until after
|
|
537
627
|
// the IPC ACK so a slow DB write can't stall the send.
|
|
@@ -604,8 +694,13 @@ export class MailxService {
|
|
|
604
694
|
/** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
|
|
605
695
|
* Throws if the account has no spam folder configured or the folder doesn't exist locally. */
|
|
606
696
|
async markAsSpamMessages(accountId, uids) {
|
|
607
|
-
|
|
608
|
-
|
|
697
|
+
// Cached accounts — same reason as send/saveDraft: a stalled GDrive
|
|
698
|
+
// mount could turn `Mark as spam` into a 120s IPC timeout.
|
|
699
|
+
let account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
700
|
+
if (!account) {
|
|
701
|
+
this._accountsCache = null;
|
|
702
|
+
account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
703
|
+
}
|
|
609
704
|
if (!account)
|
|
610
705
|
throw new Error(`Account ${accountId} not found`);
|
|
611
706
|
const spamPath = account.spam;
|
|
@@ -760,8 +855,15 @@ export class MailxService {
|
|
|
760
855
|
// a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
|
|
761
856
|
// so the reconciler can de-duplicate on the server by header search
|
|
762
857
|
// even without the previousDraftUid round-trip.
|
|
763
|
-
|
|
764
|
-
|
|
858
|
+
// Account lookup uses the cached list — `loadSettings()` reads
|
|
859
|
+
// accounts.jsonc from the GDrive mount and could itself stall for
|
|
860
|
+
// 120s, which was the actual `mailxapi timeout: saveDraft` source
|
|
861
|
+
// (the IMAP work was fire-and-forget, but loadSettings wasn't).
|
|
862
|
+
let account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
863
|
+
if (!account) {
|
|
864
|
+
this._accountsCache = null;
|
|
865
|
+
account = this.getCachedAccounts().find(a => a.id === accountId);
|
|
866
|
+
}
|
|
765
867
|
if (!account)
|
|
766
868
|
throw new Error(`Unknown account: ${accountId}`);
|
|
767
869
|
// Generate or reuse a stable draft ID for dedup
|
|
@@ -828,6 +930,41 @@ export class MailxService {
|
|
|
828
930
|
this.db.recordSentAddress(name || "", email);
|
|
829
931
|
return true;
|
|
830
932
|
}
|
|
933
|
+
/** Address-book listing — paginated, filterable. */
|
|
934
|
+
listContacts(query, page = 1, pageSize = 100) {
|
|
935
|
+
return this.db.listContacts(query || "", page, pageSize);
|
|
936
|
+
}
|
|
937
|
+
/** Upsert a contact from the address book UI (edit name). */
|
|
938
|
+
upsertContact(name, email) {
|
|
939
|
+
this.db.upsertContact(name || "", email);
|
|
940
|
+
return { ok: true };
|
|
941
|
+
}
|
|
942
|
+
/** Delete a contact from the address book. */
|
|
943
|
+
deleteContact(email) {
|
|
944
|
+
this.db.deleteContact(email);
|
|
945
|
+
return { ok: true };
|
|
946
|
+
}
|
|
947
|
+
/** Open a configured local path in the OS file explorer. Whitelisted to
|
|
948
|
+
* avoid the UI poking at arbitrary paths. */
|
|
949
|
+
async openLocalPath(which) {
|
|
950
|
+
const dir = getConfigDir();
|
|
951
|
+
let target = dir;
|
|
952
|
+
if (which === "log") {
|
|
953
|
+
const today = new Date();
|
|
954
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
955
|
+
const fname = `mailx-${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}.log`;
|
|
956
|
+
target = path.join(dir, "logs", fname);
|
|
957
|
+
}
|
|
958
|
+
const { spawn } = await import("child_process");
|
|
959
|
+
const cmd = process.platform === "win32" ? "explorer"
|
|
960
|
+
: process.platform === "darwin" ? "open"
|
|
961
|
+
: "xdg-open";
|
|
962
|
+
const args = process.platform === "win32" && which === "log"
|
|
963
|
+
? ["/select,", target]
|
|
964
|
+
: [target];
|
|
965
|
+
spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
966
|
+
return { ok: true, path: target };
|
|
967
|
+
}
|
|
831
968
|
/** Get all messages in a thread (across folders) for an account. */
|
|
832
969
|
getThreadMessages(accountId, threadId) {
|
|
833
970
|
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 };
|
|
@@ -89,6 +94,12 @@ async function dispatchAction(svc, action, p) {
|
|
|
89
94
|
return { ok: true };
|
|
90
95
|
case "getSyncPending":
|
|
91
96
|
return svc.getSyncPending();
|
|
97
|
+
case "getOutboxStatus":
|
|
98
|
+
return svc.getOutboxStatus();
|
|
99
|
+
case "listQueuedOutgoing":
|
|
100
|
+
return svc.listQueuedOutgoing();
|
|
101
|
+
case "cancelQueuedOutgoing":
|
|
102
|
+
return svc.cancelQueuedOutgoing(p.path);
|
|
92
103
|
case "reauthenticate":
|
|
93
104
|
return { ok: await svc.reauthenticate(p.accountId) };
|
|
94
105
|
// Search & contacts
|
|
@@ -98,6 +109,14 @@ async function dispatchAction(svc, action, p) {
|
|
|
98
109
|
return svc.searchContacts(p.query);
|
|
99
110
|
case "addContact":
|
|
100
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);
|
|
101
120
|
case "getThreadMessages":
|
|
102
121
|
return svc.getThreadMessages(p.accountId, p.threadId);
|
|
103
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 ||
|
|
357
|
-
name: acct.name || globalName ||
|
|
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
|
-
|
|
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";
|