@bobfrankston/mailx-store 0.1.80 → 0.1.83

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.
Files changed (3) hide show
  1. package/db.d.ts +30 -10
  2. package/db.js +108 -10
  3. package/package.json +5 -5
package/db.d.ts CHANGED
@@ -19,6 +19,25 @@ 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
@@ -770,20 +789,21 @@ export declare class MailxDB {
770
789
  }[];
771
790
  /** List all contacts (address-book view) with pagination + optional filter. */
772
791
  listContacts(query: string, page?: number, pageSize?: number): {
773
- items: {
774
- name: string;
775
- email: string;
776
- source: string;
777
- googleId: string | null;
778
- useCount: number;
779
- lastUsed: number;
780
- }[];
792
+ items: ContactCard[];
781
793
  total: number;
782
794
  page: number;
783
795
  pageSize: number;
784
796
  };
785
- /** Update or insert a contact manually (from the address book UI). */
786
- upsertContact(name: string, email: string): void;
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;
787
807
  /** Delete a contact by email (address book UI). */
788
808
  deleteContact(email: string): void;
789
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,
@@ -432,6 +442,28 @@ const SCHEMA = `
432
442
  function sleepSync(ms) {
433
443
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
434
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
+ }
435
467
  export class MailxDB {
436
468
  db;
437
469
  /** True for the DB read-worker's connection. Read-only handles never run
@@ -907,6 +939,54 @@ export class MailxDB {
907
939
  console.error(` [db] contacts v2 reset failed: ${e.message}`);
908
940
  }
909
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
+ }
910
990
  // Post-migration sanity check: verify the columns we actually read in
911
991
  // SELECTs exist. If any migration silently failed (stale driver, DB
912
992
  // file locked, permission error), later code would throw cryptic
@@ -3651,32 +3731,50 @@ export class MailxDB {
3651
3731
  const params = hasQuery ? [q, q] : [];
3652
3732
  const totalRow = this.db.prepare(`SELECT COUNT(*) as c FROM contacts ${whereClause}`).get(...params);
3653
3733
  const offset = (page - 1) * pageSize;
3654
- const rows = this.db.prepare(`SELECT name, email, source, google_id, use_count, last_used FROM contacts
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
3655
3736
  ${whereClause}
3656
3737
  ORDER BY use_count DESC, last_used DESC
3657
3738
  LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
3658
3739
  return {
3659
- items: rows.map(r => ({
3660
- name: r.name, email: r.email, source: r.source,
3661
- googleId: r.google_id || null,
3662
- useCount: r.use_count, lastUsed: r.last_used,
3663
- })),
3740
+ items: rows.map(r => rowToContactCard(r)),
3664
3741
  total: totalRow?.c || 0,
3665
3742
  page, pageSize,
3666
3743
  };
3667
3744
  }
3668
- /** Update or insert a contact manually (from the address book UI). */
3669
- upsertContact(name, email) {
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) {
3670
3755
  if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) {
3671
3756
  throw new Error(`Invalid email: ${email}`);
3672
3757
  }
3673
3758
  const now = Date.now();
3674
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
+ }
3675
3770
  if (existing) {
3676
- this.db.prepare("UPDATE contacts SET name = ?, updated_at = ? WHERE email = ?").run(name, now, email);
3771
+ this.db.prepare(`UPDATE contacts SET name = ?, ${sets.length ? sets.join(", ") + "," : ""} updated_at = ? WHERE email = ?`).run(name, ...vals, now, email);
3677
3772
  }
3678
3773
  else {
3679
- this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('manual', ?, ?, ?, 0, ?)").run(name, email, now, now);
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]));
3680
3778
  }
3681
3779
  }
3682
3780
  /** Delete a contact by email (address book UI). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.80",
3
+ "version": "0.1.83",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.38",
13
- "@bobfrankston/mailx-settings": "^0.1.48",
12
+ "@bobfrankston/mailx-types": "^0.1.40",
13
+ "@bobfrankston/mailx-settings": "^0.1.49",
14
14
  "@bobfrankston/mailx-bus": "^0.1.2",
15
15
  "mailparser": "^3.7.2"
16
16
  },
@@ -29,8 +29,8 @@
29
29
  },
30
30
  ".transformedSnapshot": {
31
31
  "dependencies": {
32
- "@bobfrankston/mailx-types": "^0.1.38",
33
- "@bobfrankston/mailx-settings": "^0.1.48",
32
+ "@bobfrankston/mailx-types": "^0.1.40",
33
+ "@bobfrankston/mailx-settings": "^0.1.49",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }