@bobfrankston/mailx-store 0.1.78 → 0.1.82
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/db.d.ts +57 -10
- package/db.js +189 -11
- package/package.json +1 -1
package/db.d.ts
CHANGED
|
@@ -19,12 +19,58 @@ export declare function cleanContactName(name: string): string;
|
|
|
19
19
|
/** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
|
|
20
20
|
* once on settings load; invalid regexes are skipped with a warning. */
|
|
21
21
|
export declare function setContactsDenyPatterns(patterns: string[]): void;
|
|
22
|
+
/** Contact-card columns beyond name/email. Kept as one list so the schema,
|
|
23
|
+
* the SELECT, the upsert and the Google People mapping can't drift apart —
|
|
24
|
+
* adding a field means touching this array and the People mapping, nothing
|
|
25
|
+
* else. `organization` predates the rest (v2); the others arrived with v3. */
|
|
26
|
+
export declare const CONTACT_FIELD_COLUMNS: readonly ["organization", "phone", "title", "address", "website", "notes", "birthday"];
|
|
27
|
+
export type ContactFieldColumn = typeof CONTACT_FIELD_COLUMNS[number];
|
|
28
|
+
export type ContactFields = Record<ContactFieldColumn, string>;
|
|
29
|
+
/** A contact as the address book and autocomplete see it. */
|
|
30
|
+
export interface ContactCard extends ContactFields {
|
|
31
|
+
name: string;
|
|
32
|
+
email: string;
|
|
33
|
+
source: string;
|
|
34
|
+
googleId: string | null;
|
|
35
|
+
useCount: number;
|
|
36
|
+
lastUsed: number;
|
|
37
|
+
}
|
|
38
|
+
/** Map a contacts row to a ContactCard, defaulting every optional field to
|
|
39
|
+
* "" so callers never have to null-check a card field. */
|
|
40
|
+
export declare function rowToContactCard(r: any): ContactCard;
|
|
22
41
|
export declare class MailxDB {
|
|
23
42
|
private db;
|
|
24
43
|
/** True for the DB read-worker's connection. Read-only handles never run
|
|
25
44
|
* DDL / migrations / backfills (the main writer already did, before this
|
|
26
45
|
* handle opened) and reject any write at the SQLite level. */
|
|
27
46
|
readonly readOnly: boolean;
|
|
47
|
+
/** Set when a WRITER connection came back read-only anyway — the OS refused
|
|
48
|
+
* read-write access at open and SQLite silently downgraded the handle (see
|
|
49
|
+
* openWritable). Every write for the life of the process will throw. The
|
|
50
|
+
* daemon reads this right after construction and raises a sticky fatal
|
|
51
|
+
* banner; nothing else should ever swallow it. */
|
|
52
|
+
writeDenied: boolean;
|
|
53
|
+
/** Open the database read-WRITE and VERIFY that we actually got write
|
|
54
|
+
* access.
|
|
55
|
+
*
|
|
56
|
+
* SQLite silently downgrades to a read-only handle when the OS refuses the
|
|
57
|
+
* read-write open (a transient sharing/permission denial — AV or backup
|
|
58
|
+
* scan, a previous instance still exiting, resume-from-sleep). Nothing
|
|
59
|
+
* throws at open: reads all work, and then EVERY write for the life of the
|
|
60
|
+
* process fails with "attempt to write a readonly database". On 2026-08-02
|
|
61
|
+
* that ran an entire boot as a zombie session — is_replied backfill,
|
|
62
|
+
* membership collapse, addAccount for all four accounts, flags (★), sync:
|
|
63
|
+
* all dead, surfaced only as a per-account banner ("outlook: attempt to
|
|
64
|
+
* write a readonly database") that read like an account problem.
|
|
65
|
+
*
|
|
66
|
+
* So: probe with a real write inside a transaction we then ROLL BACK, and
|
|
67
|
+
* reopen with backoff while the verdict is read-only. It has to be an
|
|
68
|
+
* actual page write — under WAL, `BEGIN IMMEDIATE` alone takes its lock in
|
|
69
|
+
* the -shm wal-index and SUCCEEDS on a read-only database (measured), so a
|
|
70
|
+
* lock-only probe reports healthy on exactly the handle we're hunting.
|
|
71
|
+
* SQLITE_BUSY is the opposite verdict — it PROVES the handle is
|
|
72
|
+
* write-capable and merely contended, so keep it. */
|
|
73
|
+
private openWritable;
|
|
28
74
|
constructor(dbDir: string, opts?: {
|
|
29
75
|
readOnly?: boolean;
|
|
30
76
|
skipMigrations?: boolean;
|
|
@@ -743,20 +789,21 @@ export declare class MailxDB {
|
|
|
743
789
|
}[];
|
|
744
790
|
/** List all contacts (address-book view) with pagination + optional filter. */
|
|
745
791
|
listContacts(query: string, page?: number, pageSize?: number): {
|
|
746
|
-
items:
|
|
747
|
-
name: string;
|
|
748
|
-
email: string;
|
|
749
|
-
source: string;
|
|
750
|
-
googleId: string | null;
|
|
751
|
-
useCount: number;
|
|
752
|
-
lastUsed: number;
|
|
753
|
-
}[];
|
|
792
|
+
items: ContactCard[];
|
|
754
793
|
total: number;
|
|
755
794
|
page: number;
|
|
756
795
|
pageSize: number;
|
|
757
796
|
};
|
|
758
|
-
/** Update or insert a contact manually (from the address book UI
|
|
759
|
-
|
|
797
|
+
/** Update or insert a contact manually (from the address book UI, or
|
|
798
|
+
* from an AI-extracted proposal the user accepted).
|
|
799
|
+
*
|
|
800
|
+
* `fields` is OPTIONAL and PARTIAL by design: an undefined member
|
|
801
|
+
* leaves the stored value alone rather than blanking it. The
|
|
802
|
+
* address book edits one field at a time, and mail-derived proposals
|
|
803
|
+
* only ever know a subset — neither should wipe what Google People
|
|
804
|
+
* already knew about the person. Pass an explicit empty string to
|
|
805
|
+
* clear a field. */
|
|
806
|
+
upsertContact(name: string, email: string, fields?: Partial<ContactFields>): void;
|
|
760
807
|
/** Delete a contact by email (address book UI). */
|
|
761
808
|
deleteContact(email: string): void;
|
|
762
809
|
/** Delete contact rows by Google People resourceName. Used by the
|
package/db.js
CHANGED
|
@@ -202,6 +202,16 @@ const SCHEMA = `
|
|
|
202
202
|
name TEXT DEFAULT '',
|
|
203
203
|
email TEXT NOT NULL,
|
|
204
204
|
organization TEXT DEFAULT '',
|
|
205
|
+
-- Full contact card (v3). Google People carries all of these; before
|
|
206
|
+
-- v3 we stored only name/email/organization and threw the rest away
|
|
207
|
+
-- on every sync, so a phone number in a signature had nowhere to land
|
|
208
|
+
-- (Bob 2026-08-07: "extend the contact support for all fields").
|
|
209
|
+
phone TEXT DEFAULT '',
|
|
210
|
+
title TEXT DEFAULT '',
|
|
211
|
+
address TEXT DEFAULT '',
|
|
212
|
+
website TEXT DEFAULT '',
|
|
213
|
+
notes TEXT DEFAULT '',
|
|
214
|
+
birthday TEXT DEFAULT '',
|
|
205
215
|
last_used INTEGER DEFAULT 0,
|
|
206
216
|
use_count INTEGER DEFAULT 0,
|
|
207
217
|
updated_at INTEGER NOT NULL,
|
|
@@ -426,17 +436,119 @@ const SCHEMA = `
|
|
|
426
436
|
CREATE INDEX IF NOT EXISTS idx_message_folders_msg ON message_folders(message_row_id);
|
|
427
437
|
CREATE INDEX IF NOT EXISTS idx_message_folders_folder_uid ON message_folders(folder_id, uid);
|
|
428
438
|
`;
|
|
439
|
+
/** Park the thread for `ms` without spinning. The DB is opened in a
|
|
440
|
+
* constructor (no async available) and the write-access probe below needs to
|
|
441
|
+
* wait out a transient lock; Atomics.wait is the only true sync sleep. */
|
|
442
|
+
function sleepSync(ms) {
|
|
443
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
444
|
+
}
|
|
445
|
+
/** Contact-card columns beyond name/email. Kept as one list so the schema,
|
|
446
|
+
* the SELECT, the upsert and the Google People mapping can't drift apart —
|
|
447
|
+
* adding a field means touching this array and the People mapping, nothing
|
|
448
|
+
* else. `organization` predates the rest (v2); the others arrived with v3. */
|
|
449
|
+
export const CONTACT_FIELD_COLUMNS = [
|
|
450
|
+
"organization", "phone", "title", "address", "website", "notes", "birthday",
|
|
451
|
+
];
|
|
452
|
+
/** Map a contacts row to a ContactCard, defaulting every optional field to
|
|
453
|
+
* "" so callers never have to null-check a card field. */
|
|
454
|
+
export function rowToContactCard(r) {
|
|
455
|
+
const card = {
|
|
456
|
+
name: r.name || "",
|
|
457
|
+
email: r.email || "",
|
|
458
|
+
source: r.source || "",
|
|
459
|
+
googleId: r.google_id || null,
|
|
460
|
+
useCount: r.use_count || 0,
|
|
461
|
+
lastUsed: r.last_used || 0,
|
|
462
|
+
};
|
|
463
|
+
for (const col of CONTACT_FIELD_COLUMNS)
|
|
464
|
+
card[col] = r[col] || "";
|
|
465
|
+
return card;
|
|
466
|
+
}
|
|
429
467
|
export class MailxDB {
|
|
430
468
|
db;
|
|
431
469
|
/** True for the DB read-worker's connection. Read-only handles never run
|
|
432
470
|
* DDL / migrations / backfills (the main writer already did, before this
|
|
433
471
|
* handle opened) and reject any write at the SQLite level. */
|
|
434
472
|
readOnly;
|
|
473
|
+
/** Set when a WRITER connection came back read-only anyway — the OS refused
|
|
474
|
+
* read-write access at open and SQLite silently downgraded the handle (see
|
|
475
|
+
* openWritable). Every write for the life of the process will throw. The
|
|
476
|
+
* daemon reads this right after construction and raises a sticky fatal
|
|
477
|
+
* banner; nothing else should ever swallow it. */
|
|
478
|
+
writeDenied = false;
|
|
479
|
+
/** Open the database read-WRITE and VERIFY that we actually got write
|
|
480
|
+
* access.
|
|
481
|
+
*
|
|
482
|
+
* SQLite silently downgrades to a read-only handle when the OS refuses the
|
|
483
|
+
* read-write open (a transient sharing/permission denial — AV or backup
|
|
484
|
+
* scan, a previous instance still exiting, resume-from-sleep). Nothing
|
|
485
|
+
* throws at open: reads all work, and then EVERY write for the life of the
|
|
486
|
+
* process fails with "attempt to write a readonly database". On 2026-08-02
|
|
487
|
+
* that ran an entire boot as a zombie session — is_replied backfill,
|
|
488
|
+
* membership collapse, addAccount for all four accounts, flags (★), sync:
|
|
489
|
+
* all dead, surfaced only as a per-account banner ("outlook: attempt to
|
|
490
|
+
* write a readonly database") that read like an account problem.
|
|
491
|
+
*
|
|
492
|
+
* So: probe with a real write inside a transaction we then ROLL BACK, and
|
|
493
|
+
* reopen with backoff while the verdict is read-only. It has to be an
|
|
494
|
+
* actual page write — under WAL, `BEGIN IMMEDIATE` alone takes its lock in
|
|
495
|
+
* the -shm wal-index and SUCCEEDS on a read-only database (measured), so a
|
|
496
|
+
* lock-only probe reports healthy on exactly the handle we're hunting.
|
|
497
|
+
* SQLITE_BUSY is the opposite verdict — it PROVES the handle is
|
|
498
|
+
* write-capable and merely contended, so keep it. */
|
|
499
|
+
openWritable(dbPath) {
|
|
500
|
+
const backoffMs = [0, 100, 300, 1000, 2000];
|
|
501
|
+
let lastErr = "";
|
|
502
|
+
for (let attempt = 0; attempt < backoffMs.length; attempt++) {
|
|
503
|
+
if (backoffMs[attempt])
|
|
504
|
+
sleepSync(backoffMs[attempt]);
|
|
505
|
+
const db = new DatabaseSync(dbPath);
|
|
506
|
+
try {
|
|
507
|
+
db.exec("BEGIN IMMEDIATE");
|
|
508
|
+
db.exec("CREATE TABLE _mailx_write_probe(x)");
|
|
509
|
+
db.exec("ROLLBACK"); // probe table never reaches the file
|
|
510
|
+
if (attempt > 0)
|
|
511
|
+
console.log(` [db] write access acquired on attempt ${attempt + 1}`);
|
|
512
|
+
return db;
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
lastErr = e?.message || String(e);
|
|
516
|
+
if (!/readonly/i.test(lastErr)) {
|
|
517
|
+
// Busy/locked — another writer holds the lock this instant.
|
|
518
|
+
// That is a write-capable handle; take it.
|
|
519
|
+
try {
|
|
520
|
+
db.exec("ROLLBACK");
|
|
521
|
+
}
|
|
522
|
+
catch { /* no txn was opened */ }
|
|
523
|
+
return db;
|
|
524
|
+
}
|
|
525
|
+
console.error(` [db] opened READ-ONLY (attempt ${attempt + 1}/${backoffMs.length}): ${lastErr}`);
|
|
526
|
+
db.close();
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
// Out of retries. Return a handle anyway — reading mail beats no app at
|
|
530
|
+
// all — but flag it so the daemon can say so, loudly and stickily.
|
|
531
|
+
console.error(` [db] FATAL: ${dbPath} is READ-ONLY after ${backoffMs.length} attempts — no change will be saved (${lastErr})`);
|
|
532
|
+
this.writeDenied = true;
|
|
533
|
+
return new DatabaseSync(dbPath);
|
|
534
|
+
}
|
|
435
535
|
constructor(dbDir, opts = {}) {
|
|
436
536
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
437
537
|
const dbPath = path.join(dbDir, "mailx.db");
|
|
438
538
|
this.readOnly = !!opts.readOnly;
|
|
439
|
-
this.db = new DatabaseSync(dbPath);
|
|
539
|
+
this.db = this.readOnly ? new DatabaseSync(dbPath) : this.openWritable(dbPath);
|
|
540
|
+
if (this.writeDenied) {
|
|
541
|
+
// Asked for a writer, got a read-only handle (openWritable already
|
|
542
|
+
// shouted about it). Everything below — journal_mode, the schema,
|
|
543
|
+
// every migration and backfill — is a write, so running it would
|
|
544
|
+
// throw or bury the real message under a dozen derived errors.
|
|
545
|
+
// Set up as a reader and let the daemon surface the banner: the
|
|
546
|
+
// user can still READ mail while the app says, once and plainly,
|
|
547
|
+
// that nothing is being saved.
|
|
548
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
549
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
440
552
|
if (opts.skipMigrations && !opts.readOnly) {
|
|
441
553
|
// Write-capable connection that does NOT run schema/migrations —
|
|
442
554
|
// used by the sync worker, which opens its own writer AFTER the
|
|
@@ -827,6 +939,54 @@ export class MailxDB {
|
|
|
827
939
|
console.error(` [db] contacts v2 reset failed: ${e.message}`);
|
|
828
940
|
}
|
|
829
941
|
}
|
|
942
|
+
// contacts v3: the full contact card. v2 kept only name / email /
|
|
943
|
+
// organization, so every phone number, job title, address, website,
|
|
944
|
+
// note and birthday Google People had was discarded on every sync,
|
|
945
|
+
// and a phone number read out of a signature block had nowhere to
|
|
946
|
+
// land (Bob 2026-08-07: "extend the contact support for all fields").
|
|
947
|
+
// Same recreate-the-table shape as v2 rather than an ALTER, but the
|
|
948
|
+
// EXISTING ROWS ARE CARRIED ACROSS. v2 dropped them outright, which is
|
|
949
|
+
// fine for google rows (one resync restores them) and wrong for the
|
|
950
|
+
// rest: sent/received/manual rows and their use_count ordering come
|
|
951
|
+
// from local mail and the user, and Google would never hand them back.
|
|
952
|
+
// The new columns simply arrive empty and fill in on the next sync.
|
|
953
|
+
const contactsV3Flag = this.getKv("schema", "contacts_v3");
|
|
954
|
+
if (!contactsV3Flag) {
|
|
955
|
+
try {
|
|
956
|
+
const carried = this.db.prepare("SELECT source, google_id, name, email, organization, last_used, use_count, updated_at FROM contacts").all();
|
|
957
|
+
this.db.exec("DROP TABLE IF EXISTS contacts");
|
|
958
|
+
this.db.exec(`
|
|
959
|
+
CREATE TABLE contacts (
|
|
960
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
961
|
+
source TEXT NOT NULL DEFAULT 'discovered',
|
|
962
|
+
google_id TEXT,
|
|
963
|
+
name TEXT DEFAULT '',
|
|
964
|
+
email TEXT NOT NULL,
|
|
965
|
+
organization TEXT DEFAULT '',
|
|
966
|
+
phone TEXT DEFAULT '',
|
|
967
|
+
title TEXT DEFAULT '',
|
|
968
|
+
address TEXT DEFAULT '',
|
|
969
|
+
website TEXT DEFAULT '',
|
|
970
|
+
notes TEXT DEFAULT '',
|
|
971
|
+
birthday TEXT DEFAULT '',
|
|
972
|
+
last_used INTEGER DEFAULT 0,
|
|
973
|
+
use_count INTEGER DEFAULT 0,
|
|
974
|
+
updated_at INTEGER NOT NULL,
|
|
975
|
+
UNIQUE(source, email, name)
|
|
976
|
+
);
|
|
977
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
|
|
978
|
+
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
|
979
|
+
`);
|
|
980
|
+
const ins = this.db.prepare("INSERT OR IGNORE INTO contacts (source, google_id, name, email, organization, last_used, use_count, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
981
|
+
for (const m of carried)
|
|
982
|
+
ins.run(m.source || "discovered", m.google_id || null, m.name || "", m.email, m.organization || "", m.last_used || 0, m.use_count || 0, m.updated_at || Date.now());
|
|
983
|
+
this.setKv("schema", "contacts_v3", String(Date.now()));
|
|
984
|
+
console.log(` [db] contacts table reset to v3 schema (full contact card); carried ${carried.length} row(s)`);
|
|
985
|
+
}
|
|
986
|
+
catch (e) {
|
|
987
|
+
console.error(` [db] contacts v3 reset failed: ${e.message}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
830
990
|
// Post-migration sanity check: verify the columns we actually read in
|
|
831
991
|
// SELECTs exist. If any migration silently failed (stale driver, DB
|
|
832
992
|
// file locked, permission error), later code would throw cryptic
|
|
@@ -3571,32 +3731,50 @@ export class MailxDB {
|
|
|
3571
3731
|
const params = hasQuery ? [q, q] : [];
|
|
3572
3732
|
const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
|
|
3573
3733
|
const offset = (page - 1) * pageSize;
|
|
3574
|
-
const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used
|
|
3734
|
+
const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used,
|
|
3735
|
+
organization, phone, title, address, website, notes, birthday FROM contacts
|
|
3575
3736
|
${whereClause}
|
|
3576
3737
|
ORDER BY use_count DESC, last_used DESC
|
|
3577
3738
|
LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
3578
3739
|
return {
|
|
3579
|
-
items: rows.map(r => (
|
|
3580
|
-
name: r.name, email: r.email, source: r.source,
|
|
3581
|
-
googleId: r.google_id || null,
|
|
3582
|
-
useCount: r.use_count, lastUsed: r.last_used,
|
|
3583
|
-
})),
|
|
3740
|
+
items: rows.map(r => rowToContactCard(r)),
|
|
3584
3741
|
total: totalRow?.c || 0,
|
|
3585
3742
|
page, pageSize,
|
|
3586
3743
|
};
|
|
3587
3744
|
}
|
|
3588
|
-
/** Update or insert a contact manually (from the address book UI
|
|
3589
|
-
|
|
3745
|
+
/** Update or insert a contact manually (from the address book UI, or
|
|
3746
|
+
* from an AI-extracted proposal the user accepted).
|
|
3747
|
+
*
|
|
3748
|
+
* `fields` is OPTIONAL and PARTIAL by design: an undefined member
|
|
3749
|
+
* leaves the stored value alone rather than blanking it. The
|
|
3750
|
+
* address book edits one field at a time, and mail-derived proposals
|
|
3751
|
+
* only ever know a subset — neither should wipe what Google People
|
|
3752
|
+
* already knew about the person. Pass an explicit empty string to
|
|
3753
|
+
* clear a field. */
|
|
3754
|
+
upsertContact(name, email, fields) {
|
|
3590
3755
|
if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
|
|
3591
3756
|
throw new Error(`Invalid email: ${email}`);
|
|
3592
3757
|
}
|
|
3593
3758
|
const now = Date.now();
|
|
3594
3759
|
const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
|
|
3760
|
+
// Only touch the columns the caller actually supplied.
|
|
3761
|
+
const sets = [];
|
|
3762
|
+
const vals = [];
|
|
3763
|
+
for (const col of CONTACT_FIELD_COLUMNS) {
|
|
3764
|
+
const v = fields?.[col];
|
|
3765
|
+
if (v === undefined)
|
|
3766
|
+
continue;
|
|
3767
|
+
sets.push(`${col} = ?`);
|
|
3768
|
+
vals.push(v);
|
|
3769
|
+
}
|
|
3595
3770
|
if (existing) {
|
|
3596
|
-
this.db.prepare(
|
|
3771
|
+
this.db.prepare(`UPDATE contacts SET name = ?, ${sets.length ? sets.join(", ") + "," : ""} updated_at = ? WHERE email = ?`).run(name, ...vals, now, email);
|
|
3597
3772
|
}
|
|
3598
3773
|
else {
|
|
3599
|
-
|
|
3774
|
+
const cols = CONTACT_FIELD_COLUMNS.filter(c => fields?.[c] !== undefined);
|
|
3775
|
+
const colSql = cols.length ? `, ${cols.join(", ")}` : "";
|
|
3776
|
+
const phSql = cols.length ? `, ${cols.map(() => "?").join(", ")}` : "";
|
|
3777
|
+
this.db.prepare(`INSERT INTO contacts (source, name, email, last_used, use_count, updated_at${colSql}) VALUES ('manual', ?, ?, ?, 0, ?${phSql})`).run(name, email, now, now, ...cols.map(c => fields[c]));
|
|
3600
3778
|
}
|
|
3601
3779
|
}
|
|
3602
3780
|
/** Delete a contact by email (address book UI). */
|