nothumanallowed 13.5.113 → 13.5.115
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/package.json +4 -1
- package/src/commands/ui.mjs +409 -4
- package/src/constants.mjs +1 -1
- package/src/services/email-db.mjs +688 -0
- package/src/services/email-imap.mjs +421 -0
- package/src/services/email-smtp.mjs +135 -0
- package/src/services/llm.mjs +7 -1
- package/src/services/tool-executor.mjs +95 -1
- package/src/services/web-ui.mjs +701 -83
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* email-db.mjs — SQLite local email store (~/.nha/email.db)
|
|
3
|
+
*
|
|
4
|
+
* IMAP is READ-ONLY: this DB is the source of truth for all organization.
|
|
5
|
+
* Labels, read/unread state, stars — everything lives here.
|
|
6
|
+
* Nothing is ever written back to the IMAP server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import Database from 'better-sqlite3';
|
|
10
|
+
import { createHash, randomUUID } from 'crypto';
|
|
11
|
+
import { mkdirSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
|
|
15
|
+
const NHA_DIR = join(os.homedir(), '.nha');
|
|
16
|
+
const DB_PATH = join(NHA_DIR, 'email.db');
|
|
17
|
+
|
|
18
|
+
let _db = null;
|
|
19
|
+
|
|
20
|
+
export function getDb() {
|
|
21
|
+
if (_db) return _db;
|
|
22
|
+
mkdirSync(NHA_DIR, { recursive: true });
|
|
23
|
+
_db = new Database(DB_PATH);
|
|
24
|
+
_db.pragma('journal_mode = WAL');
|
|
25
|
+
_db.pragma('foreign_keys = ON');
|
|
26
|
+
_db.pragma('cache_size = -8000'); // 8MB
|
|
27
|
+
initSchema(_db);
|
|
28
|
+
return _db;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function initSchema(db) {
|
|
32
|
+
db.exec(`
|
|
33
|
+
-- ── ACCOUNTS ──────────────────────────────────────────────────────────
|
|
34
|
+
CREATE TABLE IF NOT EXISTS email_accounts (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
type TEXT NOT NULL DEFAULT 'imap', -- 'imap' | 'google'
|
|
37
|
+
email_address TEXT NOT NULL UNIQUE,
|
|
38
|
+
display_name TEXT NOT NULL,
|
|
39
|
+
from_name TEXT,
|
|
40
|
+
-- IMAP config
|
|
41
|
+
imap_host TEXT,
|
|
42
|
+
imap_port INTEGER DEFAULT 993,
|
|
43
|
+
smtp_host TEXT,
|
|
44
|
+
smtp_port INTEGER DEFAULT 587,
|
|
45
|
+
username TEXT,
|
|
46
|
+
encrypted_password TEXT, -- AES-256-GCM: iv:authTag:ciphertext (hex)
|
|
47
|
+
-- Sync state
|
|
48
|
+
sync_status TEXT NOT NULL DEFAULT 'idle',
|
|
49
|
+
last_sync_at TEXT,
|
|
50
|
+
last_sync_error TEXT,
|
|
51
|
+
-- Display
|
|
52
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
54
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
55
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- ── FOLDERS (IMAP sync metadata only) ─────────────────────────────────
|
|
59
|
+
CREATE TABLE IF NOT EXISTS email_folders (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
62
|
+
path TEXT NOT NULL,
|
|
63
|
+
name TEXT NOT NULL,
|
|
64
|
+
folder_type TEXT NOT NULL DEFAULT 'custom',
|
|
65
|
+
uid_validity INTEGER,
|
|
66
|
+
last_uid INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
68
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
69
|
+
UNIQUE(account_id, path)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
-- ── LABELS (Gmail-style tag model) ────────────────────────────────────
|
|
73
|
+
CREATE TABLE IF NOT EXISTS email_labels (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
76
|
+
name TEXT NOT NULL,
|
|
77
|
+
color TEXT, -- hex '#FF5733'
|
|
78
|
+
icon TEXT,
|
|
79
|
+
path TEXT, -- materialized: 'clienti/rossi'
|
|
80
|
+
parent_id TEXT,
|
|
81
|
+
system_type TEXT, -- 'inbox'|'sent'|'drafts'|'starred'|'spam'|'trash'|'archived'|NULL
|
|
82
|
+
is_system INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
84
|
+
unread_count INTEGER NOT NULL DEFAULT 0,
|
|
85
|
+
total_count INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
87
|
+
UNIQUE(account_id, name)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
-- ── MESSAGES ──────────────────────────────────────────────────────────
|
|
91
|
+
CREATE TABLE IF NOT EXISTS email_messages (
|
|
92
|
+
id TEXT PRIMARY KEY,
|
|
93
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
94
|
+
folder_id TEXT REFERENCES email_folders(id) ON DELETE SET NULL,
|
|
95
|
+
imap_folder_path TEXT NOT NULL,
|
|
96
|
+
uid INTEGER NOT NULL,
|
|
97
|
+
message_id TEXT,
|
|
98
|
+
in_reply_to TEXT,
|
|
99
|
+
references_list TEXT, -- JSON array
|
|
100
|
+
thread_id TEXT,
|
|
101
|
+
subject TEXT NOT NULL DEFAULT '',
|
|
102
|
+
from_address TEXT,
|
|
103
|
+
from_name TEXT,
|
|
104
|
+
to_addresses TEXT, -- JSON array [{address, name}]
|
|
105
|
+
cc_addresses TEXT, -- JSON array
|
|
106
|
+
bcc_addresses TEXT, -- JSON array
|
|
107
|
+
body_text TEXT,
|
|
108
|
+
body_html TEXT,
|
|
109
|
+
body_preview TEXT,
|
|
110
|
+
body_reply_only TEXT,
|
|
111
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
112
|
+
has_attachments INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
content_hash TEXT,
|
|
114
|
+
internal_date TEXT NOT NULL,
|
|
115
|
+
permanently_deleted INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
source TEXT NOT NULL DEFAULT 'imap', -- 'imap'|'sent'
|
|
117
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
118
|
+
UNIQUE(account_id, imap_folder_path, uid)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_messages_account ON email_messages(account_id, internal_date DESC);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON email_messages(thread_id);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_messages_message_id ON email_messages(message_id);
|
|
124
|
+
|
|
125
|
+
-- ── ATTACHMENTS ───────────────────────────────────────────────────────
|
|
126
|
+
CREATE TABLE IF NOT EXISTS email_attachments (
|
|
127
|
+
id TEXT PRIMARY KEY,
|
|
128
|
+
message_id TEXT NOT NULL REFERENCES email_messages(id) ON DELETE CASCADE,
|
|
129
|
+
filename TEXT,
|
|
130
|
+
content_type TEXT NOT NULL,
|
|
131
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
132
|
+
part_id TEXT,
|
|
133
|
+
content_id TEXT,
|
|
134
|
+
content_blob BLOB, -- cached for files < 5MB
|
|
135
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
-- ── MESSAGE ↔ LABEL JUNCTION ──────────────────────────────────────────
|
|
139
|
+
CREATE TABLE IF NOT EXISTS email_message_labels (
|
|
140
|
+
message_id TEXT NOT NULL REFERENCES email_messages(id) ON DELETE CASCADE,
|
|
141
|
+
label_id TEXT NOT NULL REFERENCES email_labels(id) ON DELETE CASCADE,
|
|
142
|
+
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
143
|
+
PRIMARY KEY (message_id, label_id)
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_msg_labels_label ON email_message_labels(label_id);
|
|
147
|
+
|
|
148
|
+
-- ── PER-MESSAGE USER STATE ────────────────────────────────────────────
|
|
149
|
+
CREATE TABLE IF NOT EXISTS email_message_state (
|
|
150
|
+
message_id TEXT NOT NULL REFERENCES email_messages(id) ON DELETE CASCADE,
|
|
151
|
+
is_read INTEGER NOT NULL DEFAULT 0,
|
|
152
|
+
is_starred INTEGER NOT NULL DEFAULT 0,
|
|
153
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
154
|
+
PRIMARY KEY (message_id)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
-- ── BLOCKED SENDERS ───────────────────────────────────────────────────
|
|
158
|
+
CREATE TABLE IF NOT EXISTS email_blocked_senders (
|
|
159
|
+
id TEXT PRIMARY KEY,
|
|
160
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
161
|
+
email TEXT NOT NULL,
|
|
162
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
163
|
+
UNIQUE(account_id, email)
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
-- ── ARCHIVING RULES ───────────────────────────────────────────────────
|
|
167
|
+
CREATE TABLE IF NOT EXISTS email_archiving_rules (
|
|
168
|
+
id TEXT PRIMARY KEY,
|
|
169
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
170
|
+
match_type TEXT NOT NULL DEFAULT 'sender', -- 'sender'|'domain'|'subject'
|
|
171
|
+
match_value TEXT NOT NULL,
|
|
172
|
+
target_label_id TEXT REFERENCES email_labels(id) ON DELETE SET NULL,
|
|
173
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
174
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
175
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
-- ── SIGNATURES ────────────────────────────────────────────────────────
|
|
179
|
+
CREATE TABLE IF NOT EXISTS email_signatures (
|
|
180
|
+
id TEXT PRIMARY KEY,
|
|
181
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
182
|
+
name TEXT NOT NULL,
|
|
183
|
+
content TEXT NOT NULL, -- HTML
|
|
184
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
185
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
-- ── DRAFTS ────────────────────────────────────────────────────────────
|
|
189
|
+
CREATE TABLE IF NOT EXISTS email_drafts (
|
|
190
|
+
id TEXT PRIMARY KEY,
|
|
191
|
+
account_id TEXT NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE,
|
|
192
|
+
reply_to_id TEXT REFERENCES email_messages(id) ON DELETE SET NULL,
|
|
193
|
+
reply_type TEXT, -- 'reply'|'reply_all'|'forward'
|
|
194
|
+
subject TEXT NOT NULL DEFAULT '',
|
|
195
|
+
to_addresses TEXT, -- JSON array
|
|
196
|
+
cc_addresses TEXT,
|
|
197
|
+
bcc_addresses TEXT,
|
|
198
|
+
body_html TEXT,
|
|
199
|
+
body_text TEXT,
|
|
200
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
201
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
202
|
+
);
|
|
203
|
+
`);
|
|
204
|
+
|
|
205
|
+
// Seed system labels for any accounts that don't have them yet
|
|
206
|
+
seedSystemLabels(db);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const SYSTEM_LABELS = [
|
|
210
|
+
{ system_type: 'inbox', name: 'Inbox', icon: 'inbox', color: null, sort_order: 0 },
|
|
211
|
+
{ system_type: 'sent', name: 'Sent', icon: 'send', color: null, sort_order: 1 },
|
|
212
|
+
{ system_type: 'drafts', name: 'Drafts', icon: 'file', color: null, sort_order: 2 },
|
|
213
|
+
{ system_type: 'starred', name: 'Starred', icon: 'star', color: '#F59E0B', sort_order: 3 },
|
|
214
|
+
{ system_type: 'archived', name: 'Archived', icon: 'archive',color: null, sort_order: 4 },
|
|
215
|
+
{ system_type: 'spam', name: 'Spam', icon: 'shield', color: '#F97316', sort_order: 5 },
|
|
216
|
+
{ system_type: 'trash', name: 'Trash', icon: 'trash', color: null, sort_order: 6 },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
function seedSystemLabels(db) {
|
|
220
|
+
const accounts = db.prepare('SELECT id FROM email_accounts').all();
|
|
221
|
+
const insert = db.prepare(`
|
|
222
|
+
INSERT OR IGNORE INTO email_labels (id, account_id, name, system_type, is_system, color, icon, sort_order, path)
|
|
223
|
+
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?)
|
|
224
|
+
`);
|
|
225
|
+
for (const acc of accounts) {
|
|
226
|
+
for (const lbl of SYSTEM_LABELS) {
|
|
227
|
+
insert.run(randomUUID(), acc.id, lbl.name, lbl.system_type, lbl.color, lbl.icon, lbl.sort_order, lbl.system_type);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── ENCRYPTION (AES-256-GCM for passwords stored at rest) ─────────────────
|
|
233
|
+
|
|
234
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
235
|
+
|
|
236
|
+
const ENC_KEY_HEX = process.env.NHA_EMAIL_ENC_KEY || null;
|
|
237
|
+
|
|
238
|
+
function deriveKey() {
|
|
239
|
+
if (ENC_KEY_HEX && ENC_KEY_HEX.length === 64) {
|
|
240
|
+
return Buffer.from(ENC_KEY_HEX, 'hex');
|
|
241
|
+
}
|
|
242
|
+
// Fallback: derive from machine-id (less secure but works offline)
|
|
243
|
+
const seed = `nha-email-${os.hostname()}-${os.userInfo().username}`;
|
|
244
|
+
return createHash('sha256').update(seed).digest();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function encryptPassword(plaintext) {
|
|
248
|
+
const key = deriveKey();
|
|
249
|
+
const iv = randomBytes(12);
|
|
250
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
251
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
252
|
+
const tag = cipher.getAuthTag();
|
|
253
|
+
return `${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function decryptPassword(encrypted) {
|
|
257
|
+
try {
|
|
258
|
+
const key = deriveKey();
|
|
259
|
+
const parts = encrypted.split(':');
|
|
260
|
+
if (parts.length !== 3) return null;
|
|
261
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
262
|
+
const tag = Buffer.from(parts[1], 'hex');
|
|
263
|
+
const enc = Buffer.from(parts[2], 'hex');
|
|
264
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
265
|
+
decipher.setAuthTag(tag);
|
|
266
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── ACCOUNT CRUD ───────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
export function listAccounts() {
|
|
275
|
+
const db = getDb();
|
|
276
|
+
return db.prepare('SELECT id, type, email_address, display_name, from_name, imap_host, imap_port, smtp_host, smtp_port, username, sync_status, last_sync_at, sort_order, is_active FROM email_accounts ORDER BY sort_order, created_at').all();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function getAccount(id) {
|
|
280
|
+
const db = getDb();
|
|
281
|
+
return db.prepare('SELECT * FROM email_accounts WHERE id = ?').get(id);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function getAccountCredentials(id) {
|
|
285
|
+
const db = getDb();
|
|
286
|
+
const acc = db.prepare('SELECT * FROM email_accounts WHERE id = ?').get(id);
|
|
287
|
+
if (!acc) return null;
|
|
288
|
+
const password = acc.encrypted_password ? decryptPassword(acc.encrypted_password) : null;
|
|
289
|
+
return { ...acc, password };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function createAccount(data) {
|
|
293
|
+
const db = getDb();
|
|
294
|
+
const id = randomUUID();
|
|
295
|
+
const encPwd = data.password ? encryptPassword(data.password) : null;
|
|
296
|
+
db.prepare(`
|
|
297
|
+
INSERT INTO email_accounts (id, type, email_address, display_name, from_name, imap_host, imap_port, smtp_host, smtp_port, username, encrypted_password, sort_order)
|
|
298
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
299
|
+
`).run(id, data.type || 'imap', data.email_address, data.display_name || data.email_address,
|
|
300
|
+
data.from_name || null, data.imap_host || null, data.imap_port || 993,
|
|
301
|
+
data.smtp_host || null, data.smtp_port || 587,
|
|
302
|
+
data.username || data.email_address, encPwd,
|
|
303
|
+
data.sort_order || 0);
|
|
304
|
+
// Seed system labels for new account
|
|
305
|
+
seedSystemLabels(db);
|
|
306
|
+
return id;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function updateAccount(id, data) {
|
|
310
|
+
const db = getDb();
|
|
311
|
+
const fields = [];
|
|
312
|
+
const vals = [];
|
|
313
|
+
if (data.display_name !== undefined) { fields.push('display_name = ?'); vals.push(data.display_name); }
|
|
314
|
+
if (data.from_name !== undefined) { fields.push('from_name = ?'); vals.push(data.from_name); }
|
|
315
|
+
if (data.imap_host !== undefined) { fields.push('imap_host = ?'); vals.push(data.imap_host); }
|
|
316
|
+
if (data.imap_port !== undefined) { fields.push('imap_port = ?'); vals.push(data.imap_port); }
|
|
317
|
+
if (data.smtp_host !== undefined) { fields.push('smtp_host = ?'); vals.push(data.smtp_host); }
|
|
318
|
+
if (data.smtp_port !== undefined) { fields.push('smtp_port = ?'); vals.push(data.smtp_port); }
|
|
319
|
+
if (data.username !== undefined) { fields.push('username = ?'); vals.push(data.username); }
|
|
320
|
+
if (data.password !== undefined) { fields.push('encrypted_password = ?'); vals.push(encryptPassword(data.password)); }
|
|
321
|
+
if (data.is_active !== undefined) { fields.push('is_active = ?'); vals.push(data.is_active ? 1 : 0); }
|
|
322
|
+
if (!fields.length) return;
|
|
323
|
+
fields.push("updated_at = datetime('now')");
|
|
324
|
+
vals.push(id);
|
|
325
|
+
db.prepare(`UPDATE email_accounts SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function deleteAccount(id) {
|
|
329
|
+
const db = getDb();
|
|
330
|
+
db.prepare('DELETE FROM email_accounts WHERE id = ?').run(id);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function setSyncStatus(accountId, status, error) {
|
|
334
|
+
const db = getDb();
|
|
335
|
+
db.prepare(`UPDATE email_accounts SET sync_status = ?, last_sync_at = datetime('now'), last_sync_error = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
336
|
+
.run(status, error || null, accountId);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── FOLDER CRUD ────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
export function upsertFolder(accountId, path, name, folderType, uidValidity, lastUid) {
|
|
342
|
+
const db = getDb();
|
|
343
|
+
const existing = db.prepare('SELECT id FROM email_folders WHERE account_id = ? AND path = ?').get(accountId, path);
|
|
344
|
+
if (existing) {
|
|
345
|
+
db.prepare(`UPDATE email_folders SET name = ?, folder_type = ?, uid_validity = ?, last_uid = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
346
|
+
.run(name, folderType, uidValidity, lastUid, existing.id);
|
|
347
|
+
return existing.id;
|
|
348
|
+
}
|
|
349
|
+
const id = randomUUID();
|
|
350
|
+
db.prepare(`INSERT INTO email_folders (id, account_id, path, name, folder_type, uid_validity, last_uid) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
351
|
+
.run(id, accountId, path, name, folderType, uidValidity, lastUid);
|
|
352
|
+
return id;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function getFolder(accountId, path) {
|
|
356
|
+
return getDb().prepare('SELECT * FROM email_folders WHERE account_id = ? AND path = ?').get(accountId, path);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function listFolders(accountId) {
|
|
360
|
+
return getDb().prepare('SELECT * FROM email_folders WHERE account_id = ? ORDER BY folder_type, path').all(accountId);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function updateFolderUid(folderId, uidValidity, lastUid) {
|
|
364
|
+
getDb().prepare(`UPDATE email_folders SET uid_validity = ?, last_uid = ?, updated_at = datetime('now') WHERE id = ?`)
|
|
365
|
+
.run(uidValidity, lastUid, folderId);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── LABEL CRUD ─────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
export function listLabels(accountId) {
|
|
371
|
+
return getDb().prepare('SELECT * FROM email_labels WHERE account_id = ? ORDER BY is_system DESC, sort_order, name').all(accountId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function getSystemLabel(accountId, systemType) {
|
|
375
|
+
return getDb().prepare('SELECT * FROM email_labels WHERE account_id = ? AND system_type = ?').get(accountId, systemType);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function createLabel(accountId, name, color, parentId) {
|
|
379
|
+
const db = getDb();
|
|
380
|
+
const id = randomUUID();
|
|
381
|
+
const parent = parentId ? db.prepare('SELECT path, name FROM email_labels WHERE id = ?').get(parentId) : null;
|
|
382
|
+
const path = parent ? `${parent.path || parent.name}/${name}` : name;
|
|
383
|
+
db.prepare(`INSERT INTO email_labels (id, account_id, name, color, parent_id, path, is_system, sort_order) VALUES (?, ?, ?, ?, ?, ?, 0, 99)`)
|
|
384
|
+
.run(id, accountId, name, color || null, parentId || null, path);
|
|
385
|
+
return id;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function updateLabel(id, data) {
|
|
389
|
+
const db = getDb();
|
|
390
|
+
const fields = [];
|
|
391
|
+
const vals = [];
|
|
392
|
+
if (data.name !== undefined) { fields.push('name = ?'); vals.push(data.name); }
|
|
393
|
+
if (data.color !== undefined) { fields.push('color = ?'); vals.push(data.color); }
|
|
394
|
+
if (!fields.length) return;
|
|
395
|
+
vals.push(id);
|
|
396
|
+
db.prepare(`UPDATE email_labels SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function deleteLabel(id) {
|
|
400
|
+
getDb().prepare('DELETE FROM email_labels WHERE id = ? AND is_system = 0').run(id);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function updateLabelCounts(db, labelId) {
|
|
404
|
+
db.prepare(`
|
|
405
|
+
UPDATE email_labels SET
|
|
406
|
+
total_count = (SELECT COUNT(*) FROM email_message_labels eml
|
|
407
|
+
JOIN email_messages m ON m.id = eml.message_id
|
|
408
|
+
WHERE eml.label_id = ? AND m.permanently_deleted = 0),
|
|
409
|
+
unread_count = (SELECT COUNT(*) FROM email_message_labels eml
|
|
410
|
+
JOIN email_messages m ON m.id = eml.message_id
|
|
411
|
+
LEFT JOIN email_message_state s ON s.message_id = m.id
|
|
412
|
+
WHERE eml.label_id = ? AND m.permanently_deleted = 0 AND (s.is_read IS NULL OR s.is_read = 0))
|
|
413
|
+
WHERE id = ?
|
|
414
|
+
`).run(labelId, labelId, labelId);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── MESSAGE CRUD ───────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
export function contentHash(messageId, fromAddress, dateStr) {
|
|
420
|
+
return createHash('sha256').update(`${messageId}|${fromAddress}|${dateStr}`).digest('hex');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function messageExists(accountId, folderPath, uid) {
|
|
424
|
+
return !!getDb().prepare('SELECT id FROM email_messages WHERE account_id = ? AND imap_folder_path = ? AND uid = ?').get(accountId, folderPath, uid);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function insertMessage(data) {
|
|
428
|
+
const db = getDb();
|
|
429
|
+
const id = data.id || randomUUID();
|
|
430
|
+
db.prepare(`
|
|
431
|
+
INSERT OR IGNORE INTO email_messages
|
|
432
|
+
(id, account_id, folder_id, imap_folder_path, uid, message_id, in_reply_to, references_list,
|
|
433
|
+
thread_id, subject, from_address, from_name, to_addresses, cc_addresses, bcc_addresses,
|
|
434
|
+
body_text, body_html, body_preview, body_reply_only, size_bytes, has_attachments,
|
|
435
|
+
content_hash, internal_date, source)
|
|
436
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
437
|
+
`).run(
|
|
438
|
+
id, data.account_id, data.folder_id || null, data.imap_folder_path, data.uid,
|
|
439
|
+
data.message_id || null, data.in_reply_to || null,
|
|
440
|
+
data.references_list ? JSON.stringify(data.references_list) : null,
|
|
441
|
+
data.thread_id || null, data.subject || '',
|
|
442
|
+
data.from_address || null, data.from_name || null,
|
|
443
|
+
data.to_addresses ? JSON.stringify(data.to_addresses) : null,
|
|
444
|
+
data.cc_addresses ? JSON.stringify(data.cc_addresses) : null,
|
|
445
|
+
data.bcc_addresses ? JSON.stringify(data.bcc_addresses) : null,
|
|
446
|
+
data.body_text || null, data.body_html || null, data.body_preview || null,
|
|
447
|
+
data.body_reply_only || null, data.size_bytes || 0,
|
|
448
|
+
data.has_attachments ? 1 : 0, data.content_hash || null,
|
|
449
|
+
data.internal_date, data.source || 'imap'
|
|
450
|
+
);
|
|
451
|
+
// Init read state (unread)
|
|
452
|
+
db.prepare('INSERT OR IGNORE INTO email_message_state (message_id, is_read, is_starred) VALUES (?, 0, 0)').run(id);
|
|
453
|
+
return id;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function insertAttachments(messageId, attachments) {
|
|
457
|
+
const db = getDb();
|
|
458
|
+
const insert = db.prepare(`
|
|
459
|
+
INSERT OR IGNORE INTO email_attachments (id, message_id, filename, content_type, size_bytes, part_id, content_id, content_blob)
|
|
460
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
461
|
+
`);
|
|
462
|
+
for (const att of attachments) {
|
|
463
|
+
insert.run(randomUUID(), messageId, att.filename || null, att.content_type || 'application/octet-stream',
|
|
464
|
+
att.size_bytes || 0, att.part_id || null, att.content_id || null,
|
|
465
|
+
att.content ? att.content : null);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function addMessageToLabel(messageId, labelId) {
|
|
470
|
+
const db = getDb();
|
|
471
|
+
db.prepare('INSERT OR IGNORE INTO email_message_labels (message_id, label_id) VALUES (?, ?)').run(messageId, labelId);
|
|
472
|
+
updateLabelCounts(db, labelId);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function removeMessageFromLabel(messageId, labelId) {
|
|
476
|
+
const db = getDb();
|
|
477
|
+
db.prepare('DELETE FROM email_message_labels WHERE message_id = ? AND label_id = ?').run(messageId, labelId);
|
|
478
|
+
updateLabelCounts(db, labelId);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function getMessageLabels(messageId) {
|
|
482
|
+
return getDb().prepare('SELECT l.* FROM email_labels l JOIN email_message_labels j ON j.label_id = l.id WHERE j.message_id = ?').all(messageId);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── MESSAGE QUERIES ────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
export function listMessages(accountId, labelId, limit, offset, search) {
|
|
488
|
+
const db = getDb();
|
|
489
|
+
let where = 'm.account_id = ? AND m.permanently_deleted = 0';
|
|
490
|
+
const params = [accountId];
|
|
491
|
+
|
|
492
|
+
if (labelId) {
|
|
493
|
+
where += ' AND EXISTS (SELECT 1 FROM email_message_labels j WHERE j.message_id = m.id AND j.label_id = ?)';
|
|
494
|
+
params.push(labelId);
|
|
495
|
+
}
|
|
496
|
+
if (search) {
|
|
497
|
+
where += ' AND (m.subject LIKE ? OR m.from_address LIKE ? OR m.from_name LIKE ? OR m.body_preview LIKE ?)';
|
|
498
|
+
const q = `%${search}%`;
|
|
499
|
+
params.push(q, q, q, q);
|
|
500
|
+
}
|
|
501
|
+
params.push(limit || 50, offset || 0);
|
|
502
|
+
|
|
503
|
+
const rows = db.prepare(`
|
|
504
|
+
SELECT m.id, m.subject, m.from_address, m.from_name, m.internal_date,
|
|
505
|
+
m.body_preview, m.has_attachments, m.thread_id,
|
|
506
|
+
COALESCE(s.is_read, 0) as is_read,
|
|
507
|
+
COALESCE(s.is_starred, 0) as is_starred
|
|
508
|
+
FROM email_messages m
|
|
509
|
+
LEFT JOIN email_message_state s ON s.message_id = m.id
|
|
510
|
+
WHERE ${where}
|
|
511
|
+
ORDER BY m.internal_date DESC
|
|
512
|
+
LIMIT ? OFFSET ?
|
|
513
|
+
`).all(...params);
|
|
514
|
+
|
|
515
|
+
const total = db.prepare(`SELECT COUNT(*) as cnt FROM email_messages m WHERE ${where.replace('LIMIT ? OFFSET ?', '')}`).get(...params.slice(0, -2));
|
|
516
|
+
|
|
517
|
+
return { messages: rows, total: total?.cnt || 0 };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function getMessage(id) {
|
|
521
|
+
const db = getDb();
|
|
522
|
+
const msg = db.prepare(`
|
|
523
|
+
SELECT m.*, COALESCE(s.is_read, 0) as is_read, COALESCE(s.is_starred, 0) as is_starred
|
|
524
|
+
FROM email_messages m
|
|
525
|
+
LEFT JOIN email_message_state s ON s.message_id = m.id
|
|
526
|
+
WHERE m.id = ? AND m.permanently_deleted = 0
|
|
527
|
+
`).get(id);
|
|
528
|
+
if (!msg) return null;
|
|
529
|
+
const attachments = db.prepare('SELECT id, filename, content_type, size_bytes, part_id, content_id FROM email_attachments WHERE message_id = ?').all(id);
|
|
530
|
+
const labels = getMessageLabels(id);
|
|
531
|
+
return { ...msg, attachments, labels };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function getThread(threadId, accountId) {
|
|
535
|
+
const db = getDb();
|
|
536
|
+
return db.prepare(`
|
|
537
|
+
SELECT m.*, COALESCE(s.is_read, 0) as is_read, COALESCE(s.is_starred, 0) as is_starred
|
|
538
|
+
FROM email_messages m
|
|
539
|
+
LEFT JOIN email_message_state s ON s.message_id = m.id
|
|
540
|
+
WHERE m.thread_id = ? AND m.account_id = ? AND m.permanently_deleted = 0
|
|
541
|
+
ORDER BY m.internal_date ASC
|
|
542
|
+
`).all(threadId, accountId);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── STATE MUTATIONS (local only — never touches IMAP) ─────────────────────
|
|
546
|
+
|
|
547
|
+
export function markRead(messageId, isRead) {
|
|
548
|
+
getDb().prepare(`INSERT INTO email_message_state (message_id, is_read, is_starred) VALUES (?, ?, 0)
|
|
549
|
+
ON CONFLICT(message_id) DO UPDATE SET is_read = excluded.is_read, updated_at = datetime('now')`)
|
|
550
|
+
.run(messageId, isRead ? 1 : 0);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export function markStarred(messageId, isStarred) {
|
|
554
|
+
getDb().prepare(`INSERT INTO email_message_state (message_id, is_read, is_starred) VALUES (?, 0, ?)
|
|
555
|
+
ON CONFLICT(message_id) DO UPDATE SET is_starred = excluded.is_starred, updated_at = datetime('now')`)
|
|
556
|
+
.run(messageId, isStarred ? 1 : 0);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function softDelete(messageId) {
|
|
560
|
+
// Moves to trash label, never deletes from IMAP
|
|
561
|
+
const db = getDb();
|
|
562
|
+
db.prepare("UPDATE email_messages SET permanently_deleted = 0 WHERE id = ?").run(messageId);
|
|
563
|
+
// Remove all non-trash labels
|
|
564
|
+
const msg = db.prepare('SELECT account_id FROM email_messages WHERE id = ?').get(messageId);
|
|
565
|
+
if (!msg) return;
|
|
566
|
+
const trash = getSystemLabel(msg.account_id, 'trash');
|
|
567
|
+
if (trash) {
|
|
568
|
+
db.prepare('DELETE FROM email_message_labels WHERE message_id = ?').run(messageId);
|
|
569
|
+
addMessageToLabel(messageId, trash.id);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function markAllRead(accountId, labelId) {
|
|
574
|
+
const db = getDb();
|
|
575
|
+
let msgIds;
|
|
576
|
+
if (labelId) {
|
|
577
|
+
msgIds = db.prepare(`SELECT m.id FROM email_messages m JOIN email_message_labels j ON j.message_id = m.id WHERE m.account_id = ? AND j.label_id = ? AND m.permanently_deleted = 0`).all(accountId, labelId).map(r => r.id);
|
|
578
|
+
} else {
|
|
579
|
+
msgIds = db.prepare('SELECT id FROM email_messages WHERE account_id = ? AND permanently_deleted = 0').all(accountId).map(r => r.id);
|
|
580
|
+
}
|
|
581
|
+
const stmt = db.prepare(`INSERT INTO email_message_state (message_id, is_read, is_starred) VALUES (?, 1, 0)
|
|
582
|
+
ON CONFLICT(message_id) DO UPDATE SET is_read = 1, updated_at = datetime('now')`);
|
|
583
|
+
const tx = db.transaction((ids) => {
|
|
584
|
+
for (const id of ids) stmt.run(id);
|
|
585
|
+
});
|
|
586
|
+
tx(msgIds);
|
|
587
|
+
return msgIds.length;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ── BLOCKED SENDERS ────────────────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
export function blockSender(accountId, email) {
|
|
593
|
+
const db = getDb();
|
|
594
|
+
const id = randomUUID();
|
|
595
|
+
db.prepare('INSERT OR IGNORE INTO email_blocked_senders (id, account_id, email) VALUES (?, ?, ?)').run(id, accountId, email.toLowerCase());
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function unblockSender(id) {
|
|
599
|
+
getDb().prepare('DELETE FROM email_blocked_senders WHERE id = ?').run(id);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function listBlockedSenders(accountId) {
|
|
603
|
+
return getDb().prepare('SELECT * FROM email_blocked_senders WHERE account_id = ? ORDER BY created_at DESC').all(accountId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function isSenderBlocked(accountId, email) {
|
|
607
|
+
return !!getDb().prepare('SELECT id FROM email_blocked_senders WHERE account_id = ? AND email = ?').get(accountId, email.toLowerCase());
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── ARCHIVING RULES ────────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
export function listArchivingRules(accountId) {
|
|
613
|
+
return getDb().prepare('SELECT r.*, l.name as label_name, l.color as label_color FROM email_archiving_rules r LEFT JOIN email_labels l ON l.id = r.target_label_id WHERE r.account_id = ? ORDER BY r.priority').all(accountId);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function createArchivingRule(accountId, matchType, matchValue, targetLabelId) {
|
|
617
|
+
const id = randomUUID();
|
|
618
|
+
getDb().prepare('INSERT INTO email_archiving_rules (id, account_id, match_type, match_value, target_label_id) VALUES (?, ?, ?, ?, ?)').run(id, accountId, matchType, matchValue, targetLabelId || null);
|
|
619
|
+
return id;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function deleteArchivingRule(id) {
|
|
623
|
+
getDb().prepare('DELETE FROM email_archiving_rules WHERE id = ?').run(id);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function applyArchivingRules(accountId, messageId, fromAddress, subject) {
|
|
627
|
+
const db = getDb();
|
|
628
|
+
const rules = db.prepare('SELECT * FROM email_archiving_rules WHERE account_id = ? AND is_active = 1 ORDER BY priority').all(accountId);
|
|
629
|
+
const inboxLabel = getSystemLabel(accountId, 'inbox');
|
|
630
|
+
for (const rule of rules) {
|
|
631
|
+
let match = false;
|
|
632
|
+
if (rule.match_type === 'sender') match = fromAddress?.toLowerCase() === rule.match_value.toLowerCase();
|
|
633
|
+
else if (rule.match_type === 'domain') match = fromAddress?.toLowerCase().endsWith('@' + rule.match_value.toLowerCase());
|
|
634
|
+
else if (rule.match_type === 'subject') match = subject?.toLowerCase().includes(rule.match_value.toLowerCase());
|
|
635
|
+
if (match && rule.target_label_id) {
|
|
636
|
+
// Remove inbox, add target (first rule wins)
|
|
637
|
+
if (inboxLabel) db.prepare('DELETE FROM email_message_labels WHERE message_id = ? AND label_id = ?').run(messageId, inboxLabel.id);
|
|
638
|
+
addMessageToLabel(messageId, rule.target_label_id);
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── SIGNATURES ─────────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
export function listSignatures(accountId) {
|
|
648
|
+
return getDb().prepare('SELECT * FROM email_signatures WHERE account_id = ? ORDER BY is_default DESC, created_at').all(accountId);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function createSignature(accountId, name, content, isDefault) {
|
|
652
|
+
const db = getDb();
|
|
653
|
+
const id = randomUUID();
|
|
654
|
+
if (isDefault) db.prepare('UPDATE email_signatures SET is_default = 0 WHERE account_id = ?').run(accountId);
|
|
655
|
+
db.prepare('INSERT INTO email_signatures (id, account_id, name, content, is_default) VALUES (?, ?, ?, ?, ?)').run(id, accountId, name, content, isDefault ? 1 : 0);
|
|
656
|
+
return id;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function deleteSignature(id) {
|
|
660
|
+
getDb().prepare('DELETE FROM email_signatures WHERE id = ?').run(id);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ── DRAFTS ─────────────────────────────────────────────────────────────────
|
|
664
|
+
|
|
665
|
+
export function saveDraft(accountId, data) {
|
|
666
|
+
const db = getDb();
|
|
667
|
+
const id = data.id || randomUUID();
|
|
668
|
+
if (data.id) {
|
|
669
|
+
db.prepare(`UPDATE email_drafts SET subject=?, to_addresses=?, cc_addresses=?, bcc_addresses=?, body_html=?, body_text=?, updated_at=datetime('now') WHERE id=?`)
|
|
670
|
+
.run(data.subject||'', JSON.stringify(data.to||[]), JSON.stringify(data.cc||[]), JSON.stringify(data.bcc||[]), data.body_html||'', data.body_text||'', id);
|
|
671
|
+
} else {
|
|
672
|
+
db.prepare(`INSERT INTO email_drafts (id, account_id, reply_to_id, reply_type, subject, to_addresses, cc_addresses, bcc_addresses, body_html, body_text) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
673
|
+
.run(id, accountId, data.reply_to_id||null, data.reply_type||null, data.subject||'', JSON.stringify(data.to||[]), JSON.stringify(data.cc||[]), JSON.stringify(data.bcc||[]), data.body_html||'', data.body_text||'');
|
|
674
|
+
}
|
|
675
|
+
return id;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function getDraft(id) {
|
|
679
|
+
return getDb().prepare('SELECT * FROM email_drafts WHERE id = ?').get(id);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function listDrafts(accountId) {
|
|
683
|
+
return getDb().prepare("SELECT * FROM email_drafts WHERE account_id = ? ORDER BY updated_at DESC").all(accountId);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function deleteDraft(id) {
|
|
687
|
+
getDb().prepare('DELETE FROM email_drafts WHERE id = ?').run(id);
|
|
688
|
+
}
|