@bobfrankston/mailx-store 0.1.3 → 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.
- package/db.d.ts +250 -4
- package/db.js +1392 -57
- package/file-store.d.ts +27 -13
- package/file-store.js +60 -39
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 */
|