@bobfrankston/mailx-store 0.1.2 → 0.1.5

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