|
@@ -3,7 +3,12 @@ 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 {
|
|
|
|
|
+ consumeDummyPasswordVerification,
|
|
|
|
|
+ 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,
|
|
@@ -201,6 +206,13 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
import_source TEXT NOT NULL DEFAULT '',
|
|
import_source TEXT NOT NULL DEFAULT '',
|
|
|
import_source_key TEXT NOT NULL DEFAULT '',
|
|
import_source_key TEXT NOT NULL DEFAULT '',
|
|
|
pop3_size INTEGER NOT NULL DEFAULT 0,
|
|
pop3_size INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ storage_backend TEXT NOT NULL DEFAULT 'sqlite',
|
|
|
|
|
+ storage_key TEXT NOT NULL DEFAULT '',
|
|
|
|
|
+ storage_relpath TEXT NOT NULL DEFAULT '',
|
|
|
|
|
+ storage_sha256 TEXT NOT NULL DEFAULT '',
|
|
|
|
|
+ storage_size INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ storage_mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ indexed_at TEXT,
|
|
|
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,
|
|
@@ -210,6 +222,17 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
|
FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS inbound_maildir_migration_staging (
|
|
|
|
|
+ message_id INTEGER PRIMARY KEY,
|
|
|
|
|
+ storage_key TEXT NOT NULL,
|
|
|
|
|
+ storage_relpath TEXT NOT NULL,
|
|
|
|
|
+ storage_sha256 TEXT NOT NULL DEFAULT '',
|
|
|
|
|
+ storage_size INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ storage_mtime_ms INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ indexed_at TEXT NOT NULL,
|
|
|
|
|
+ FOREIGN KEY(message_id) REFERENCES inbound_messages(id) ON DELETE CASCADE
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
CREATE TABLE IF NOT EXISTS inbound_folders (
|
|
CREATE TABLE IF NOT EXISTS inbound_folders (
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
mailbox_id INTEGER NOT NULL,
|
|
mailbox_id INTEGER NOT NULL,
|
|
@@ -363,6 +386,13 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
ensureColumn('inbound_messages', 'import_source', "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', 'import_source_key', "TEXT NOT NULL DEFAULT ''");
|
|
|
ensureColumn('inbound_messages', 'pop3_size', 'INTEGER NOT NULL DEFAULT 0');
|
|
ensureColumn('inbound_messages', 'pop3_size', 'INTEGER NOT NULL DEFAULT 0');
|
|
|
|
|
+ ensureColumn('inbound_messages', 'storage_backend', "TEXT NOT NULL DEFAULT 'sqlite'");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'storage_key', "TEXT NOT NULL DEFAULT ''");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'storage_relpath', "TEXT NOT NULL DEFAULT ''");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'storage_sha256', "TEXT NOT NULL DEFAULT ''");
|
|
|
|
|
+ ensureColumn('inbound_messages', 'storage_size', 'INTEGER NOT NULL DEFAULT 0');
|
|
|
|
|
+ ensureColumn('inbound_messages', 'storage_mtime_ms', 'INTEGER NOT NULL DEFAULT 0');
|
|
|
|
|
+ ensureColumn('inbound_messages', 'indexed_at', 'TEXT');
|
|
|
backfillInboundPop3Sizes();
|
|
backfillInboundPop3Sizes();
|
|
|
ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
|
|
ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
|
|
|
migrateWebhookDeliveriesForInbound();
|
|
migrateWebhookDeliveriesForInbound();
|
|
@@ -389,6 +419,9 @@ export function initDatabase(dataDir, secret = '') {
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_import_source_key
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_import_source_key
|
|
|
ON inbound_messages(import_source, import_source_key)
|
|
ON inbound_messages(import_source, import_source_key)
|
|
|
WHERE import_source != '' AND import_source_key != '';
|
|
WHERE import_source != '' AND import_source_key != '';
|
|
|
|
|
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_maildir_storage_key
|
|
|
|
|
+ ON inbound_messages(mailbox_id, storage_key)
|
|
|
|
|
+ WHERE storage_backend = 'maildir' AND storage_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);
|
|
@@ -1154,7 +1187,10 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
|
|
|
|
|
|
|
|
export function verifyInboundMailboxCredential(username, password) {
|
|
export function verifyInboundMailboxCredential(username, password) {
|
|
|
const mailboxAddress = normalizeInboundAddress(username);
|
|
const mailboxAddress = normalizeInboundAddress(username);
|
|
|
- if (!mailboxAddress) return null;
|
|
|
|
|
|
|
+ if (!mailboxAddress) {
|
|
|
|
|
+ consumeDummyPasswordVerification(password);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
const row = requireDb()
|
|
const row = requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
|
SELECT
|
|
SELECT
|
|
@@ -1178,8 +1214,18 @@ export function verifyInboundMailboxCredential(username, password) {
|
|
|
LIMIT 1
|
|
LIMIT 1
|
|
|
`)
|
|
`)
|
|
|
.get(mailboxAddress, now());
|
|
.get(mailboxAddress, now());
|
|
|
- if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
|
|
|
|
|
- if (isLegacyPasswordHash(row.password_hash)) {
|
|
|
|
|
|
|
+ if (!row?.password_hash || row.user_status !== 'active') {
|
|
|
|
|
+ consumeDummyPasswordVerification(password);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ const legacyPasswordHash = isLegacyPasswordHash(row.password_hash);
|
|
|
|
|
+ if (!verifyPassword(password, row.password_hash)) {
|
|
|
|
|
+ // MD5-CRYPT is much faster than the current scrypt format. Match the
|
|
|
|
|
+ // missing-account cost so failed logins cannot identify legacy mailboxes.
|
|
|
|
|
+ if (legacyPasswordHash) consumeDummyPasswordVerification(password);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (legacyPasswordHash) {
|
|
|
const upgradedAt = now();
|
|
const upgradedAt = now();
|
|
|
requireDb()
|
|
requireDb()
|
|
|
.prepare(`
|
|
.prepare(`
|
|
@@ -1260,14 +1306,17 @@ export function createInboundMessage(mailbox, message = {}) {
|
|
|
const rawMessageBytes = message.rawMessageBytes === undefined || message.rawMessageBytes === null
|
|
const rawMessageBytes = message.rawMessageBytes === undefined || message.rawMessageBytes === null
|
|
|
? Buffer.from(rawMessage, 'utf8')
|
|
? Buffer.from(rawMessage, 'utf8')
|
|
|
: Buffer.from(message.rawMessageBytes);
|
|
: Buffer.from(message.rawMessageBytes);
|
|
|
|
|
+ const storage = normalizeInboundStorage(message.storage, rawMessageBytes);
|
|
|
if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
|
|
if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
|
|
|
const result = requireDb()
|
|
const result = requireDb()
|
|
|
.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, pop3_size,
|
|
raw_message, raw_message_bytes, text_body, html_body, preview, read_state, pop3_size,
|
|
|
|
|
+ storage_backend, storage_key, storage_relpath, storage_sha256, storage_size,
|
|
|
|
|
+ storage_mtime_ms, indexed_at,
|
|
|
received_at, created_at, updated_at
|
|
received_at, created_at, updated_at
|
|
|
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?, ?)
|
|
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
`)
|
|
`)
|
|
|
.run(
|
|
.run(
|
|
|
mailbox.id,
|
|
mailbox.id,
|
|
@@ -1284,6 +1333,13 @@ export function createInboundMessage(mailbox, message = {}) {
|
|
|
htmlBody,
|
|
htmlBody,
|
|
|
inboundPreview(textBody || htmlToText(htmlBody)),
|
|
inboundPreview(textBody || htmlToText(htmlBody)),
|
|
|
canonicalPop3MessageSize(rawMessageBytes),
|
|
canonicalPop3MessageSize(rawMessageBytes),
|
|
|
|
|
+ storage.backend,
|
|
|
|
|
+ storage.key,
|
|
|
|
|
+ storage.relpath,
|
|
|
|
|
+ storage.sha256,
|
|
|
|
|
+ storage.size,
|
|
|
|
|
+ storage.mtimeMs,
|
|
|
|
|
+ storage.indexedAt,
|
|
|
receivedAt,
|
|
receivedAt,
|
|
|
receivedAt,
|
|
receivedAt,
|
|
|
receivedAt
|
|
receivedAt
|
|
@@ -1291,6 +1347,17 @@ export function createInboundMessage(mailbox, message = {}) {
|
|
|
return getInboundMessage(mailbox.userId, result.lastInsertRowid);
|
|
return getInboundMessage(mailbox.userId, result.lastInsertRowid);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function createInboundMessageWithWebhook(mailbox, message = {}) {
|
|
|
|
|
+ return withInboundWebhookTransaction(() => {
|
|
|
|
|
+ const inboundMessage = createInboundMessage(mailbox, message);
|
|
|
|
|
+ enqueueInboundWebhookDeliveries(inboundMessage, {
|
|
|
|
|
+ database: requireDb(),
|
|
|
|
|
+ manageTransactions: false
|
|
|
|
|
+ });
|
|
|
|
|
+ return inboundMessage;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export const importedInboundMessageLookupSql = `
|
|
export const importedInboundMessageLookupSql = `
|
|
|
SELECT id
|
|
SELECT id
|
|
|
FROM inbound_messages
|
|
FROM inbound_messages
|
|
@@ -1327,6 +1394,7 @@ export function createImportedInboundMessage(mailbox, message = {}) {
|
|
|
const textBody = String(message.textBody || '');
|
|
const textBody = String(message.textBody || '');
|
|
|
const htmlBody = String(message.htmlBody || '');
|
|
const htmlBody = String(message.htmlBody || '');
|
|
|
const rawMessageBytes = Buffer.from(message.rawMessageBytes || Buffer.alloc(0));
|
|
const rawMessageBytes = Buffer.from(message.rawMessageBytes || Buffer.alloc(0));
|
|
|
|
|
+ const storage = normalizeInboundStorage(message.storage, rawMessageBytes);
|
|
|
const flags = normalizeImportedStringList(message.flags);
|
|
const flags = normalizeImportedStringList(message.flags);
|
|
|
const keywords = normalizeImportedStringList(message.keywords);
|
|
const keywords = normalizeImportedStringList(message.keywords);
|
|
|
const read = message.read === undefined
|
|
const read = message.read === undefined
|
|
@@ -1340,8 +1408,10 @@ export function createImportedInboundMessage(mailbox, message = {}) {
|
|
|
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,
|
|
raw_message, raw_message_bytes, text_body, html_body, preview, read_state,
|
|
|
flags_json, keywords_json, import_source, import_source_key,
|
|
flags_json, keywords_json, import_source, import_source_key,
|
|
|
|
|
+ storage_backend, storage_key, storage_relpath, storage_sha256, storage_size,
|
|
|
|
|
+ storage_mtime_ms, indexed_at,
|
|
|
pop3_size, received_at, created_at, updated_at
|
|
pop3_size, received_at, created_at, updated_at
|
|
|
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
`)
|
|
`)
|
|
|
.run(
|
|
.run(
|
|
|
mailbox.id,
|
|
mailbox.id,
|
|
@@ -1361,6 +1431,13 @@ export function createImportedInboundMessage(mailbox, message = {}) {
|
|
|
JSON.stringify(keywords),
|
|
JSON.stringify(keywords),
|
|
|
importSource,
|
|
importSource,
|
|
|
sourceKey,
|
|
sourceKey,
|
|
|
|
|
+ storage.backend,
|
|
|
|
|
+ storage.key,
|
|
|
|
|
+ storage.relpath,
|
|
|
|
|
+ storage.sha256,
|
|
|
|
|
+ storage.size,
|
|
|
|
|
+ storage.mtimeMs,
|
|
|
|
|
+ storage.indexedAt,
|
|
|
canonicalPop3MessageSize(rawMessageBytes),
|
|
canonicalPop3MessageSize(rawMessageBytes),
|
|
|
receivedAt,
|
|
receivedAt,
|
|
|
insertedAt,
|
|
insertedAt,
|
|
@@ -1374,6 +1451,360 @@ export function createImportedInboundMessage(mailbox, message = {}) {
|
|
|
return { created: false, message: { id: Number(concurrent.id) } };
|
|
return { created: false, message: { id: Number(concurrent.id) } };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function createImportedInboundMessageWithWebhook(mailbox, message = {}) {
|
|
|
|
|
+ return withInboundWebhookTransaction(() => {
|
|
|
|
|
+ const result = createImportedInboundMessage(mailbox, message);
|
|
|
|
|
+ if (!result.created) return result;
|
|
|
|
|
+ const inboundMessage = getInboundMessage(mailbox.userId, result.message.id);
|
|
|
|
|
+ if (!inboundMessage) throw new Error('收信邮件索引写入失败。');
|
|
|
|
|
+ enqueueInboundWebhookDeliveries(inboundMessage, {
|
|
|
|
|
+ database: requireDb(),
|
|
|
|
|
+ manageTransactions: false
|
|
|
|
|
+ });
|
|
|
|
|
+ return result;
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function withInboundWebhookTransaction(operation) {
|
|
|
|
|
+ const database = requireDb();
|
|
|
|
|
+ database.exec('BEGIN IMMEDIATE');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = operation();
|
|
|
|
|
+ database.exec('COMMIT');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ database.exec('ROLLBACK');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore rollback errors when no transaction is open
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listInboundMaildirMigrationCandidates(afterId = 0, limit = 250, { mailboxId = 0 } = {}) {
|
|
|
|
|
+ const cleanAfterId = Math.max(0, Number(afterId) || 0);
|
|
|
|
|
+ const cleanLimit = Math.min(1000, Math.max(1, Number(limit) || 250));
|
|
|
|
|
+ const cleanMailboxId = Math.max(0, Number(mailboxId) || 0);
|
|
|
|
|
+ return requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ msg.id,
|
|
|
|
|
+ msg.mailbox_id,
|
|
|
|
|
+ msg.folder,
|
|
|
|
|
+ msg.raw_message,
|
|
|
|
|
+ msg.raw_message_bytes,
|
|
|
|
|
+ msg.flags_json,
|
|
|
|
|
+ msg.keywords_json,
|
|
|
|
|
+ msg.read_state,
|
|
|
|
|
+ msg.received_at,
|
|
|
|
|
+ msg.storage_backend,
|
|
|
|
|
+ msg.storage_key,
|
|
|
|
|
+ msg.storage_relpath,
|
|
|
|
|
+ m.address AS mailbox_address
|
|
|
|
|
+ FROM inbound_messages msg
|
|
|
|
|
+ JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
|
|
+ WHERE msg.id > ?
|
|
|
|
|
+ AND (? = 0 OR msg.mailbox_id = ?)
|
|
|
|
|
+ AND msg.deleted_at IS NULL
|
|
|
|
|
+ AND m.deleted_at IS NULL
|
|
|
|
|
+ AND (msg.storage_backend != 'maildir' OR msg.storage_key = '')
|
|
|
|
|
+ ORDER BY msg.id ASC
|
|
|
|
|
+ LIMIT ?
|
|
|
|
|
+ `)
|
|
|
|
|
+ .all(cleanAfterId, cleanMailboxId, cleanMailboxId, cleanLimit)
|
|
|
|
|
+ .map((row) => ({
|
|
|
|
|
+ id: Number(row.id),
|
|
|
|
|
+ mailboxId: Number(row.mailbox_id),
|
|
|
|
|
+ mailboxAddress: row.mailbox_address,
|
|
|
|
|
+ folder: row.folder || 'INBOX',
|
|
|
|
|
+ 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'),
|
|
|
|
|
+ flags: normalizeImportedStringList(safeJson(row.flags_json, [])),
|
|
|
|
|
+ keywords: normalizeImportedStringList(safeJson(row.keywords_json, [])),
|
|
|
|
|
+ read: row.read_state === 'true',
|
|
|
|
|
+ receivedAt: row.received_at,
|
|
|
|
|
+ storageBackend: row.storage_backend || 'sqlite',
|
|
|
|
|
+ storageKey: row.storage_key || '',
|
|
|
|
|
+ storageRelpath: row.storage_relpath || ''
|
|
|
|
|
+ }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function recordInboundMessageMaildirStorage(id, storage = {}) {
|
|
|
|
|
+ const normalized = normalizeInboundStorage({ ...storage, backend: 'maildir' });
|
|
|
|
|
+ const result = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE inbound_messages
|
|
|
|
|
+ SET storage_backend = 'maildir', storage_key = ?, storage_relpath = ?,
|
|
|
|
|
+ storage_sha256 = ?, storage_size = ?, storage_mtime_ms = ?, indexed_at = ?,
|
|
|
|
|
+ updated_at = ?
|
|
|
|
|
+ WHERE id = ? AND deleted_at IS NULL
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(
|
|
|
|
|
+ normalized.key,
|
|
|
|
|
+ normalized.relpath,
|
|
|
|
|
+ normalized.sha256,
|
|
|
|
|
+ normalized.size,
|
|
|
|
|
+ normalized.mtimeMs,
|
|
|
|
|
+ normalized.indexedAt,
|
|
|
|
|
+ now(),
|
|
|
|
|
+ Number(id)
|
|
|
|
|
+ );
|
|
|
|
|
+ return result.changes > 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function stageInboundMessageMaildirStorageBatch(records = []) {
|
|
|
|
|
+ const normalizedRecords = records.map((record) => ({
|
|
|
|
|
+ id: Number(record?.id),
|
|
|
|
|
+ storage: normalizeInboundStorage({ ...record?.storage, backend: 'maildir' })
|
|
|
|
|
+ }));
|
|
|
|
|
+ if (!normalizedRecords.length) return 0;
|
|
|
|
|
+ if (normalizedRecords.some((record) => !Number.isSafeInteger(record.id) || record.id <= 0)) {
|
|
|
|
|
+ throw new Error('Maildir 迁移邮件 ID 不正确。');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const database = requireDb();
|
|
|
|
|
+ const stage = database.prepare(`
|
|
|
|
|
+ INSERT INTO inbound_maildir_migration_staging (
|
|
|
|
|
+ message_id, storage_key, storage_relpath, storage_sha256,
|
|
|
|
|
+ storage_size, storage_mtime_ms, indexed_at
|
|
|
|
|
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
+ ON CONFLICT(message_id) DO UPDATE SET
|
|
|
|
|
+ storage_key = excluded.storage_key,
|
|
|
|
|
+ storage_relpath = excluded.storage_relpath,
|
|
|
|
|
+ storage_sha256 = excluded.storage_sha256,
|
|
|
|
|
+ storage_size = excluded.storage_size,
|
|
|
|
|
+ storage_mtime_ms = excluded.storage_mtime_ms,
|
|
|
|
|
+ indexed_at = excluded.indexed_at
|
|
|
|
|
+ `);
|
|
|
|
|
+ database.exec('BEGIN IMMEDIATE');
|
|
|
|
|
+ try {
|
|
|
|
|
+ for (const record of normalizedRecords) {
|
|
|
|
|
+ const storage = record.storage;
|
|
|
|
|
+ stage.run(
|
|
|
|
|
+ record.id,
|
|
|
|
|
+ storage.key,
|
|
|
|
|
+ storage.relpath,
|
|
|
|
|
+ storage.sha256,
|
|
|
|
|
+ storage.size,
|
|
|
|
|
+ storage.mtimeMs,
|
|
|
|
|
+ storage.indexedAt
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ database.exec('COMMIT');
|
|
|
|
|
+ return normalizedRecords.length;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ database.exec('ROLLBACK');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore rollback errors when no transaction is open
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listInboundMaildirMigrationStaging(afterMessageId = 0, limit = 250) {
|
|
|
|
|
+ const cleanAfterId = Math.max(0, Number(afterMessageId) || 0);
|
|
|
|
|
+ const cleanLimit = Math.min(1000, Math.max(1, Number(limit) || 250));
|
|
|
|
|
+ return requireDb().prepare(`
|
|
|
|
|
+ SELECT s.message_id, s.storage_key, s.storage_relpath, m.address AS mailbox_address
|
|
|
|
|
+ FROM inbound_maildir_migration_staging s
|
|
|
|
|
+ JOIN inbound_messages msg ON msg.id = s.message_id
|
|
|
|
|
+ JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
|
|
+ WHERE s.message_id > ?
|
|
|
|
|
+ ORDER BY s.message_id ASC
|
|
|
|
|
+ LIMIT ?
|
|
|
|
|
+ `).all(cleanAfterId, cleanLimit).map((row) => ({
|
|
|
|
|
+ messageId: Number(row.message_id),
|
|
|
|
|
+ storageKey: row.storage_key,
|
|
|
|
|
+ storageRelpath: row.storage_relpath,
|
|
|
|
|
+ mailboxAddress: row.mailbox_address
|
|
|
|
|
+ }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function clearInboundMaildirMigrationStaging() {
|
|
|
|
|
+ return Number(requireDb().prepare('DELETE FROM inbound_maildir_migration_staging').run().changes || 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function commitInboundMaildirMigrationStaging(expectedCount = null) {
|
|
|
|
|
+ const database = requireDb();
|
|
|
|
|
+ let expected = null;
|
|
|
|
|
+ if (expectedCount !== null) {
|
|
|
|
|
+ expected = Number(expectedCount);
|
|
|
|
|
+ if (!Number.isSafeInteger(expected) || expected < 0) {
|
|
|
|
|
+ throw new Error('Maildir 迁移预期数量不正确。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const update = database.prepare(`
|
|
|
|
|
+ UPDATE inbound_messages
|
|
|
|
|
+ SET storage_backend = 'maildir', storage_key = ?, storage_relpath = ?,
|
|
|
|
|
+ storage_sha256 = ?, storage_size = ?, storage_mtime_ms = ?, indexed_at = ?,
|
|
|
|
|
+ updated_at = ?
|
|
|
|
|
+ WHERE id = ? AND deleted_at IS NULL
|
|
|
|
|
+ AND (storage_backend != 'maildir' OR storage_key = '')
|
|
|
|
|
+ `);
|
|
|
|
|
+ const updatedAt = now();
|
|
|
|
|
+ database.exec('BEGIN IMMEDIATE');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const total = Number(database.prepare(`
|
|
|
|
|
+ SELECT COUNT(*) AS total FROM inbound_maildir_migration_staging
|
|
|
|
|
+ `).get()?.total || 0);
|
|
|
|
|
+ if (expected !== null && total !== expected) {
|
|
|
|
|
+ throw new Error(`Maildir 迁移暂存数量不一致:预期 ${expected},实际 ${total}。`);
|
|
|
|
|
+ }
|
|
|
|
|
+ let changed = 0;
|
|
|
|
|
+ for (const row of database.prepare(`
|
|
|
|
|
+ SELECT * FROM inbound_maildir_migration_staging ORDER BY message_id ASC
|
|
|
|
|
+ `).iterate()) {
|
|
|
|
|
+ const result = update.run(
|
|
|
|
|
+ row.storage_key,
|
|
|
|
|
+ row.storage_relpath,
|
|
|
|
|
+ row.storage_sha256,
|
|
|
|
|
+ row.storage_size,
|
|
|
|
|
+ row.storage_mtime_ms,
|
|
|
|
|
+ row.indexed_at,
|
|
|
|
|
+ updatedAt,
|
|
|
|
|
+ row.message_id
|
|
|
|
|
+ );
|
|
|
|
|
+ if (result.changes !== 1) {
|
|
|
|
|
+ throw new Error(`邮件 ${row.message_id} 的 Maildir 切换状态已发生变化。`);
|
|
|
|
|
+ }
|
|
|
|
|
+ changed += Number(result.changes);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (changed !== total) throw new Error('Maildir 批量切换未完整提交。');
|
|
|
|
|
+ database.prepare('DELETE FROM inbound_maildir_migration_staging').run();
|
|
|
|
|
+ database.exec('COMMIT');
|
|
|
|
|
+ return changed;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ database.exec('ROLLBACK');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore rollback errors when no transaction is open
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listInboundMailboxesForStorage() {
|
|
|
|
|
+ return requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT m.*, d.domain, 0 AS message_count, 0 AS unread_count, NULL AS last_message_at
|
|
|
|
|
+ FROM inbound_mailboxes m
|
|
|
|
|
+ JOIN domains d ON d.id = m.domain_id
|
|
|
|
|
+ JOIN users u ON u.id = m.user_id
|
|
|
|
|
+ WHERE m.deleted_at IS NULL AND m.status = 'active' AND u.status = 'active'
|
|
|
|
|
+ AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
|
|
|
|
|
+ ORDER BY m.id ASC
|
|
|
|
|
+ `)
|
|
|
|
|
+ .all(now())
|
|
|
|
|
+ .map((row) => publicInboundMailbox(row));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listInboundMaildirIndex(mailboxId) {
|
|
|
|
|
+ return requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT id, storage_key, storage_relpath, storage_size, storage_mtime_ms,
|
|
|
|
|
+ folder, flags_json, keywords_json, read_state, deleted_at
|
|
|
|
|
+ FROM inbound_messages
|
|
|
|
|
+ WHERE mailbox_id = ? AND storage_backend = 'maildir' AND storage_key != ''
|
|
|
|
|
+ ORDER BY id ASC
|
|
|
|
|
+ `)
|
|
|
|
|
+ .all(Number(mailboxId))
|
|
|
|
|
+ .map((row) => ({
|
|
|
|
|
+ id: Number(row.id),
|
|
|
|
|
+ storageKey: row.storage_key,
|
|
|
|
|
+ storageRelpath: row.storage_relpath || '',
|
|
|
|
|
+ storageSize: Number(row.storage_size || 0),
|
|
|
|
|
+ storageMtimeMs: Number(row.storage_mtime_ms || 0),
|
|
|
|
|
+ folder: row.folder || 'INBOX',
|
|
|
|
|
+ flags: normalizeImportedStringList(safeJson(row.flags_json, [])),
|
|
|
|
|
+ keywords: normalizeImportedStringList(safeJson(row.keywords_json, [])),
|
|
|
|
|
+ read: row.read_state === 'true',
|
|
|
|
|
+ deleted: Boolean(row.deleted_at)
|
|
|
|
|
+ }));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function syncInboundMessageMaildirMetadata(mailbox, storage = {}, message = {}) {
|
|
|
|
|
+ if (!mailbox?.id || !mailbox?.userId) throw new Error('收信邮箱不存在。');
|
|
|
|
|
+ const normalized = normalizeInboundStorage({ ...storage, backend: 'maildir' });
|
|
|
|
|
+ const folder = normalizeInboundFolder(message.folder) || 'INBOX';
|
|
|
|
|
+ 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 result = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE inbound_messages
|
|
|
|
|
+ SET folder = ?, read_state = ?, flags_json = ?, keywords_json = ?,
|
|
|
|
|
+ storage_relpath = ?,
|
|
|
|
|
+ storage_sha256 = CASE WHEN ? != '' THEN ? ELSE storage_sha256 END,
|
|
|
|
|
+ storage_size = ?, storage_mtime_ms = ?,
|
|
|
|
|
+ indexed_at = ?, deleted_at = NULL, updated_at = ?
|
|
|
|
|
+ WHERE mailbox_id = ? AND storage_backend = 'maildir' AND storage_key = ?
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(
|
|
|
|
|
+ folder,
|
|
|
|
|
+ boolString(read),
|
|
|
|
|
+ JSON.stringify(flags),
|
|
|
|
|
+ JSON.stringify(keywords),
|
|
|
|
|
+ normalized.relpath,
|
|
|
|
|
+ normalized.sha256,
|
|
|
|
|
+ normalized.sha256,
|
|
|
|
|
+ normalized.size,
|
|
|
|
|
+ normalized.mtimeMs,
|
|
|
|
|
+ normalized.indexedAt,
|
|
|
|
|
+ now(),
|
|
|
|
|
+ Number(mailbox.id),
|
|
|
|
|
+ normalized.key
|
|
|
|
|
+ );
|
|
|
|
|
+ return result.changes > 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function markMissingInboundMaildirMessages(mailboxId, presentStorageKeys) {
|
|
|
|
|
+ const present = presentStorageKeys instanceof Set
|
|
|
|
|
+ ? presentStorageKeys
|
|
|
|
|
+ : new Set(Array.isArray(presentStorageKeys) ? presentStorageKeys : []);
|
|
|
|
|
+ const missing = listInboundMaildirIndex(mailboxId)
|
|
|
|
|
+ .filter((message) => !message.deleted && !present.has(message.storageKey))
|
|
|
|
|
+ .map((message) => message.id);
|
|
|
|
|
+ if (!missing.length) return 0;
|
|
|
|
|
+ const placeholders = missing.map(() => '?').join(', ');
|
|
|
|
|
+ const updatedAt = now();
|
|
|
|
|
+ return Number(requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ UPDATE inbound_messages
|
|
|
|
|
+ SET deleted_at = ?, indexed_at = ?, updated_at = ?
|
|
|
|
|
+ WHERE mailbox_id = ? AND storage_backend = 'maildir' AND id IN (${placeholders})
|
|
|
|
|
+ `)
|
|
|
|
|
+ .run(updatedAt, updatedAt, updatedAt, Number(mailboxId), ...missing).changes || 0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function getInboundMessageMaildirStorage(userId, id) {
|
|
|
|
|
+ const row = requireDb()
|
|
|
|
|
+ .prepare(`
|
|
|
|
|
+ SELECT msg.id, msg.storage_backend, msg.storage_key, msg.storage_relpath,
|
|
|
|
|
+ msg.flags_json, msg.read_state, m.address AS mailbox_address
|
|
|
|
|
+ FROM inbound_messages msg
|
|
|
|
|
+ JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
|
|
|
|
|
+ WHERE msg.id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
|
|
|
|
|
+ LIMIT 1
|
|
|
|
|
+ `)
|
|
|
|
|
+ .get(Number(id), Number(userId));
|
|
|
|
|
+ if (!row) return null;
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: Number(row.id),
|
|
|
|
|
+ backend: row.storage_backend || 'sqlite',
|
|
|
|
|
+ key: row.storage_key || '',
|
|
|
|
|
+ relpath: row.storage_relpath || '',
|
|
|
|
|
+ mailboxAddress: row.mailbox_address,
|
|
|
|
|
+ flags: normalizeImportedStringList(safeJson(row.flags_json, [])),
|
|
|
|
|
+ read: row.read_state === 'true'
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
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 = ?')
|
|
@@ -1593,6 +2024,56 @@ export function createInboundFolder(mailbox, folder) {
|
|
|
return { name, standard: false };
|
|
return { name, standard: false };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+export function syncInboundMaildirFolders(mailbox, folders = []) {
|
|
|
|
|
+ if (!mailbox?.id || !mailbox?.userId) throw new Error('收信邮箱不存在。');
|
|
|
|
|
+ const names = [...new Set(folders
|
|
|
|
|
+ .map((folder) => normalizeInboundFolder(folder))
|
|
|
|
|
+ .filter((folder) => folder && !isStandardInboundFolder(folder)))];
|
|
|
|
|
+ const present = new Set(names);
|
|
|
|
|
+ const database = requireDb();
|
|
|
|
|
+ const existing = database.prepare(`
|
|
|
|
|
+ SELECT id, name, deleted_at
|
|
|
|
|
+ FROM inbound_folders
|
|
|
|
|
+ WHERE mailbox_id = ? AND user_id = ?
|
|
|
|
|
+ `).all(Number(mailbox.id), mailbox.userId);
|
|
|
|
|
+ const timestamp = now();
|
|
|
|
|
+ const upsert = database.prepare(`
|
|
|
|
|
+ INSERT INTO inbound_folders (mailbox_id, user_id, name, subscribed, created_at, updated_at, deleted_at)
|
|
|
|
|
+ VALUES (?, ?, ?, 'true', ?, ?, NULL)
|
|
|
|
|
+ ON CONFLICT(mailbox_id, name) DO UPDATE SET
|
|
|
|
|
+ subscribed = 'true', updated_at = excluded.updated_at, deleted_at = NULL
|
|
|
|
|
+ `);
|
|
|
|
|
+ const remove = database.prepare(`
|
|
|
|
|
+ UPDATE inbound_folders
|
|
|
|
|
+ SET deleted_at = ?, updated_at = ?
|
|
|
|
|
+ WHERE id = ? AND deleted_at IS NULL
|
|
|
|
|
+ `);
|
|
|
|
|
+ database.exec('BEGIN IMMEDIATE');
|
|
|
|
|
+ try {
|
|
|
|
|
+ let createdOrRestored = 0;
|
|
|
|
|
+ let deleted = 0;
|
|
|
|
|
+ const existingByName = new Map(existing.map((row) => [row.name, row]));
|
|
|
|
|
+ for (const name of names) {
|
|
|
|
|
+ const row = existingByName.get(name);
|
|
|
|
|
+ upsert.run(Number(mailbox.id), mailbox.userId, name, timestamp, timestamp);
|
|
|
|
|
+ if (!row || row.deleted_at) createdOrRestored += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const row of existing) {
|
|
|
|
|
+ if (present.has(row.name) || row.deleted_at) continue;
|
|
|
|
|
+ deleted += Number(remove.run(timestamp, timestamp, row.id).changes || 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ database.exec('COMMIT');
|
|
|
|
|
+ return { createdOrRestored, deleted };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ database.exec('ROLLBACK');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore rollback errors when no transaction is open
|
|
|
|
|
+ }
|
|
|
|
|
+ throw error;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function inboundFolderExists(mailbox, folder) {
|
|
export function inboundFolderExists(mailbox, folder) {
|
|
|
if (!mailbox?.id || !mailbox?.userId) return false;
|
|
if (!mailbox?.id || !mailbox?.userId) return false;
|
|
|
const name = normalizeInboundFolder(folder);
|
|
const name = normalizeInboundFolder(folder);
|
|
@@ -4636,7 +5117,7 @@ function normalizeEmail(value) {
|
|
|
|
|
|
|
|
function normalizeInboundAddress(value) {
|
|
function normalizeInboundAddress(value) {
|
|
|
const email = normalizeEmail(value);
|
|
const email = normalizeEmail(value);
|
|
|
- if (!email) return '';
|
|
|
|
|
|
|
+ if (!email || /[\/\\\u0000]/.test(email)) return '';
|
|
|
const [localPart, domain] = email.split('@');
|
|
const [localPart, domain] = email.split('@');
|
|
|
if (!localPart || !domain) return '';
|
|
if (!localPart || !domain) return '';
|
|
|
return `${localPart}@${domain}`;
|
|
return `${localPart}@${domain}`;
|
|
@@ -4678,6 +5159,44 @@ function normalizeImportedStringList(values) {
|
|
|
return [...new Set(list.map((value) => String(value || '').trim()).filter(Boolean))];
|
|
return [...new Set(list.map((value) => String(value || '').trim()).filter(Boolean))];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function normalizeInboundStorage(storage, rawMessageBytes = null) {
|
|
|
|
|
+ if (!storage || storage.backend !== 'maildir') {
|
|
|
|
|
+ return {
|
|
|
|
|
+ backend: 'sqlite',
|
|
|
|
|
+ key: '',
|
|
|
|
|
+ relpath: '',
|
|
|
|
|
+ sha256: '',
|
|
|
|
|
+ size: 0,
|
|
|
|
|
+ mtimeMs: 0,
|
|
|
|
|
+ indexedAt: null
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ const key = String(storage.key || '').trim().toLowerCase();
|
|
|
|
|
+ const relpath = String(storage.relpath || '').trim().replace(/\\/g, '/');
|
|
|
|
|
+ const suppliedSha256 = String(storage.sha256 || '').trim().toLowerCase();
|
|
|
|
|
+ const bytes = rawMessageBytes === null || rawMessageBytes === undefined
|
|
|
|
|
+ ? null
|
|
|
|
|
+ : Buffer.from(rawMessageBytes);
|
|
|
|
|
+ const sha256 = suppliedSha256 || (bytes ? crypto.createHash('sha256').update(bytes).digest('hex') : '');
|
|
|
|
|
+ const size = storage.size === undefined || storage.size === null
|
|
|
|
|
+ ? (bytes?.length || 0)
|
|
|
|
|
+ : Number(storage.size);
|
|
|
|
|
+ const mtimeMs = Number(storage.mtimeMs || 0);
|
|
|
|
|
+ if (!/^[a-z0-9][a-z0-9._-]{0,191}$/.test(key)) throw new Error('Maildir 存储标识不正确。');
|
|
|
|
|
+ if (
|
|
|
|
|
+ !relpath
|
|
|
|
|
+ || relpath.length > 1024
|
|
|
|
|
+ || relpath.startsWith('/')
|
|
|
|
|
+ || relpath.split('/').some((part) => !part || part === '.' || part === '..')
|
|
|
|
|
+ || /[\r\n\u0000]/.test(relpath)
|
|
|
|
|
+ ) throw new Error('Maildir 存储路径不正确。');
|
|
|
|
|
+ if (sha256 && !/^[a-f0-9]{64}$/.test(sha256)) throw new Error('Maildir 内容摘要不正确。');
|
|
|
|
|
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error('Maildir 邮件大小不正确。');
|
|
|
|
|
+ if (!Number.isSafeInteger(mtimeMs) || mtimeMs < 0) throw new Error('Maildir 修改时间不正确。');
|
|
|
|
|
+ const indexedAt = storage.indexedAt ? normalizeImportedReceivedAt(storage.indexedAt) : now();
|
|
|
|
|
+ return { backend: 'maildir', key, relpath, sha256, size, mtimeMs, indexedAt };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function canonicalPop3MessageSize(value) {
|
|
function canonicalPop3MessageSize(value) {
|
|
|
const bytes = Buffer.isBuffer(value)
|
|
const bytes = Buffer.isBuffer(value)
|
|
|
? value
|
|
? value
|