read-email-mcp 1.0.0 → 2.0.0
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/LICENSE +21 -21
- package/README.md +153 -34
- package/dist/accounts-cli.d.ts +1 -0
- package/dist/accounts-cli.js +518 -0
- package/dist/accounts-cli.js.map +1 -0
- package/dist/config.d.ts +30 -2
- package/dist/config.js +219 -15
- package/dist/config.js.map +1 -1
- package/dist/imap-discovery.d.ts +52 -0
- package/dist/imap-discovery.js +207 -0
- package/dist/imap-discovery.js.map +1 -0
- package/dist/imap.d.ts +41 -5
- package/dist/imap.js +332 -64
- package/dist/imap.js.map +1 -1
- package/dist/index.js +28 -8
- package/dist/index.js.map +1 -1
- package/dist/providers.d.ts +12 -0
- package/dist/providers.js +122 -0
- package/dist/providers.js.map +1 -0
- package/dist/server.d.ts +3 -6
- package/dist/server.js +10 -8
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +31 -2
- package/dist/store.js +300 -153
- package/dist/store.js.map +1 -1
- package/dist/tools.d.ts +189 -174
- package/dist/tools.js +714 -60
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +44 -1
- package/docs/production-readiness.md +84 -70
- package/package.json +10 -9
- package/scripts/smoke-mcp.mjs +8 -1
package/dist/store.js
CHANGED
|
@@ -15,67 +15,67 @@ export class EmailStore {
|
|
|
15
15
|
this.connection.pragma("busy_timeout = 5000");
|
|
16
16
|
this.connection.pragma("temp_store = MEMORY");
|
|
17
17
|
this.connection.pragma("foreign_keys = ON");
|
|
18
|
-
this.connection.exec(`
|
|
19
|
-
CREATE TABLE IF NOT EXISTS mailboxes (
|
|
20
|
-
path TEXT PRIMARY KEY,
|
|
21
|
-
delimiter TEXT NOT NULL,
|
|
22
|
-
uid_validity TEXT,
|
|
23
|
-
highest_uid INTEGER NOT NULL DEFAULT 0,
|
|
24
|
-
selectable INTEGER NOT NULL DEFAULT 1,
|
|
25
|
-
last_synced_at TEXT
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
29
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
-
mailbox TEXT NOT NULL,
|
|
31
|
-
uid INTEGER NOT NULL,
|
|
32
|
-
uid_validity TEXT NOT NULL,
|
|
33
|
-
message_id TEXT,
|
|
34
|
-
in_reply_to TEXT,
|
|
35
|
-
refs_json TEXT NOT NULL,
|
|
36
|
-
subject TEXT,
|
|
37
|
-
date TEXT NOT NULL,
|
|
38
|
-
internal_date TEXT,
|
|
39
|
-
from_json TEXT NOT NULL,
|
|
40
|
-
to_json TEXT NOT NULL,
|
|
41
|
-
cc_json TEXT NOT NULL,
|
|
42
|
-
bcc_json TEXT NOT NULL,
|
|
43
|
-
flags_json TEXT NOT NULL,
|
|
44
|
-
size INTEGER,
|
|
45
|
-
has_attachments INTEGER NOT NULL DEFAULT 0,
|
|
46
|
-
attachment_count INTEGER NOT NULL DEFAULT 0,
|
|
47
|
-
preview TEXT,
|
|
48
|
-
text TEXT,
|
|
49
|
-
html TEXT,
|
|
50
|
-
headers_json TEXT NOT NULL,
|
|
51
|
-
attachments_json TEXT NOT NULL,
|
|
52
|
-
raw_path TEXT NOT NULL,
|
|
53
|
-
parse_error TEXT,
|
|
54
|
-
updated_at TEXT NOT NULL,
|
|
55
|
-
UNIQUE(mailbox, uid)
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date, id);
|
|
59
|
-
CREATE INDEX IF NOT EXISTS idx_messages_mailbox_uid ON messages(mailbox, uid);
|
|
60
|
-
CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id);
|
|
61
|
-
CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to);
|
|
62
|
-
|
|
63
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
64
|
-
subject,
|
|
65
|
-
addresses,
|
|
66
|
-
body,
|
|
67
|
-
preview
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
71
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
-
ok INTEGER NOT NULL,
|
|
73
|
-
mailbox TEXT,
|
|
74
|
-
started_at TEXT NOT NULL,
|
|
75
|
-
finished_at TEXT NOT NULL,
|
|
76
|
-
message TEXT NOT NULL,
|
|
77
|
-
synced_messages INTEGER NOT NULL
|
|
78
|
-
);
|
|
18
|
+
this.connection.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS mailboxes (
|
|
20
|
+
path TEXT PRIMARY KEY,
|
|
21
|
+
delimiter TEXT NOT NULL,
|
|
22
|
+
uid_validity TEXT,
|
|
23
|
+
highest_uid INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
selectable INTEGER NOT NULL DEFAULT 1,
|
|
25
|
+
last_synced_at TEXT
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
mailbox TEXT NOT NULL,
|
|
31
|
+
uid INTEGER NOT NULL,
|
|
32
|
+
uid_validity TEXT NOT NULL,
|
|
33
|
+
message_id TEXT,
|
|
34
|
+
in_reply_to TEXT,
|
|
35
|
+
refs_json TEXT NOT NULL,
|
|
36
|
+
subject TEXT,
|
|
37
|
+
date TEXT NOT NULL,
|
|
38
|
+
internal_date TEXT,
|
|
39
|
+
from_json TEXT NOT NULL,
|
|
40
|
+
to_json TEXT NOT NULL,
|
|
41
|
+
cc_json TEXT NOT NULL,
|
|
42
|
+
bcc_json TEXT NOT NULL,
|
|
43
|
+
flags_json TEXT NOT NULL,
|
|
44
|
+
size INTEGER,
|
|
45
|
+
has_attachments INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
attachment_count INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
preview TEXT,
|
|
48
|
+
text TEXT,
|
|
49
|
+
html TEXT,
|
|
50
|
+
headers_json TEXT NOT NULL,
|
|
51
|
+
attachments_json TEXT NOT NULL,
|
|
52
|
+
raw_path TEXT NOT NULL,
|
|
53
|
+
parse_error TEXT,
|
|
54
|
+
updated_at TEXT NOT NULL,
|
|
55
|
+
UNIQUE(mailbox, uid)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date, id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_messages_mailbox_uid ON messages(mailbox, uid);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_messages_in_reply_to ON messages(in_reply_to);
|
|
62
|
+
|
|
63
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
64
|
+
subject,
|
|
65
|
+
addresses,
|
|
66
|
+
body,
|
|
67
|
+
preview
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
ok INTEGER NOT NULL,
|
|
73
|
+
mailbox TEXT,
|
|
74
|
+
started_at TEXT NOT NULL,
|
|
75
|
+
finished_at TEXT NOT NULL,
|
|
76
|
+
message TEXT NOT NULL,
|
|
77
|
+
synced_messages INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
79
|
`);
|
|
80
80
|
this.connection.pragma("optimize=0x10002");
|
|
81
81
|
}
|
|
@@ -88,15 +88,15 @@ export class EmailStore {
|
|
|
88
88
|
}
|
|
89
89
|
upsertMailbox(mailbox) {
|
|
90
90
|
this.connection
|
|
91
|
-
.prepare(`
|
|
92
|
-
INSERT INTO mailboxes(path, delimiter, uid_validity, highest_uid, selectable, last_synced_at)
|
|
93
|
-
VALUES (@path, @delimiter, @uidValidity, @highestUid, @selectable, @lastSyncedAt)
|
|
94
|
-
ON CONFLICT(path) DO UPDATE SET
|
|
95
|
-
delimiter = excluded.delimiter,
|
|
96
|
-
uid_validity = excluded.uid_validity,
|
|
97
|
-
highest_uid = excluded.highest_uid,
|
|
98
|
-
selectable = excluded.selectable,
|
|
99
|
-
last_synced_at = excluded.last_synced_at
|
|
91
|
+
.prepare(`
|
|
92
|
+
INSERT INTO mailboxes(path, delimiter, uid_validity, highest_uid, selectable, last_synced_at)
|
|
93
|
+
VALUES (@path, @delimiter, @uidValidity, @highestUid, @selectable, @lastSyncedAt)
|
|
94
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
95
|
+
delimiter = excluded.delimiter,
|
|
96
|
+
uid_validity = excluded.uid_validity,
|
|
97
|
+
highest_uid = excluded.highest_uid,
|
|
98
|
+
selectable = excluded.selectable,
|
|
99
|
+
last_synced_at = excluded.last_synced_at
|
|
100
100
|
`)
|
|
101
101
|
.run({
|
|
102
102
|
path: mailbox.path,
|
|
@@ -109,19 +109,19 @@ export class EmailStore {
|
|
|
109
109
|
}
|
|
110
110
|
listMailboxes() {
|
|
111
111
|
const rows = this.connection
|
|
112
|
-
.prepare(`
|
|
113
|
-
SELECT
|
|
114
|
-
mailboxes.path,
|
|
115
|
-
mailboxes.delimiter,
|
|
116
|
-
mailboxes.uid_validity AS uidValidity,
|
|
117
|
-
mailboxes.highest_uid AS highestUid,
|
|
118
|
-
mailboxes.selectable,
|
|
119
|
-
mailboxes.last_synced_at AS lastSyncedAt,
|
|
120
|
-
COUNT(messages.id) AS messageCount
|
|
121
|
-
FROM mailboxes
|
|
122
|
-
LEFT JOIN messages ON messages.mailbox = mailboxes.path
|
|
123
|
-
GROUP BY mailboxes.path
|
|
124
|
-
ORDER BY mailboxes.path ASC
|
|
112
|
+
.prepare(`
|
|
113
|
+
SELECT
|
|
114
|
+
mailboxes.path,
|
|
115
|
+
mailboxes.delimiter,
|
|
116
|
+
mailboxes.uid_validity AS uidValidity,
|
|
117
|
+
mailboxes.highest_uid AS highestUid,
|
|
118
|
+
mailboxes.selectable,
|
|
119
|
+
mailboxes.last_synced_at AS lastSyncedAt,
|
|
120
|
+
COUNT(messages.id) AS messageCount
|
|
121
|
+
FROM mailboxes
|
|
122
|
+
LEFT JOIN messages ON messages.mailbox = mailboxes.path
|
|
123
|
+
GROUP BY mailboxes.path
|
|
124
|
+
ORDER BY mailboxes.path ASC
|
|
125
125
|
`)
|
|
126
126
|
.all();
|
|
127
127
|
return rows.map((row) => ({
|
|
@@ -136,11 +136,11 @@ export class EmailStore {
|
|
|
136
136
|
}
|
|
137
137
|
getMailbox(mailboxPath) {
|
|
138
138
|
const row = this.connection
|
|
139
|
-
.prepare(`
|
|
140
|
-
SELECT path, delimiter, uid_validity AS uidValidity, highest_uid AS highestUid,
|
|
141
|
-
selectable, last_synced_at AS lastSyncedAt
|
|
142
|
-
FROM mailboxes
|
|
143
|
-
WHERE path = ?
|
|
139
|
+
.prepare(`
|
|
140
|
+
SELECT path, delimiter, uid_validity AS uidValidity, highest_uid AS highestUid,
|
|
141
|
+
selectable, last_synced_at AS lastSyncedAt
|
|
142
|
+
FROM mailboxes
|
|
143
|
+
WHERE path = ?
|
|
144
144
|
`)
|
|
145
145
|
.get(mailboxPath);
|
|
146
146
|
if (!row) {
|
|
@@ -156,7 +156,9 @@ export class EmailStore {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
deleteMailboxMessages(mailboxPath) {
|
|
159
|
-
const rows = this.connection
|
|
159
|
+
const rows = this.connection
|
|
160
|
+
.prepare("SELECT id, raw_path AS rawPath FROM messages WHERE mailbox = ?")
|
|
161
|
+
.all(mailboxPath);
|
|
160
162
|
const transaction = this.connection.transaction(() => {
|
|
161
163
|
for (const row of rows) {
|
|
162
164
|
this.connection.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(row.id);
|
|
@@ -164,6 +166,32 @@ export class EmailStore {
|
|
|
164
166
|
this.connection.prepare("DELETE FROM messages WHERE mailbox = ?").run(mailboxPath);
|
|
165
167
|
});
|
|
166
168
|
transaction();
|
|
169
|
+
return rows.map((row) => row.rawPath);
|
|
170
|
+
}
|
|
171
|
+
updateMessageFlags(mailbox, uid, flags) {
|
|
172
|
+
const result = this.connection
|
|
173
|
+
.prepare("UPDATE messages SET flags_json = @flags, updated_at = @updatedAt WHERE mailbox = @mailbox AND uid = @uid")
|
|
174
|
+
.run({ flags: JSON.stringify(flags), updatedAt: new Date().toISOString(), mailbox, uid });
|
|
175
|
+
return result.changes > 0;
|
|
176
|
+
}
|
|
177
|
+
pruneMailboxMessagesExcept(mailboxPath, keepUids, maxUid) {
|
|
178
|
+
const rows = this.connection
|
|
179
|
+
.prepare("SELECT id, uid, raw_path AS rawPath FROM messages WHERE mailbox = ? AND uid <= ?")
|
|
180
|
+
.all(mailboxPath, maxUid);
|
|
181
|
+
const stale = rows.filter((row) => !keepUids.has(row.uid));
|
|
182
|
+
if (stale.length === 0) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const transaction = this.connection.transaction(() => {
|
|
186
|
+
const deleteFts = this.connection.prepare("DELETE FROM messages_fts WHERE rowid = ?");
|
|
187
|
+
const deleteMessage = this.connection.prepare("DELETE FROM messages WHERE id = ?");
|
|
188
|
+
for (const row of stale) {
|
|
189
|
+
deleteFts.run(row.id);
|
|
190
|
+
deleteMessage.run(row.id);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
transaction();
|
|
194
|
+
return stale.map((row) => row.rawPath);
|
|
167
195
|
}
|
|
168
196
|
upsertMessage(message) {
|
|
169
197
|
const transaction = this.connection.transaction((input) => {
|
|
@@ -174,51 +202,51 @@ export class EmailStore {
|
|
|
174
202
|
let id;
|
|
175
203
|
if (existing) {
|
|
176
204
|
this.connection
|
|
177
|
-
.prepare(`
|
|
178
|
-
UPDATE messages SET
|
|
179
|
-
uid_validity = @uidValidity,
|
|
180
|
-
message_id = @messageId,
|
|
181
|
-
in_reply_to = @inReplyTo,
|
|
182
|
-
refs_json = @refsJson,
|
|
183
|
-
subject = @subject,
|
|
184
|
-
date = @date,
|
|
185
|
-
internal_date = @internalDate,
|
|
186
|
-
from_json = @fromJson,
|
|
187
|
-
to_json = @toJson,
|
|
188
|
-
cc_json = @ccJson,
|
|
189
|
-
bcc_json = @bccJson,
|
|
190
|
-
flags_json = @flagsJson,
|
|
191
|
-
size = @size,
|
|
192
|
-
has_attachments = @hasAttachments,
|
|
193
|
-
attachment_count = @attachmentCount,
|
|
194
|
-
preview = @preview,
|
|
195
|
-
text = @text,
|
|
196
|
-
html = @html,
|
|
197
|
-
headers_json = @headersJson,
|
|
198
|
-
attachments_json = @attachmentsJson,
|
|
199
|
-
raw_path = @rawPath,
|
|
200
|
-
parse_error = @parseError,
|
|
201
|
-
updated_at = @updatedAt
|
|
202
|
-
WHERE id = @id
|
|
205
|
+
.prepare(`
|
|
206
|
+
UPDATE messages SET
|
|
207
|
+
uid_validity = @uidValidity,
|
|
208
|
+
message_id = @messageId,
|
|
209
|
+
in_reply_to = @inReplyTo,
|
|
210
|
+
refs_json = @refsJson,
|
|
211
|
+
subject = @subject,
|
|
212
|
+
date = @date,
|
|
213
|
+
internal_date = @internalDate,
|
|
214
|
+
from_json = @fromJson,
|
|
215
|
+
to_json = @toJson,
|
|
216
|
+
cc_json = @ccJson,
|
|
217
|
+
bcc_json = @bccJson,
|
|
218
|
+
flags_json = @flagsJson,
|
|
219
|
+
size = @size,
|
|
220
|
+
has_attachments = @hasAttachments,
|
|
221
|
+
attachment_count = @attachmentCount,
|
|
222
|
+
preview = @preview,
|
|
223
|
+
text = @text,
|
|
224
|
+
html = @html,
|
|
225
|
+
headers_json = @headersJson,
|
|
226
|
+
attachments_json = @attachmentsJson,
|
|
227
|
+
raw_path = @rawPath,
|
|
228
|
+
parse_error = @parseError,
|
|
229
|
+
updated_at = @updatedAt
|
|
230
|
+
WHERE id = @id
|
|
203
231
|
`)
|
|
204
232
|
.run({ ...payload, id: existing.id });
|
|
205
233
|
id = existing.id;
|
|
206
234
|
}
|
|
207
235
|
else {
|
|
208
236
|
const result = this.connection
|
|
209
|
-
.prepare(`
|
|
210
|
-
INSERT INTO messages(
|
|
211
|
-
mailbox, uid, uid_validity, message_id, in_reply_to, refs_json, subject, date,
|
|
212
|
-
internal_date, from_json, to_json, cc_json, bcc_json, flags_json, size,
|
|
213
|
-
has_attachments, attachment_count, preview, text, html, headers_json,
|
|
214
|
-
attachments_json, raw_path, parse_error, updated_at
|
|
215
|
-
)
|
|
216
|
-
VALUES (
|
|
217
|
-
@mailbox, @uid, @uidValidity, @messageId, @inReplyTo, @refsJson, @subject, @date,
|
|
218
|
-
@internalDate, @fromJson, @toJson, @ccJson, @bccJson, @flagsJson, @size,
|
|
219
|
-
@hasAttachments, @attachmentCount, @preview, @text, @html, @headersJson,
|
|
220
|
-
@attachmentsJson, @rawPath, @parseError, @updatedAt
|
|
221
|
-
)
|
|
237
|
+
.prepare(`
|
|
238
|
+
INSERT INTO messages(
|
|
239
|
+
mailbox, uid, uid_validity, message_id, in_reply_to, refs_json, subject, date,
|
|
240
|
+
internal_date, from_json, to_json, cc_json, bcc_json, flags_json, size,
|
|
241
|
+
has_attachments, attachment_count, preview, text, html, headers_json,
|
|
242
|
+
attachments_json, raw_path, parse_error, updated_at
|
|
243
|
+
)
|
|
244
|
+
VALUES (
|
|
245
|
+
@mailbox, @uid, @uidValidity, @messageId, @inReplyTo, @refsJson, @subject, @date,
|
|
246
|
+
@internalDate, @fromJson, @toJson, @ccJson, @bccJson, @flagsJson, @size,
|
|
247
|
+
@hasAttachments, @attachmentCount, @preview, @text, @html, @headersJson,
|
|
248
|
+
@attachmentsJson, @rawPath, @parseError, @updatedAt
|
|
249
|
+
)
|
|
222
250
|
`)
|
|
223
251
|
.run(payload);
|
|
224
252
|
id = Number(result.lastInsertRowid);
|
|
@@ -250,6 +278,16 @@ export class EmailStore {
|
|
|
250
278
|
.get(messageId);
|
|
251
279
|
return row ? rowToMessage(row) : null;
|
|
252
280
|
}
|
|
281
|
+
getMessagesByIds(ids) {
|
|
282
|
+
if (ids.length === 0) {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
286
|
+
const rows = this.connection
|
|
287
|
+
.prepare(`SELECT * FROM messages WHERE id IN (${placeholders}) ORDER BY date ASC, id ASC`)
|
|
288
|
+
.all(...ids);
|
|
289
|
+
return rows.map(rowToMessage);
|
|
290
|
+
}
|
|
253
291
|
getThread(messageIdOrInternalId) {
|
|
254
292
|
const root = typeof messageIdOrInternalId === "number"
|
|
255
293
|
? this.getMessageById(messageIdOrInternalId)
|
|
@@ -258,14 +296,48 @@ export class EmailStore {
|
|
|
258
296
|
return [];
|
|
259
297
|
}
|
|
260
298
|
const identifiers = new Set([root.messageId, root.inReplyTo, ...root.references].filter((value) => Boolean(value)));
|
|
299
|
+
const found = new Map([[root.id, root]]);
|
|
261
300
|
if (identifiers.size === 0) {
|
|
262
301
|
return [root];
|
|
263
302
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
303
|
+
for (let round = 0; round < 10 && identifiers.size <= 500; round += 1) {
|
|
304
|
+
const batch = this.findMessagesReferencing([...identifiers]);
|
|
305
|
+
const fresh = batch.filter((message) => !found.has(message.id));
|
|
306
|
+
if (fresh.length === 0) {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
for (const message of fresh) {
|
|
310
|
+
found.set(message.id, message);
|
|
311
|
+
for (const identifier of [message.messageId, message.inReplyTo, ...message.references]) {
|
|
312
|
+
if (identifier) {
|
|
313
|
+
identifiers.add(identifier);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return [...found.values()].sort((a, b) => (a.date === b.date ? a.id - b.id : a.date.localeCompare(b.date)));
|
|
319
|
+
}
|
|
320
|
+
findMessagesReferencing(identifiers) {
|
|
321
|
+
if (identifiers.length === 0) {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const rows = [];
|
|
325
|
+
for (let index = 0; index < identifiers.length; index += 200) {
|
|
326
|
+
const chunk = identifiers.slice(index, index + 200);
|
|
327
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
328
|
+
rows.push(...this.connection
|
|
329
|
+
.prepare(`
|
|
330
|
+
SELECT * FROM messages
|
|
331
|
+
WHERE message_id IN (${placeholders})
|
|
332
|
+
OR in_reply_to IN (${placeholders})
|
|
333
|
+
OR EXISTS (
|
|
334
|
+
SELECT 1 FROM json_each(messages.refs_json) ref
|
|
335
|
+
WHERE ref.value IN (${placeholders})
|
|
336
|
+
)
|
|
337
|
+
`)
|
|
338
|
+
.all(...chunk, ...chunk, ...chunk));
|
|
339
|
+
}
|
|
340
|
+
return rows.map(rowToMessage);
|
|
269
341
|
}
|
|
270
342
|
getDateBuckets(options) {
|
|
271
343
|
const params = {};
|
|
@@ -282,9 +354,9 @@ export class EmailStore {
|
|
|
282
354
|
}
|
|
283
355
|
recordSyncResult(result) {
|
|
284
356
|
this.connection
|
|
285
|
-
.prepare(`
|
|
286
|
-
INSERT INTO sync_runs(ok, mailbox, started_at, finished_at, message, synced_messages)
|
|
287
|
-
VALUES (@ok, @mailbox, @startedAt, @finishedAt, @message, @syncedMessages)
|
|
357
|
+
.prepare(`
|
|
358
|
+
INSERT INTO sync_runs(ok, mailbox, started_at, finished_at, message, synced_messages)
|
|
359
|
+
VALUES (@ok, @mailbox, @startedAt, @finishedAt, @message, @syncedMessages)
|
|
288
360
|
`)
|
|
289
361
|
.run({
|
|
290
362
|
ok: result.ok ? 1 : 0,
|
|
@@ -295,15 +367,79 @@ export class EmailStore {
|
|
|
295
367
|
syncedMessages: result.syncedMessages
|
|
296
368
|
});
|
|
297
369
|
}
|
|
370
|
+
getMessageCounts() {
|
|
371
|
+
const row = this.connection
|
|
372
|
+
.prepare(`
|
|
373
|
+
SELECT
|
|
374
|
+
COUNT(*) AS total,
|
|
375
|
+
SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Seen') THEN 1 ELSE 0 END) AS unread,
|
|
376
|
+
SUM(CASE WHEN EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Flagged') THEN 1 ELSE 0 END) AS flagged
|
|
377
|
+
FROM messages m
|
|
378
|
+
`)
|
|
379
|
+
.get();
|
|
380
|
+
return { total: Number(row.total), unread: Number(row.unread ?? 0), flagged: Number(row.flagged ?? 0) };
|
|
381
|
+
}
|
|
382
|
+
getTopSenders(options = {}) {
|
|
383
|
+
const limit = normalizeLimit(options.limit);
|
|
384
|
+
const unreadClause = options.unreadOnly
|
|
385
|
+
? "AND NOT EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Seen')"
|
|
386
|
+
: "";
|
|
387
|
+
const rows = this.connection
|
|
388
|
+
.prepare(`
|
|
389
|
+
SELECT
|
|
390
|
+
LOWER(json_extract(sender.value, '$.address')) AS address,
|
|
391
|
+
MAX(NULLIF(json_extract(sender.value, '$.name'), '')) AS name,
|
|
392
|
+
COUNT(*) AS count,
|
|
393
|
+
SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM json_each(m.flags_json) f WHERE f.value = '\\Seen') THEN 1 ELSE 0 END) AS unreadCount
|
|
394
|
+
FROM messages m, json_each(m.from_json) AS sender
|
|
395
|
+
WHERE json_extract(sender.value, '$.address') IS NOT NULL
|
|
396
|
+
${unreadClause}
|
|
397
|
+
GROUP BY address
|
|
398
|
+
ORDER BY count DESC, address ASC
|
|
399
|
+
LIMIT @limit
|
|
400
|
+
`)
|
|
401
|
+
.all({ limit });
|
|
402
|
+
return rows.map((row) => ({
|
|
403
|
+
address: String(row.address),
|
|
404
|
+
name: nullableString(row.name),
|
|
405
|
+
count: Number(row.count),
|
|
406
|
+
unreadCount: Number(row.unreadCount ?? 0)
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
searchRanked(filters, paging) {
|
|
410
|
+
const params = {
|
|
411
|
+
ftsQuery: toFtsQuery(filters.query ?? ""),
|
|
412
|
+
limit: paging.limit + 1,
|
|
413
|
+
offset: Math.max(Math.trunc(paging.offset), 0)
|
|
414
|
+
};
|
|
415
|
+
const where = this.filterClauses(filters, params);
|
|
416
|
+
where.push("messages_fts MATCH @ftsQuery");
|
|
417
|
+
const rows = this.connection
|
|
418
|
+
.prepare(`
|
|
419
|
+
SELECT m.*, bm25(messages_fts) AS rank
|
|
420
|
+
FROM messages m
|
|
421
|
+
JOIN messages_fts ON messages_fts.rowid = m.id
|
|
422
|
+
WHERE ${where.join(" AND ")}
|
|
423
|
+
ORDER BY rank ASC, m.id ASC
|
|
424
|
+
LIMIT @limit OFFSET @offset
|
|
425
|
+
`)
|
|
426
|
+
.all(params);
|
|
427
|
+
const hasMore = rows.length > paging.limit;
|
|
428
|
+
const pageRows = hasMore ? rows.slice(0, paging.limit) : rows;
|
|
429
|
+
return {
|
|
430
|
+
items: pageRows.map((row) => ({ ...messageToSummary(rowToMessage(row)), rank: Number(row.rank) })),
|
|
431
|
+
hasMore
|
|
432
|
+
};
|
|
433
|
+
}
|
|
298
434
|
getSyncStatus() {
|
|
299
435
|
const total = this.connection.prepare("SELECT COUNT(*) AS count FROM messages").get();
|
|
300
436
|
const latest = this.connection.prepare("SELECT MAX(date) AS latest FROM messages").get();
|
|
301
437
|
const last = this.connection
|
|
302
|
-
.prepare(`
|
|
303
|
-
SELECT ok, mailbox, started_at AS startedAt, finished_at AS finishedAt, message, synced_messages AS syncedMessages
|
|
304
|
-
FROM sync_runs
|
|
305
|
-
ORDER BY id DESC
|
|
306
|
-
LIMIT 1
|
|
438
|
+
.prepare(`
|
|
439
|
+
SELECT ok, mailbox, started_at AS startedAt, finished_at AS finishedAt, message, synced_messages AS syncedMessages
|
|
440
|
+
FROM sync_runs
|
|
441
|
+
ORDER BY id DESC
|
|
442
|
+
LIMIT 1
|
|
307
443
|
`)
|
|
308
444
|
.get();
|
|
309
445
|
return {
|
|
@@ -358,11 +494,11 @@ export class EmailStore {
|
|
|
358
494
|
}
|
|
359
495
|
const direction = sort === "date_asc" ? "ASC" : "DESC";
|
|
360
496
|
const rows = this.connection
|
|
361
|
-
.prepare(`
|
|
362
|
-
SELECT m.* FROM messages m
|
|
363
|
-
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
364
|
-
ORDER BY m.date ${direction}, m.id ${direction}
|
|
365
|
-
LIMIT @limit
|
|
497
|
+
.prepare(`
|
|
498
|
+
SELECT m.* FROM messages m
|
|
499
|
+
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
500
|
+
ORDER BY m.date ${direction}, m.id ${direction}
|
|
501
|
+
LIMIT @limit
|
|
366
502
|
`)
|
|
367
503
|
.all(params);
|
|
368
504
|
const hasMore = rows.length > limit;
|
|
@@ -379,6 +515,13 @@ export class EmailStore {
|
|
|
379
515
|
params.mailbox = filters.mailbox;
|
|
380
516
|
clauses.push(`${prefix}mailbox = @mailbox`);
|
|
381
517
|
}
|
|
518
|
+
if (filters.mailboxes?.length) {
|
|
519
|
+
const placeholders = filters.mailboxes.map((_, index) => `@mailboxes${index}`);
|
|
520
|
+
filters.mailboxes.forEach((mailbox, index) => {
|
|
521
|
+
params[`mailboxes${index}`] = mailbox;
|
|
522
|
+
});
|
|
523
|
+
clauses.push(`${prefix}mailbox IN (${placeholders.join(", ")})`);
|
|
524
|
+
}
|
|
382
525
|
if (filters.from) {
|
|
383
526
|
params.from = `%${filters.from.toLowerCase()}%`;
|
|
384
527
|
clauses.push(`LOWER(${prefix}from_json) LIKE @from`);
|
|
@@ -396,11 +539,11 @@ export class EmailStore {
|
|
|
396
539
|
clauses.push(`LOWER(${prefix}subject) LIKE @subject`);
|
|
397
540
|
}
|
|
398
541
|
if (filters.startDate) {
|
|
399
|
-
params.startDate = filters.startDate;
|
|
542
|
+
params.startDate = normalizeFilterDate(filters.startDate);
|
|
400
543
|
clauses.push(`${prefix}date >= @startDate`);
|
|
401
544
|
}
|
|
402
545
|
if (filters.endDate) {
|
|
403
|
-
params.endDate = filters.endDate;
|
|
546
|
+
params.endDate = normalizeFilterDate(filters.endDate);
|
|
404
547
|
clauses.push(`${prefix}date <= @endDate`);
|
|
405
548
|
}
|
|
406
549
|
if (filters.hasAttachments !== undefined) {
|
|
@@ -457,9 +600,9 @@ export class EmailStore {
|
|
|
457
600
|
].join(" ");
|
|
458
601
|
this.connection.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(id);
|
|
459
602
|
this.connection
|
|
460
|
-
.prepare(`
|
|
461
|
-
INSERT INTO messages_fts(rowid, subject, addresses, body, preview)
|
|
462
|
-
VALUES (?, ?, ?, ?, ?)
|
|
603
|
+
.prepare(`
|
|
604
|
+
INSERT INTO messages_fts(rowid, subject, addresses, body, preview)
|
|
605
|
+
VALUES (?, ?, ?, ?, ?)
|
|
463
606
|
`)
|
|
464
607
|
.run(id, message.subject ?? "", addresses, message.text ?? "", message.preview ?? "");
|
|
465
608
|
}
|
|
@@ -539,10 +682,10 @@ function normalizeLimit(limit) {
|
|
|
539
682
|
}
|
|
540
683
|
return Math.min(Math.max(Math.trunc(limit ?? 25), 1), 100);
|
|
541
684
|
}
|
|
542
|
-
function encodeCursor(message, sort) {
|
|
685
|
+
export function encodeCursor(message, sort) {
|
|
543
686
|
return Buffer.from(JSON.stringify({ date: message.date, id: message.id, sort }), "utf8").toString("base64url");
|
|
544
687
|
}
|
|
545
|
-
function decodeCursor(cursor) {
|
|
688
|
+
export function decodeCursor(cursor) {
|
|
546
689
|
if (!cursor) {
|
|
547
690
|
return null;
|
|
548
691
|
}
|
|
@@ -567,6 +710,10 @@ function fileSize(filePath) {
|
|
|
567
710
|
return 0;
|
|
568
711
|
}
|
|
569
712
|
}
|
|
713
|
+
function normalizeFilterDate(value) {
|
|
714
|
+
const parsed = new Date(value);
|
|
715
|
+
return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
|
|
716
|
+
}
|
|
570
717
|
function toFtsQuery(query) {
|
|
571
718
|
const terms = query
|
|
572
719
|
.trim()
|