@bobfrankston/mailx-store 0.1.3 → 0.1.7

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 (5) hide show
  1. package/db.d.ts +282 -4
  2. package/db.js +1513 -57
  3. package/file-store.d.ts +45 -13
  4. package/file-store.js +108 -43
  5. package/package.json +13 -9
package/db.d.ts CHANGED
@@ -4,9 +4,133 @@
4
4
  * Message bodies are NOT here -- they live in the MessageStore backend.
5
5
  */
6
6
  import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery } from "@bobfrankston/mailx-types";
7
+ /** User-configured patterns from contacts.jsonc `denylistPatterns`. Compiled
8
+ * once on settings load; invalid regexes are skipped with a warning. */
9
+ export declare function setContactsDenyPatterns(patterns: string[]): void;
7
10
  export declare class MailxDB {
8
11
  private db;
9
12
  constructor(dbDir: string);
13
+ /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
14
+ * runs at startup). The user-facing message names the recovery command. */
15
+ private verifySchema;
16
+ /** One-shot: rewrite absolute body_path entries to relative-to-store
17
+ * paths. Idempotent; flag stored in kv. Caller passes a FileMessageStore
18
+ * whose `rewriteAbsoluteToRelative` does the actual mapping (mailx-store
19
+ * doesn't depend on file-store directly to avoid a cycle). */
20
+ runOneShotBodyPathMigration(rewriteFn: (rows: Array<{
21
+ id: number;
22
+ body_path: string;
23
+ }>, update: (id: number, p: string) => void) => number): void;
24
+ /** One-shot purge: when the in-DB filter rules tighten (new prefix or
25
+ * domain matchers), historical contacts harvested under the old rules
26
+ * remain in the table and keep being written back to contacts.jsonc on
27
+ * every cloud save. Bumping the version below re-runs the purge once
28
+ * per device. The kv flag (`contacts:purge_v`) records the last version
29
+ * that ran so we don't keep doing the work on every startup. */
30
+ private runOneShotJunkContactPurge;
31
+ /** Fetch a string from the kv table. Returns null when not set. */
32
+ getKv(scope: string, key: string): string | null;
33
+ /** Upsert a kv row. Pass `null` to delete. */
34
+ setKv(scope: string, key: string, value: string | null): void;
35
+ /** One-time: assign UUIDs to every `messages` row that's missing one.
36
+ * Runs on every startup but the WHERE clause makes it a no-op after the
37
+ * first pass. */
38
+ private backfillUuids;
39
+ /** Has this Message-ID already been sent? Used to prevent the outbox from
40
+ * re-sending the same raw file across crash/restart cycles. */
41
+ hasSentMessage(messageId: string): boolean;
42
+ /** Record a successfully sent message so future attempts are skipped. */
43
+ recordSent(messageId: string, accountId: string, subject: string, recipients: string[]): void;
44
+ /** Q49 heuristic: has the user ever sent a message to `recipientEmail`
45
+ * that had a non-empty Cc field? Used by compose to auto-expand the Cc
46
+ * input when replying to someone who customarily gets Cc'd with others.
47
+ * Query scans only Sent folders (special_use='sent') and matches the
48
+ * recipient's address inside `to_json` via LIKE. No special index — the
49
+ * Sent folder's row count is typically a few thousand at most; acceptable
50
+ * on the compose-open path. */
51
+ hasCcHistoryTo(recipientEmail: string): boolean;
52
+ /** Same shape as hasCcHistoryTo for the Bcc field. Bcc only appears in the
53
+ * user's own Sent copy, so it's still a reliable signal that this user
54
+ * habitually Bccs when writing to this recipient. */
55
+ hasBccHistoryTo(recipientEmail: string): boolean;
56
+ /** Mark a Message-ID as locally-deleted for an account. No-op if messageId
57
+ * is empty (e.g. provider stripped the header) — without a stable id we
58
+ * can't check against future sync results anyway. */
59
+ addTombstone(accountId: string, messageId: string, subject?: string): void;
60
+ /** Is this Message-ID tombstoned for this account? */
61
+ hasTombstone(accountId: string, messageId: string): boolean;
62
+ /** Remove a tombstone — used by "undelete" (Ctrl-Z) so a subsequent sync
63
+ * re-imports the message as normal. Also lets the user recover from a
64
+ * mistaken local delete. */
65
+ removeTombstone(accountId: string, messageId: string): void;
66
+ /** Age-out tombstones older than the given cutoff. Keeps the table from
67
+ * growing unboundedly. Default retention is 30 days; caller passes the
68
+ * actual cutoff in ms since epoch. */
69
+ pruneTombstones(olderThanMs: number): number;
70
+ upsertCalendarEvent(ev: {
71
+ uuid?: string;
72
+ accountId: string;
73
+ providerId?: string;
74
+ calendarId?: string;
75
+ title: string;
76
+ startMs: number;
77
+ endMs: number;
78
+ allDay?: boolean;
79
+ location?: string;
80
+ notes?: string;
81
+ etag?: string;
82
+ dirty?: boolean;
83
+ recurringEventId?: string;
84
+ htmlLink?: string;
85
+ }): string;
86
+ getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
87
+ /** Lookup by uuid only — used by patch/delete paths that don't have an
88
+ * accountId context. Returns the row even when it's soft-deleted. */
89
+ getCalendarEventByUuid(uuid: string): any | null;
90
+ getTaskByUuid(uuid: string): any | null;
91
+ getDirtyCalendarEvents(accountId: string): any[];
92
+ private calendarRowToObject;
93
+ /** Find a calendar event by its Google Calendar event id (provider_id).
94
+ * Global lookup — not window-scoped — so repeat pulls dedup cleanly. */
95
+ getCalendarEventByProviderId(accountId: string, providerId: string): any | null;
96
+ markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
97
+ deleteCalendarEventLocal(uuid: string): void;
98
+ purgeCalendarEvent(uuid: string): void;
99
+ upsertTask(t: {
100
+ uuid?: string;
101
+ accountId: string;
102
+ providerId?: string;
103
+ listId?: string;
104
+ title: string;
105
+ notes?: string;
106
+ dueMs?: number;
107
+ completedMs?: number;
108
+ etag?: string;
109
+ dirty?: boolean;
110
+ }): string;
111
+ getTasks(accountId: string, includeCompleted?: boolean): any[];
112
+ getDirtyTasks(accountId: string): any[];
113
+ private taskRowToObject;
114
+ markTaskClean(uuid: string, providerId: string, etag: string): void;
115
+ deleteTaskLocal(uuid: string): void;
116
+ purgeTask(uuid: string): void;
117
+ /** Local delete for the two-way cache drainer (symmetric with calendar/tasks). */
118
+ deleteContactLocal(email: string): void;
119
+ enqueueStoreSync(kind: string, op: string, accountId: string, targetUuid: string, payload: any): void;
120
+ getStoreSyncQueue(kind?: string, accountId?: string): any[];
121
+ completeStoreSync(id: number): void;
122
+ failStoreSync(id: number, error: string): void;
123
+ /** Idempotently add a column to a table if it's missing. */
124
+ private addColumnIfMissing;
125
+ /** Compute a thread id for an incoming message. Strategy:
126
+ * 1. If any ancestor (in_reply_to or references) is already present in
127
+ * messages with a thread_id, reuse it — this handles the case where
128
+ * replies arrive before / after the root.
129
+ * 2. Otherwise use the oldest ref (first entry in References), or
130
+ * in_reply_to, or the message's own messageId as the thread root. */
131
+ private computeThreadId;
132
+ /** Get all messages in a thread (across folders) for a given account. */
133
+ getThreadMessages(accountId: string, threadId: string): MessageEnvelope[];
10
134
  close(): void;
11
135
  upsertAccount(id: string, name: string, email: string, configJson: string): void;
12
136
  getAccounts(): {
@@ -15,10 +139,18 @@ export declare class MailxDB {
15
139
  email: string;
16
140
  lastSync: number;
17
141
  }[];
142
+ getAccountConfigs(): {
143
+ id: string;
144
+ name: string;
145
+ email: string;
146
+ configJson: string;
147
+ }[];
18
148
  updateLastSync(accountId: string, timestamp: number): void;
19
149
  upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
20
150
  getFolders(accountId: string): Folder[];
21
151
  deleteFolder(folderId: number): void;
152
+ markFolderRead(folderId: number): void;
153
+ deleteAllMessages(accountId: string, folderId: number): void;
22
154
  updateFolderCounts(folderId: number, total: number, unread: number): void;
23
155
  updateFolderSync(folderId: number, uidvalidity: number, highestModseq: string): void;
24
156
  getFolderSync(folderId: number): {
@@ -42,33 +174,179 @@ export declare class MailxDB {
42
174
  hasAttachments: boolean;
43
175
  preview: string;
44
176
  bodyPath: string;
177
+ providerId?: string;
45
178
  }): number;
46
179
  getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
47
- getMessageByUid(accountId: string, uid: number): MessageEnvelope;
180
+ /** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
181
+ getUnifiedInbox(page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
182
+ /** Map a `messages` row to a MessageEnvelope. Exposes `uuid` (stable local
183
+ * identity) and `bodyPath` (authoritative on-disk location) in addition
184
+ * to the server-binding metadata. */
185
+ private rowToEnvelope;
186
+ getMessageByUid(accountId: string, uid: number, folderId?: number): MessageEnvelope;
187
+ /** Look up a message by its stable local UUID. Returned envelope includes
188
+ * the current (folder_id, uid) — these may have changed since the UUID
189
+ * was minted (folder move or server UID renumber) but the UUID itself
190
+ * is stable. Use this as the identity in any long-lived reference
191
+ * (compose in-reply-to, dally, undo stacks). */
192
+ getMessageByUuid(uuid: string): MessageEnvelope;
48
193
  getMessageBodyPath(accountId: string, uid: number): string;
49
194
  updateMessageFlags(accountId: string, uid: number, flags: string[]): void;
195
+ updateMessageFolder(accountId: string, uid: number, targetFolderId: number): void;
196
+ updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
197
+ /** Get messages without cached bodies (for background prefetch) */
198
+ getMessagesWithoutBody(accountId: string, limit?: number): {
199
+ uid: number;
200
+ folderId: number;
201
+ }[];
50
202
  getHighestUid(accountId: string, folderId: number): number;
203
+ getOldestDate(accountId: string, folderId: number): number;
204
+ getMessageCount(accountId: string, folderId: number): number;
51
205
  /** Get all UIDs for a folder */
52
206
  getUidsForFolder(accountId: string, folderId: number): number[];
53
207
  /** Delete a message by account + UID */
54
208
  deleteMessage(accountId: string, uid: number): void;
209
+ /** Recalculate folder total/unread counts from actual messages */
210
+ recalcFolderCounts(folderId: number): void;
55
211
  /** Bulk insert within a transaction for sync performance */
56
212
  beginTransaction(): void;
57
213
  commitTransaction(): void;
58
214
  rollbackTransaction(): void;
59
215
  /** Record an address used in sent mail */
60
216
  recordSentAddress(name: string, email: string): void;
61
- /** Seed contacts from all message senders in the DB */
217
+ /** True if `email` (lowercased) appears in the active denylist. Cached
218
+ * in-memory; refreshed on contacts.jsonc reload via setContactsDenylist. */
219
+ private _denylist;
220
+ isAddressDenylisted(emailLower: string): boolean;
221
+ setContactsDenylist(emails: string[]): void;
222
+ /** Priority sender / domain index — populated from contacts.jsonc on reload.
223
+ * Used by the client to flag list rows. Source of truth is the JSONC; this
224
+ * is just a fast in-memory lookup. */
225
+ private _prioritySenders;
226
+ private _priorityDomains;
227
+ setPriorityIndex(senders: string[], domains: string[]): void;
228
+ getPriorityIndex(): {
229
+ senders: string[];
230
+ domains: string[];
231
+ };
232
+ isPrioritySender(addr: string): boolean;
233
+ /** Callback fired when local-DB contacts mutations happen (sends adding
234
+ * to discovered, corpus seeder finding new addresses). The service
235
+ * registers a debounced cloud flush here so the GDrive copy stays in
236
+ * sync. NOT fired from applyContactsConfig — that's the inbound path
237
+ * and would create a write loop. */
238
+ private _onContactsChanged?;
239
+ setOnContactsChanged(cb: () => void): void;
240
+ private notifyContactsChanged;
241
+ /** Seed `discovered`-tier contacts from every address that appears in
242
+ * any cached message — From / To / Cc / Bcc across all folders. One row
243
+ * per email; first non-empty name observed wins. Sent-folder rows skip
244
+ * the From (it's us). Junk addresses (noreply, mailer-daemon, *-bounces)
245
+ * and denylisted addresses are dropped at seed time so they never enter
246
+ * autocomplete.
247
+ *
248
+ * Discovered is a single tier; sub-distinctions like sent-vs-received
249
+ * collapse here because the user-facing UI shows them as one "discovered"
250
+ * source. Recency-weighted use_count differentiates within the tier. */
62
251
  seedContactsFromMessages(): number;
63
- /** Search contacts by name or email prefix */
252
+ /** Apply the contents of contacts.jsonc replaces all preferred-tier rows
253
+ * with the entries in `preferred[]`, merges `discovered[]` into the local
254
+ * cache, sets the in-memory denylist, and purges any discovered rows
255
+ * whose email is now denylisted. Preferred rows are *not* auto-purged on
256
+ * denylist hit — if the user explicitly added them they win that
257
+ * conflict; we just log a warning.
258
+ *
259
+ * Discovered rows from the file are MERGED with whatever the local
260
+ * message-corpus seeder has produced. Each device contributes its
261
+ * observed addresses; over time GDrive accumulates the union. */
262
+ applyContactsConfig(cfg: {
263
+ preferred?: {
264
+ name?: string;
265
+ email: string;
266
+ source?: string;
267
+ organization?: string;
268
+ org?: string;
269
+ priority?: boolean;
270
+ }[];
271
+ denylist?: string[];
272
+ denylistPatterns?: string[];
273
+ priorityDomains?: string[];
274
+ discovered?: {
275
+ name?: string;
276
+ email: string;
277
+ useCount?: number;
278
+ lastUsed?: number;
279
+ }[];
280
+ }): {
281
+ preferred: number;
282
+ discovered: number;
283
+ purged: number;
284
+ conflicts: string[];
285
+ };
286
+ /** Build the contacts.jsonc shape from current DB state — for round-trip
287
+ * to GDrive. Preferred-tier rows come from anything not in the reserved
288
+ * system sources; discovered comes from `source='discovered'` rows;
289
+ * denylist comes from the in-memory set (set by applyContactsConfig).
290
+ * Caller is responsible for actually writing the cloud copy. */
291
+ exportContactsConfig(): {
292
+ preferred: {
293
+ name: string;
294
+ email: string;
295
+ source: string;
296
+ organization?: string;
297
+ }[];
298
+ denylist: string[];
299
+ discovered: {
300
+ name: string;
301
+ email: string;
302
+ useCount: number;
303
+ lastUsed: number;
304
+ }[];
305
+ };
306
+ /** Search contacts by name or email prefix.
307
+ *
308
+ * Source-tier bonus is what makes the curated address book win against
309
+ * passive corpus harvest. Anything in `contacts.jsonc#preferred[]` (any
310
+ * source value other than the two reserved system sources) gets the
311
+ * highest tier — that's the user's explicit address book and overrides
312
+ * Google. Google sits in the middle (the auto-synced address book).
313
+ * `discovered` is the corpus-harvested floor.
314
+ *
315
+ * Multi-name-per-email is supported: the same email can carry distinct
316
+ * (source, name) rows — Bob's wife and Bob Smith both at bob@example.com
317
+ * surface as two rows, each typing-completable by their own name. */
64
318
  searchContacts(query: string, limit?: number): {
65
319
  name: string;
66
320
  email: string;
67
321
  source: string;
68
322
  useCount: number;
69
323
  }[];
324
+ /** List all contacts (address-book view) with pagination + optional filter. */
325
+ listContacts(query: string, page?: number, pageSize?: number): {
326
+ items: {
327
+ name: string;
328
+ email: string;
329
+ source: string;
330
+ googleId: string | null;
331
+ useCount: number;
332
+ lastUsed: number;
333
+ }[];
334
+ total: number;
335
+ page: number;
336
+ pageSize: number;
337
+ };
338
+ /** Update or insert a contact manually (from the address book UI). */
339
+ upsertContact(name: string, email: string): void;
340
+ /** Delete a contact by email (address book UI). */
341
+ deleteContact(email: string): void;
342
+ /** Delete contact rows by Google People resourceName. Used by the
343
+ * incremental People sync when a person comes back with `metadata.deleted = true`
344
+ * — the email may have already changed/disappeared, but the resourceName
345
+ * is stable. Removes all rows tied to that Google identity (a single
346
+ * contact can have multiple email addresses, each is its own row). */
347
+ deleteContactByGoogleId(googleId: string): number;
70
348
  /** Full-text search across all messages. Supports qualifiers: from:, to:, subject: */
71
- searchMessages(query: string, page?: number, pageSize?: number): PagedResult<MessageEnvelope>;
349
+ searchMessages(query: string, page?: number, pageSize?: number, accountId?: string, folderId?: number): PagedResult<MessageEnvelope>;
72
350
  /** Rebuild FTS index from existing messages */
73
351
  rebuildSearchIndex(): number;
74
352
  /** Queue a local action for later sync to IMAP */