|
@@ -1,8 +1,9 @@
|
|
|
-import { mkdirSync } from 'node:fs';
|
|
|
|
|
|
|
+import { chmodSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
import crypto from 'node:crypto';
|
|
import crypto from 'node:crypto';
|
|
|
import path from 'node:path';
|
|
import path from 'node:path';
|
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
|
import { dkimPublicFromPrivateKey } from './dkim.js';
|
|
import { dkimPublicFromPrivateKey } from './dkim.js';
|
|
|
|
|
+import { hashPassword, isLegacyPasswordHash, verifyPassword } from './password-hash.js';
|
|
|
import { decryptTrackingTarget, hashTrackingToken } from './tracking.js';
|
|
import { decryptTrackingTarget, hashTrackingToken } from './tracking.js';
|
|
|
import {
|
|
import {
|
|
|
MAX_WEBHOOK_ATTEMPTS,
|
|
MAX_WEBHOOK_ATTEMPTS,
|
|
@@ -31,8 +32,11 @@ const defaultApiTokenScopes = ['send'];
|
|
|
|
|
|
|
|
export function initDatabase(dataDir, secret = '') {
|
|
export function initDatabase(dataDir, secret = '') {
|
|
|
secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
|
|
secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
|
|
|
- mkdirSync(dataDir, { recursive: true });
|
|
|
|
|
- db = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
|
|
|
|
|
|
|
+ const databasePath = path.join(dataDir, 'mailhub.sqlite');
|
|
|
|
|
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
|
|
|
+ chmodSync(dataDir, 0o700);
|
|
|
|
|
+ db = new DatabaseSync(databasePath);
|
|
|
|
|
+ chmodSync(databasePath, 0o600);
|
|
|
db.function('normalize_failure_reason', { deterministic: true }, (detail, status) => (
|
|
db.function('normalize_failure_reason', { deterministic: true }, (detail, status) => (
|
|
|
String(detail || '').replace(/\s+/g, ' ').trim() || String(status || 'unknown failure')
|
|
String(detail || '').replace(/\s+/g, ' ').trim() || String(status || 'unknown failure')
|
|
|
));
|
|
));
|
|
@@ -192,6 +196,11 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
html_body TEXT NOT NULL DEFAULT '',
|
|
html_body TEXT NOT NULL DEFAULT '',
|
|
|
preview TEXT NOT NULL DEFAULT '',
|
|
preview TEXT NOT NULL DEFAULT '',
|
|
|
read_state TEXT NOT NULL DEFAULT 'false',
|
|
read_state TEXT NOT NULL DEFAULT 'false',
|
|
|
|
|
+ flags_json TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
+ keywords_json TEXT NOT NULL DEFAULT '[]',
|
|
|
|
|
+ import_source TEXT NOT NULL DEFAULT '',
|
|
|
|
|
+ import_source_key TEXT NOT NULL DEFAULT '',
|
|
|
|
|
+ pop3_size INTEGER NOT NULL DEFAULT 0,
|
|
|
received_at TEXT NOT NULL,
|
|
received_at TEXT NOT NULL,
|
|
|
created_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
|
updated_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
@@ -349,6 +358,12 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
|
|
ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
|
|
|
ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
|
|
ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
|
|
|
ensureColumn('inbound_messages', 'raw_message_bytes', 'BLOB');
|
|
ensureColumn('inbound_messages', 'raw_message_bytes', 'BLOB');
|
|
|
|
|
+ ensureColumn('inbound_messages', 'flags_json', "TEXT NOT NULL DEFAULT '[]'");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'keywords_json', "TEXT NOT NULL DEFAULT '[]'");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'import_source', "TEXT NOT NULL DEFAULT ''");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'import_source_key', "TEXT NOT NULL DEFAULT ''");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'pop3_size', 'INTEGER NOT NULL DEFAULT 0');
|
|
|
|
|
+ backfillInboundPop3Sizes();
|
|
|
ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
|
|
ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
|
|
|
migrateWebhookDeliveriesForInbound();
|
|
migrateWebhookDeliveriesForInbound();
|
|
|
ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
|
|
ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
|
|
@@ -371,6 +386,9 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_status ON api_tokens(user_id, revoked_at, expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_status ON api_tokens(user_id, revoked_at, expires_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
|
|
|
|
|
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_import_source_key
|
|
|
|
|
+ ON inbound_messages(import_source, import_source_key)
|
|
|
|
|
+ WHERE import_source != '' AND import_source_key != '';
|
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
|
|
CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
|
|
|
CREATE INDEX IF NOT EXISTS idx_webhooks_user_mailbox ON webhooks(user_id, mailbox_id);
|
|
CREATE INDEX IF NOT EXISTS idx_webhooks_user_mailbox ON webhooks(user_id, mailbox_id);
|
|
|
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_next ON webhook_deliveries(status, next_attempt_at);
|
|
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_next ON webhook_deliveries(status, next_attempt_at);
|
|
@@ -384,6 +402,7 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
`);
|
|
`);
|
|
|
normalizeSendEventQueueIds();
|
|
normalizeSendEventQueueIds();
|
|
|
normalizeDkimPublicKeys();
|
|
normalizeDkimPublicKeys();
|
|
|
|
|
+ secureDatabaseStorage(dataDir, databasePath);
|
|
|
return db;
|
|
return db;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -950,6 +969,80 @@ export function createInboundMailbox(userId, mailbox = {}) {
|
|
|
return getInboundMailbox(result.lastInsertRowid, userId);
|
|
return getInboundMailbox(result.lastInsertRowid, userId);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function upsertImportedInboundMailbox(userId, mailbox = {}) {
|
|
|
|
|
+ const address = normalizeInboundAddress(mailbox.address);
|
|
|
|
|
+ if (!address) throw new Error('导入邮箱格式不正确。');
|
|
|
|
|
+ const [localPart, domainName] = address.split('@');
|
|
|
|
|
+ const domain = getDomainByName(domainName, { userId });
|
|
|
|
|
+ if (!domain) throw new Error('导入邮箱所属域名不存在。');
|
|
|
|
|
+ const passwordHash = String(mailbox.passwordHash || '').trim();
|
|
|
|
|
+ if (passwordHash && !isLegacyPasswordHash(passwordHash)) {
|
|
|
|
|
+ throw new Error('导入邮箱使用了不支持的旧密码格式。');
|
|
|
|
|
+ }
|
|
|
|
|
+ const aliases = normalizeMailboxAliases(mailbox.aliases, domain.domain, localPart);
|
|
|
|
|
+ const forwardTo = normalizeRecipientList(mailbox.forwardTo);
|
|
|
|
|
+ const keepForwarded = boolString(mailbox.keepForwarded ?? true);
|
|
|
|
|
+ const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
|
|
|
|
|
+ const status = normalizeInboundMailboxStatus(mailbox.status || 'active');
|
|
|
|
|
+ const updatedAt = now();
|
|
|
|
|
+ const existing = requireDb()
|
|
|
|
|
+ .prepare('SELECT * FROM inbound_mailboxes WHERE address = ? LIMIT 1')
|
|
|
|
|
+ .get(address);
|
|
|
|
|
+
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ if (existing.user_id !== Number(userId) || existing.domain_id !== Number(domain.id) || existing.deleted_at) {
|
|
|
|
|
+ throw new Error('导入邮箱与现有资源冲突。');
|
|
|
|
|
+ }
|
|
|
|
|
+ const nextPasswordHash = !existing.password_hash || isLegacyPasswordHash(existing.password_hash)
|
|
|
|
|
+ ? (passwordHash || existing.password_hash)
|
|
|
|
|
+ : existing.password_hash;
|
|
|
|
|
+ requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE inbound_mailboxes
|
|
|
|
|
+ SET display_name = ?, password_hash = ?, aliases_json = ?, forward_to_json = ?,
|
|
|
|
|
+ keep_forwarded = ?, quota_mb = ?, status = ?, updated_at = ?
|
|
|
|
|
+ WHERE id = ? AND user_id = ? AND deleted_at IS NULL
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(
|
|
|
|
|
+ String(mailbox.displayName || '').trim(),
|
|
|
|
|
+ nextPasswordHash || '',
|
|
|
|
|
+ JSON.stringify(aliases),
|
|
|
|
|
+ JSON.stringify(forwardTo),
|
|
|
|
|
+ keepForwarded,
|
|
|
|
|
+ quotaMb,
|
|
|
|
|
+ status,
|
|
|
|
|
+ updatedAt,
|
|
|
|
|
+ existing.id,
|
|
|
|
|
+ userId
|
|
|
|
|
+ );
|
|
|
|
|
+ return getInboundMailbox(existing.id, userId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ INSERT INTO inbound_mailboxes (
|
|
|
|
|
+ user_id, domain_id, address, local_part, display_name, password_hash, password_secret,
|
|
|
|
|
+ aliases_json, forward_to_json, keep_forwarded, quota_mb, status, created_at, updated_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(
|
|
|
|
|
+ userId,
|
|
|
|
|
+ domain.id,
|
|
|
|
|
+ address,
|
|
|
|
|
+ localPart,
|
|
|
|
|
+ String(mailbox.displayName || '').trim(),
|
|
|
|
|
+ passwordHash,
|
|
|
|
|
+ JSON.stringify(aliases),
|
|
|
|
|
+ JSON.stringify(forwardTo),
|
|
|
|
|
+ keepForwarded,
|
|
|
|
|
+ quotaMb,
|
|
|
|
|
+ status,
|
|
|
|
|
+ updatedAt,
|
|
|
|
|
+ updatedAt
|
|
|
|
|
+ );
|
|
|
|
|
+ return getInboundMailbox(result.lastInsertRowid, userId);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function updateInboundMailbox(userId, id, patch = {}) {
|
|
export function updateInboundMailbox(userId, id, patch = {}) {
|
|
|
const current = getInboundMailbox(id, userId, { includeSecret: true });
|
|
const current = getInboundMailbox(id, userId, { includeSecret: true });
|
|
|
if (!current) return null;
|
|
if (!current) return null;
|
|
@@ -1086,6 +1179,20 @@ export function verifyInboundMailboxCredential(username, password) {
|
|
|
`)
|
|
`)
|
|
|
.get(mailboxAddress, now());
|
|
.get(mailboxAddress, now());
|
|
|
if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
|
|
if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
|
|
|
|
|
+ if (isLegacyPasswordHash(row.password_hash)) {
|
|
|
|
|
+ const upgradedAt = now();
|
|
|
|
|
+ requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE inbound_mailboxes
|
|
|
|
|
+ SET password_hash = ?, updated_at = ?
|
|
|
|
|
+ WHERE id = ? AND password_hash = ?
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(hashPassword(password), upgradedAt, row.id, row.password_hash);
|
|
|
|
|
+ row.password_hash = requireDb()
|
|
|
|
|
+ .prepare('SELECT password_hash FROM inbound_mailboxes WHERE id = ?')
|
|
|
|
|
+ .get(row.id)?.password_hash || row.password_hash;
|
|
|
|
|
+ row.updated_at = upgradedAt;
|
|
|
|
|
+ }
|
|
|
return {
|
|
return {
|
|
|
user: {
|
|
user: {
|
|
|
id: row.auth_user_id,
|
|
id: row.auth_user_id,
|
|
@@ -1158,8 +1265,9 @@ export function createInboundMessage(mailbox, message = {}) {
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
INSERT INTO inbound_messages (
|
|
INSERT INTO inbound_messages (
|
|
|
mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
|
|
mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
|
|
|
- raw_message, raw_message_bytes, text_body, html_body, preview, read_state, received_at, created_at, updated_at
|
|
|
|
|
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?)
|
|
|
|
|
|
|
+ raw_message, raw_message_bytes, text_body, html_body, preview, read_state, pop3_size,
|
|
|
|
|
+ received_at, created_at, updated_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?, ?)
|
|
|
`)
|
|
`)
|
|
|
.run(
|
|
.run(
|
|
|
mailbox.id,
|
|
mailbox.id,
|
|
@@ -1175,6 +1283,7 @@ export function createInboundMessage(mailbox, message = {}) {
|
|
|
textBody,
|
|
textBody,
|
|
|
htmlBody,
|
|
htmlBody,
|
|
|
inboundPreview(textBody || htmlToText(htmlBody)),
|
|
inboundPreview(textBody || htmlToText(htmlBody)),
|
|
|
|
|
+ canonicalPop3MessageSize(rawMessageBytes),
|
|
|
receivedAt,
|
|
receivedAt,
|
|
|
receivedAt,
|
|
receivedAt,
|
|
|
receivedAt
|
|
receivedAt
|
|
@@ -1182,6 +1291,84 @@ export function createInboundMessage(mailbox, message = {}) {
|
|
|
return getInboundMessage(mailbox.userId, result.lastInsertRowid);
|
|
return getInboundMessage(mailbox.userId, result.lastInsertRowid);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function hasImportedInboundMessage(importSource, sourceKey) {
|
|
|
|
|
+ const source = normalizeImportSource(importSource);
|
|
|
|
|
+ const key = normalizeImportSourceKey(sourceKey);
|
|
|
|
|
+ if (!source || !key) return false;
|
|
|
|
|
+ return Boolean(requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT 1
|
|
|
|
|
+ FROM inbound_messages
|
|
|
|
|
+ WHERE import_source = ? AND import_source_key = ?
|
|
|
|
|
+ LIMIT 1
|
|
|
|
|
+ `)
|
|
|
|
|
+ .get(source, key));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function createImportedInboundMessage(mailbox, message = {}) {
|
|
|
|
|
+ if (!mailbox?.id || !mailbox?.userId || !mailbox?.domainId) throw new Error('导入邮箱不存在。');
|
|
|
|
|
+ const importSource = normalizeImportSource(message.importSource);
|
|
|
|
|
+ const sourceKey = normalizeImportSourceKey(message.sourceKey);
|
|
|
|
|
+ if (!importSource || !sourceKey) throw new Error('导入邮件缺少稳定来源标识。');
|
|
|
|
|
+ const existing = requireDb()
|
|
|
|
|
+ .prepare('SELECT id FROM inbound_messages WHERE import_source = ? AND import_source_key = ? LIMIT 1')
|
|
|
|
|
+ .get(importSource, sourceKey);
|
|
|
|
|
+ if (existing) return { created: false, message: { id: Number(existing.id) } };
|
|
|
|
|
+
|
|
|
|
|
+ const receivedAt = normalizeImportedReceivedAt(message.receivedAt);
|
|
|
|
|
+ const folder = normalizeInboundFolder(message.folder) || 'INBOX';
|
|
|
|
|
+ const textBody = String(message.textBody || '');
|
|
|
|
|
+ const htmlBody = String(message.htmlBody || '');
|
|
|
|
|
+ const rawMessageBytes = Buffer.from(message.rawMessageBytes || Buffer.alloc(0));
|
|
|
|
|
+ const flags = normalizeImportedStringList(message.flags);
|
|
|
|
|
+ const keywords = normalizeImportedStringList(message.keywords);
|
|
|
|
|
+ const read = message.read === undefined
|
|
|
|
|
+ ? flags.some((flag) => flag.toLowerCase() === '\\seen')
|
|
|
|
|
+ : Boolean(message.read);
|
|
|
|
|
+ if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
|
|
|
|
|
+ const insertedAt = now();
|
|
|
|
|
+ const result = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ INSERT OR IGNORE INTO inbound_messages (
|
|
|
|
|
+ mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
|
|
|
|
|
+ raw_message, raw_message_bytes, text_body, html_body, preview, read_state,
|
|
|
|
|
+ flags_json, keywords_json, import_source, import_source_key,
|
|
|
|
|
+ pop3_size, received_at, created_at, updated_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(
|
|
|
|
|
+ mailbox.id,
|
|
|
|
|
+ mailbox.userId,
|
|
|
|
|
+ mailbox.domainId,
|
|
|
|
|
+ folder,
|
|
|
|
|
+ normalizeEmail(message.sender) || String(message.sender || '').trim(),
|
|
|
|
|
+ JSON.stringify(normalizeRecipientList(message.recipients)),
|
|
|
|
|
+ String(message.subject || '').trim() || '(no subject)',
|
|
|
|
|
+ String(message.messageId || '').trim(),
|
|
|
|
|
+ rawMessageBytes,
|
|
|
|
|
+ textBody,
|
|
|
|
|
+ htmlBody,
|
|
|
|
|
+ String(message.preview || '').trim() || inboundPreview(textBody || htmlToText(htmlBody)),
|
|
|
|
|
+ boolString(read),
|
|
|
|
|
+ JSON.stringify(flags),
|
|
|
|
|
+ JSON.stringify(keywords),
|
|
|
|
|
+ importSource,
|
|
|
|
|
+ sourceKey,
|
|
|
|
|
+ canonicalPop3MessageSize(rawMessageBytes),
|
|
|
|
|
+ receivedAt,
|
|
|
|
|
+ insertedAt,
|
|
|
|
|
+ insertedAt
|
|
|
|
|
+ );
|
|
|
|
|
+ if (result.changes) {
|
|
|
|
|
+ return { created: true, message: { id: Number(result.lastInsertRowid) } };
|
|
|
|
|
+ }
|
|
|
|
|
+ const concurrent = requireDb()
|
|
|
|
|
+ .prepare('SELECT id FROM inbound_messages WHERE import_source = ? AND import_source_key = ? LIMIT 1')
|
|
|
|
|
+ .get(importSource, sourceKey);
|
|
|
|
|
+ if (!concurrent) throw new Error('导入邮件写入失败。');
|
|
|
|
|
+ return { created: false, message: { id: Number(concurrent.id) } };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function isDataMigrationComplete(name) {
|
|
export function isDataMigrationComplete(name) {
|
|
|
return Boolean(requireDb()
|
|
return Boolean(requireDb()
|
|
|
.prepare('SELECT 1 FROM data_migrations WHERE name = ?')
|
|
.prepare('SELECT 1 FROM data_migrations WHERE name = ?')
|
|
@@ -1261,7 +1448,7 @@ export function listInboundMessages(userId, { mailboxId = null, folder = 'INBOX'
|
|
|
}
|
|
}
|
|
|
return requireDb()
|
|
return requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
- SELECT msg.*, m.address AS mailbox_address, d.domain
|
|
|
|
|
|
|
+ SELECT ${inboundMessageSummaryColumns('msg')}, m.address AS mailbox_address, d.domain
|
|
|
FROM inbound_messages msg
|
|
FROM inbound_messages msg
|
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
JOIN domains d ON d.id = msg.domain_id
|
|
JOIN domains d ON d.id = msg.domain_id
|
|
@@ -1314,7 +1501,7 @@ export function searchInboundMessages(userId, filters = {}, access = {}) {
|
|
|
.get(...params)?.total || 0);
|
|
.get(...params)?.total || 0);
|
|
|
const rows = requireDb()
|
|
const rows = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
- SELECT msg.*, m.address AS mailbox_address, d.domain
|
|
|
|
|
|
|
+ SELECT ${inboundMessageSummaryColumns('msg')}, m.address AS mailbox_address, d.domain
|
|
|
FROM inbound_messages msg
|
|
FROM inbound_messages msg
|
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
JOIN domains d ON d.id = msg.domain_id
|
|
JOIN domains d ON d.id = msg.domain_id
|
|
@@ -1421,7 +1608,7 @@ export function listInboundMailboxProtocolMessages(mailbox, { folder = 'INBOX' }
|
|
|
const selectedFolder = normalizeInboundFolder(folder) || 'INBOX';
|
|
const selectedFolder = normalizeInboundFolder(folder) || 'INBOX';
|
|
|
return requireDb()
|
|
return requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
- SELECT msg.*, m.address AS mailbox_address, d.domain
|
|
|
|
|
|
|
+ SELECT ${inboundMessageSummaryColumns('msg')}, m.address AS mailbox_address, d.domain
|
|
|
FROM inbound_messages msg
|
|
FROM inbound_messages msg
|
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
JOIN domains d ON d.id = msg.domain_id
|
|
JOIN domains d ON d.id = msg.domain_id
|
|
@@ -1429,14 +1616,38 @@ export function listInboundMailboxProtocolMessages(mailbox, { folder = 'INBOX' }
|
|
|
ORDER BY msg.id ASC
|
|
ORDER BY msg.id ASC
|
|
|
`)
|
|
`)
|
|
|
.all(Number(mailbox.id), mailbox.userId, selectedFolder)
|
|
.all(Number(mailbox.id), mailbox.userId, selectedFolder)
|
|
|
- .map((row) => publicInboundMessage(row, { includeBody: true, includeRawBytes: true }));
|
|
|
|
|
|
|
+ .map(publicInboundMessage);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function getInboundMailboxProtocolMessage(mailbox, messageId, { folder = 'INBOX' } = {}) {
|
|
|
|
|
+ if (!mailbox?.id || !mailbox?.userId) return null;
|
|
|
|
|
+ const selectedFolder = normalizeInboundFolder(folder) || 'INBOX';
|
|
|
|
|
+ const row = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT msg.*, m.address AS mailbox_address, d.domain
|
|
|
|
|
+ FROM inbound_messages msg
|
|
|
|
|
+ JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
|
|
+ JOIN domains d ON d.id = msg.domain_id
|
|
|
|
|
+ WHERE msg.id = ? AND msg.mailbox_id = ? AND msg.user_id = ?
|
|
|
|
|
+ AND msg.folder = ? AND msg.deleted_at IS NULL
|
|
|
|
|
+ LIMIT 1
|
|
|
|
|
+ `)
|
|
|
|
|
+ .get(Number(messageId), Number(mailbox.id), mailbox.userId, selectedFolder);
|
|
|
|
|
+ return publicInboundMessage(row, { includeRawBytes: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function markInboundMessageRead(userId, id, read = true) {
|
|
export function markInboundMessageRead(userId, id, read = true) {
|
|
|
|
|
+ const current = requireDb()
|
|
|
|
|
+ .prepare('SELECT flags_json FROM inbound_messages WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
+ .get(Number(id), userId);
|
|
|
|
|
+ if (!current) return null;
|
|
|
|
|
+ const flags = normalizeImportedStringList(safeJson(current.flags_json, []))
|
|
|
|
|
+ .filter((flag) => flag.toLowerCase() !== '\\seen');
|
|
|
|
|
+ if (read) flags.push('\\Seen');
|
|
|
const updatedAt = now();
|
|
const updatedAt = now();
|
|
|
const result = requireDb()
|
|
const result = requireDb()
|
|
|
- .prepare('UPDATE inbound_messages SET read_state = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
- .run(read ? 'true' : 'false', updatedAt, Number(id), userId);
|
|
|
|
|
|
|
+ .prepare('UPDATE inbound_messages SET read_state = ?, flags_json = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
|
|
|
|
+ .run(read ? 'true' : 'false', JSON.stringify(flags), updatedAt, Number(id), userId);
|
|
|
if (!result.changes) return null;
|
|
if (!result.changes) return null;
|
|
|
return getInboundMessage(userId, id);
|
|
return getInboundMessage(userId, id);
|
|
|
}
|
|
}
|
|
@@ -3774,6 +3985,37 @@ function ensureColumn(table, column, definition) {
|
|
|
if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
if (!columnExists(table, column)) requireDb().exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function backfillInboundPop3Sizes() {
|
|
|
|
|
+ if (!tableExists('inbound_messages') || !columnExists('inbound_messages', 'pop3_size')) return;
|
|
|
|
|
+ const database = requireDb();
|
|
|
|
|
+ const update = database.prepare('UPDATE inbound_messages SET pop3_size = ? WHERE id = ?');
|
|
|
|
|
+ database.exec('BEGIN IMMEDIATE');
|
|
|
|
|
+ try {
|
|
|
|
|
+ for (const row of database.prepare(`
|
|
|
|
|
+ SELECT id, COALESCE(raw_message_bytes, CAST(raw_message AS BLOB), X'') AS raw_message_bytes
|
|
|
|
|
+ FROM inbound_messages
|
|
|
|
|
+ WHERE pop3_size IS NULL OR pop3_size <= 0
|
|
|
|
|
+ `).iterate()) {
|
|
|
|
|
+ update.run(canonicalPop3MessageSize(row.raw_message_bytes), row.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ database.exec('COMMIT');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ database.exec('ROLLBACK');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore rollback errors when no transaction is open
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function secureDatabaseStorage(dataDir, databasePath) {
|
|
|
|
|
+ chmodSync(dataDir, 0o700);
|
|
|
|
|
+ for (const filename of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) {
|
|
|
|
|
+ if (existsSync(filename)) chmodSync(filename, 0o600);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function normalizeDkimPublicKeys() {
|
|
function normalizeDkimPublicKeys() {
|
|
|
if (!tableExists('domains') || !columnExists('domains', 'dkim_private') || !columnExists('domains', 'dkim_public')) return;
|
|
if (!tableExists('domains') || !columnExists('domains', 'dkim_private') || !columnExists('domains', 'dkim_public')) return;
|
|
|
const rows = requireDb().prepare('SELECT id, dkim_public, dkim_private FROM domains').all();
|
|
const rows = requireDb().prepare('SELECT id, dkim_public, dkim_private FROM domains').all();
|
|
@@ -3880,6 +4122,15 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
|
|
|
|
|
|
|
|
function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
|
|
function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
|
|
|
if (!row) return null;
|
|
if (!row) return null;
|
|
|
|
|
+ const rawMessageSize = row.raw_message_size === undefined
|
|
|
|
|
+ ? (row.raw_message_bytes
|
|
|
|
|
+ ? Number(row.raw_message_bytes.byteLength || row.raw_message_bytes.length || 0)
|
|
|
|
|
+ : Buffer.byteLength(String(row.raw_message || ''), 'utf8'))
|
|
|
|
|
+ : Number(row.raw_message_size || 0);
|
|
|
|
|
+ const storedPop3MessageSize = Number(row.pop3_size || 0);
|
|
|
|
|
+ const pop3MessageSize = storedPop3MessageSize > 0
|
|
|
|
|
+ ? storedPop3MessageSize
|
|
|
|
|
+ : canonicalPop3MessageSize(row.raw_message_bytes || row.raw_message || '');
|
|
|
return {
|
|
return {
|
|
|
id: row.id,
|
|
id: row.id,
|
|
|
mailboxId: row.mailbox_id,
|
|
mailboxId: row.mailbox_id,
|
|
@@ -3894,20 +4145,50 @@ function publicInboundMessage(row, { includeBody = false, includeRawBytes = fals
|
|
|
messageId: row.message_id,
|
|
messageId: row.message_id,
|
|
|
preview: row.preview,
|
|
preview: row.preview,
|
|
|
read: row.read_state === 'true',
|
|
read: row.read_state === 'true',
|
|
|
|
|
+ flags: safeJson(row.flags_json, []),
|
|
|
|
|
+ keywords: safeJson(row.keywords_json, []),
|
|
|
|
|
+ rawMessageSize,
|
|
|
|
|
+ pop3MessageSize,
|
|
|
receivedAt: row.received_at,
|
|
receivedAt: row.received_at,
|
|
|
createdAt: row.created_at,
|
|
createdAt: row.created_at,
|
|
|
updatedAt: row.updated_at,
|
|
updatedAt: row.updated_at,
|
|
|
...(includeBody ? {
|
|
...(includeBody ? {
|
|
|
- rawMessage: row.raw_message,
|
|
|
|
|
|
|
+ rawMessage: row.raw_message || (row.raw_message_bytes ? Buffer.from(row.raw_message_bytes).toString('utf8') : ''),
|
|
|
textBody: row.text_body,
|
|
textBody: row.text_body,
|
|
|
htmlBody: row.html_body
|
|
htmlBody: row.html_body
|
|
|
} : {}),
|
|
} : {}),
|
|
|
...(includeRawBytes ? {
|
|
...(includeRawBytes ? {
|
|
|
- rawMessageBytes: row.raw_message_bytes ? Buffer.from(row.raw_message_bytes) : Buffer.from(row.raw_message || '', 'utf8')
|
|
|
|
|
|
|
+ rawMessageBytes: row.raw_message_bytes
|
|
|
|
|
+ ? (Buffer.isBuffer(row.raw_message_bytes) ? row.raw_message_bytes : Buffer.from(row.raw_message_bytes))
|
|
|
|
|
+ : Buffer.from(row.raw_message || '', 'utf8')
|
|
|
} : {})
|
|
} : {})
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function inboundMessageSummaryColumns(alias) {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'id',
|
|
|
|
|
+ 'mailbox_id',
|
|
|
|
|
+ 'user_id',
|
|
|
|
|
+ 'domain_id',
|
|
|
|
|
+ 'folder',
|
|
|
|
|
+ 'sender',
|
|
|
|
|
+ 'recipients_json',
|
|
|
|
|
+ 'subject',
|
|
|
|
|
+ 'message_id',
|
|
|
|
|
+ 'preview',
|
|
|
|
|
+ 'read_state',
|
|
|
|
|
+ 'flags_json',
|
|
|
|
|
+ 'keywords_json',
|
|
|
|
|
+ 'pop3_size',
|
|
|
|
|
+ 'received_at',
|
|
|
|
|
+ 'created_at',
|
|
|
|
|
+ 'updated_at'
|
|
|
|
|
+ ].map((column) => `${alias}.${column}`).concat(
|
|
|
|
|
+ `COALESCE(length(${alias}.raw_message_bytes), length(CAST(${alias}.raw_message AS BLOB)), 0) AS raw_message_size`
|
|
|
|
|
+ ).join(', ');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
|
|
function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
|
|
|
if (!row) return null;
|
|
if (!row) return null;
|
|
|
const password = includePassword ? decryptSecret(row.password_secret) : '';
|
|
const password = includePassword ? decryptSecret(row.password_secret) : '';
|
|
@@ -4369,6 +4650,42 @@ function normalizeInboundFolder(value, fallback = '') {
|
|
|
.join('/');
|
|
.join('/');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function normalizeImportSource(value) {
|
|
|
|
|
+ const source = String(value || '').trim().toLowerCase();
|
|
|
|
|
+ if (!source || source.length > 120 || !/^[a-z0-9][a-z0-9._:@/-]*$/.test(source)) return '';
|
|
|
|
|
+ return source;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeImportSourceKey(value) {
|
|
|
|
|
+ const key = String(value || '').trim();
|
|
|
|
|
+ if (!key || key.length > 512 || /[\r\n\u0000]/.test(key)) return '';
|
|
|
|
|
+ return key;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeImportedReceivedAt(value) {
|
|
|
|
|
+ const timestamp = Date.parse(String(value || ''));
|
|
|
|
|
+ if (!Number.isFinite(timestamp)) throw new Error('导入邮件时间不正确。');
|
|
|
|
|
+ return new Date(timestamp).toISOString();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function normalizeImportedStringList(values) {
|
|
|
|
|
+ const list = Array.isArray(values) ? values : [values];
|
|
|
|
|
+ return [...new Set(list.map((value) => String(value || '').trim()).filter(Boolean))];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function canonicalPop3MessageSize(value) {
|
|
|
|
|
+ const bytes = Buffer.isBuffer(value)
|
|
|
|
|
+ ? value
|
|
|
|
|
+ : value instanceof Uint8Array
|
|
|
|
|
+ ? Buffer.from(value)
|
|
|
|
|
+ : Buffer.from(String(value || ''), 'utf8');
|
|
|
|
|
+ let size = bytes.length;
|
|
|
|
|
+ for (let index = 0; index < bytes.length; index += 1) {
|
|
|
|
|
+ if (bytes[index] === 0x0a && (index === 0 || bytes[index - 1] !== 0x0d)) size += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return bytes.at(-1) === 0x0a ? size : size + 2;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function inboundFolderSpecialUse(folder) {
|
|
function inboundFolderSpecialUse(folder) {
|
|
|
return {
|
|
return {
|
|
|
Sent: '\\Sent',
|
|
Sent: '\\Sent',
|
|
@@ -4849,19 +5166,6 @@ function tokenHash(token) {
|
|
|
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
|
|
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function hashPassword(password) {
|
|
|
|
|
- const salt = crypto.randomBytes(16).toString('hex');
|
|
|
|
|
- const hash = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
|
|
|
|
- return `scrypt$${salt}$${hash}`;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function verifyPassword(password, stored) {
|
|
|
|
|
- const [scheme, salt, hash] = String(stored || '').split('$');
|
|
|
|
|
- if (scheme !== 'scrypt' || !salt || !hash) return false;
|
|
|
|
|
- const actual = crypto.scryptSync(String(password), salt, 64).toString('hex');
|
|
|
|
|
- return safeEqual(actual, hash);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
function encryptSecret(value) {
|
|
function encryptSecret(value) {
|
|
|
if (!value) return '';
|
|
if (!value) return '';
|
|
|
const iv = crypto.randomBytes(12);
|
|
const iv = crypto.randomBytes(12);
|