maildir-sync.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import crypto from 'node:crypto';
  2. import { unlink } from 'node:fs/promises';
  3. import path from 'node:path';
  4. import {
  5. clearInboundMaildirMigrationStaging,
  6. commitInboundMaildirMigrationStaging,
  7. createImportedInboundMessage,
  8. createImportedInboundMessageWithWebhook,
  9. listInboundFolders,
  10. listInboundMailboxesForStorage,
  11. listInboundMaildirIndex,
  12. listInboundMaildirMigrationCandidates,
  13. listInboundMaildirMigrationStaging,
  14. markMissingInboundMaildirMessages,
  15. stageInboundMessageMaildirStorageBatch,
  16. syncInboundMaildirFolders,
  17. syncInboundMessageMaildirMetadata
  18. } from './db.js';
  19. import { parseInboundMessage } from './inbound-mail.js';
  20. import {
  21. ensureMaildirMailbox,
  22. flushMaildirFiles,
  23. listMaildirFolders,
  24. maildirHomePath,
  25. readMaildirMessage,
  26. scanMaildirMailbox,
  27. sqliteMaildirStorageKey,
  28. writeMaildirMessage
  29. } from './maildir-store.js';
  30. export async function migrateInboundMessagesToMaildir({
  31. root,
  32. batchSize = 250,
  33. onProgress = null
  34. } = {}) {
  35. const cleanBatchSize = Math.min(1000, Math.max(1, Number(batchSize) || 250));
  36. let lastId = 0;
  37. let processed = 0;
  38. let written = 0;
  39. let reused = 0;
  40. let looseFiles = [];
  41. let looseDirectories = [];
  42. try {
  43. await cleanupMaildirMigrationStaging({ root, batchSize: cleanBatchSize });
  44. for (const mailbox of listInboundMailboxesForStorage()) {
  45. await ensureMaildirMailbox(root, mailbox.address, listInboundFolders(mailbox), { durable: true });
  46. const entries = new Map(
  47. (await scanMaildirMailbox({ root, address: mailbox.address }))
  48. .map((entry) => [entry.storageKey, entry])
  49. );
  50. let mailboxAfterId = 0;
  51. while (true) {
  52. const candidates = listInboundMaildirMigrationCandidates(
  53. mailboxAfterId,
  54. cleanBatchSize,
  55. { mailboxId: mailbox.id }
  56. );
  57. if (!candidates.length) break;
  58. const batchRecords = [];
  59. const batchFiles = [];
  60. const batchDirectories = [];
  61. looseFiles = batchFiles;
  62. looseDirectories = batchDirectories;
  63. for (const candidate of candidates) {
  64. mailboxAfterId = candidate.id;
  65. lastId = Math.max(lastId, candidate.id);
  66. const storageKey = validStorageKey(candidate.storageKey)
  67. ? candidate.storageKey
  68. : sqliteMaildirStorageKey(candidate.id);
  69. const existing = entries.get(storageKey);
  70. if (existing) {
  71. const { bytes } = await readMaildirMessage(existing);
  72. if (!bytes.equals(Buffer.from(candidate.rawMessageBytes))) {
  73. throw new Error(`邮件 ${candidate.id} 的 Maildir 存储标识已被其他内容占用。`);
  74. }
  75. batchDirectories.push(path.dirname(existing.filePath));
  76. await unlink(existing.filePath);
  77. entries.delete(storageKey);
  78. reused += 1;
  79. }
  80. const storage = await writeMaildirMessage({
  81. root,
  82. address: candidate.mailboxAddress,
  83. rawMessageBytes: candidate.rawMessageBytes,
  84. folder: candidate.folder,
  85. flags: candidate.flags,
  86. keywords: candidate.keywords,
  87. read: candidate.read,
  88. receivedAt: candidate.receivedAt,
  89. storageKey,
  90. durable: false
  91. });
  92. const filePath = maildirFilePath(root, candidate.mailboxAddress, storage.relpath);
  93. entries.set(storageKey, {
  94. storageKey,
  95. filePath,
  96. relpath: storage.relpath,
  97. size: storage.size,
  98. mtimeMs: storage.mtimeMs,
  99. folder: candidate.folder,
  100. flags: candidate.flags,
  101. keywords: candidate.keywords,
  102. read: candidate.read
  103. });
  104. written += 1;
  105. batchFiles.push(filePath);
  106. batchRecords.push({ id: candidate.id, storage });
  107. processed += 1;
  108. await onProgress?.({ processed, written, reused, lastId: candidate.id });
  109. }
  110. stageInboundMessageMaildirStorageBatch(batchRecords);
  111. await flushMaildirFiles(batchFiles, { root, directories: batchDirectories });
  112. looseFiles = [];
  113. looseDirectories = [];
  114. }
  115. }
  116. const switched = commitInboundMaildirMigrationStaging(processed);
  117. if (switched !== processed) throw new Error('Maildir 批量切换未完整提交。');
  118. } catch (error) {
  119. await removeMaildirFiles(looseFiles);
  120. await flushMaildirFiles([], { root, directories: looseDirectories }).catch(() => null);
  121. await cleanupMaildirMigrationStaging({ root, batchSize: cleanBatchSize }).catch(() => null);
  122. throw error;
  123. }
  124. return { processed, written, reused, lastId };
  125. }
  126. export async function reconcileMaildirMailbox({ root, mailbox, missingCounts = new Map() }) {
  127. await ensureMaildirMailbox(root, mailbox.address);
  128. const folderReport = syncInboundMaildirFolders(
  129. mailbox,
  130. await listMaildirFolders({ root, address: mailbox.address })
  131. );
  132. const index = listInboundMaildirIndex(mailbox.id);
  133. const entries = assignStorageKeys(
  134. await scanMaildirMailbox({ root, address: mailbox.address }),
  135. index
  136. );
  137. const indexByKey = new Map(index.map((message) => [message.storageKey, message]));
  138. const present = new Set();
  139. let created = 0;
  140. let updated = 0;
  141. for (const entry of entries) {
  142. present.add(entry.storageKey);
  143. missingCounts.delete(missingCounterKey(mailbox.id, entry.storageKey));
  144. const existing = indexByKey.get(entry.storageKey);
  145. const storage = storageFromEntry(entry);
  146. if (existing) {
  147. if (
  148. !existing.deleted
  149. && existing.storageRelpath === entry.relpath
  150. && existing.storageSize === entry.size
  151. && existing.storageMtimeMs === entry.mtimeMs
  152. && existing.folder === entry.folder
  153. && existing.read === entry.read
  154. && sameStringSet(existing.flags, entry.flags)
  155. && sameStringSet(existing.keywords, entry.keywords)
  156. ) continue;
  157. if (syncInboundMessageMaildirMetadata(mailbox, storage, entry)) updated += 1;
  158. continue;
  159. }
  160. const { bytes, sha256 } = await readMaildirMessage(entry);
  161. let parsed = {};
  162. try {
  163. parsed = await parseInboundMessage(bytes, [mailbox.address]);
  164. } catch {
  165. // Dovecot must still expose malformed historic messages verbatim. The
  166. // management index falls back to minimal metadata instead of dropping it.
  167. }
  168. const createIndex = entry.folder === 'INBOX' && entry.storageKey.startsWith('mhsmtp-')
  169. ? createImportedInboundMessageWithWebhook
  170. : createImportedInboundMessage;
  171. const result = createIndex(mailbox, {
  172. ...parsed,
  173. importSource: 'maildir-index',
  174. sourceKey: `${mailbox.id}:${entry.storageKey}`,
  175. folder: entry.folder,
  176. flags: entry.flags,
  177. keywords: entry.keywords,
  178. read: entry.read,
  179. receivedAt: entry.receivedAt,
  180. rawMessageBytes: bytes,
  181. recipients: parsed.recipients?.length ? parsed.recipients : [mailbox.address],
  182. storage: storageFromEntry(entry, { sha256, size: bytes.length })
  183. });
  184. if (result.created) {
  185. created += 1;
  186. }
  187. else if (syncInboundMessageMaildirMetadata(mailbox, storage, entry)) updated += 1;
  188. }
  189. const protectedKeys = new Set(present);
  190. for (const message of index) {
  191. if (present.has(message.storageKey) || message.deleted) continue;
  192. const counterKey = missingCounterKey(mailbox.id, message.storageKey);
  193. const count = (missingCounts.get(counterKey) || 0) + 1;
  194. missingCounts.set(counterKey, count);
  195. if (count < 2) protectedKeys.add(message.storageKey);
  196. }
  197. const deleted = markMissingInboundMaildirMessages(mailbox.id, protectedKeys);
  198. return {
  199. mailboxId: mailbox.id,
  200. files: entries.length,
  201. foldersCreatedOrRestored: folderReport.createdOrRestored,
  202. foldersDeleted: folderReport.deleted,
  203. created,
  204. updated,
  205. deleted
  206. };
  207. }
  208. export async function reconcileAllMaildirs({ root, missingCounts = new Map() } = {}) {
  209. const reports = [];
  210. for (const mailbox of listInboundMailboxesForStorage()) {
  211. reports.push(await reconcileMaildirMailbox({ root, mailbox, missingCounts }));
  212. }
  213. return reports;
  214. }
  215. export function startMaildirReconciler({
  216. root,
  217. enabled = true,
  218. intervalMs = 300_000,
  219. logger = console
  220. } = {}) {
  221. if (!enabled) return { stop() {} };
  222. const delay = Math.max(1_000, Number(intervalMs) || 300_000);
  223. const missingCounts = new Map();
  224. let stopped = false;
  225. let timer = null;
  226. const schedule = () => {
  227. if (stopped) return;
  228. timer = setTimeout(run, delay);
  229. timer.unref?.();
  230. };
  231. const run = async () => {
  232. try {
  233. await reconcileAllMaildirs({ root, missingCounts });
  234. } catch (error) {
  235. logger.warn?.(`Maildir index synchronization failed: ${error.message || error}`);
  236. } finally {
  237. schedule();
  238. }
  239. };
  240. schedule();
  241. return {
  242. async runNow() {
  243. return reconcileAllMaildirs({ root, missingCounts });
  244. },
  245. stop() {
  246. stopped = true;
  247. clearTimeout(timer);
  248. }
  249. };
  250. }
  251. function assignStorageKeys(entries, index) {
  252. const indexByRelpath = new Map(index.map((message) => [message.storageRelpath, message]));
  253. const used = new Set();
  254. const pending = [];
  255. const output = [];
  256. for (const entry of entries) {
  257. const existing = indexByRelpath.get(entry.relpath);
  258. if (existing && !used.has(existing.storageKey)) {
  259. used.add(existing.storageKey);
  260. output.push({ ...entry, storageKey: existing.storageKey });
  261. } else {
  262. pending.push(entry);
  263. }
  264. }
  265. for (const entry of pending) {
  266. let storageKey = entry.storageKey;
  267. if (used.has(storageKey)) {
  268. storageKey = `mhcopy-${crypto.createHash('sha256')
  269. .update(`${entry.storageKey}\0${entry.relpath}`)
  270. .digest('hex')
  271. .slice(0, 40)}`;
  272. }
  273. while (used.has(storageKey)) {
  274. storageKey = `mhcopy-${crypto.createHash('sha256')
  275. .update(`${storageKey}\0${entry.relpath}`)
  276. .digest('hex')
  277. .slice(0, 40)}`;
  278. }
  279. used.add(storageKey);
  280. output.push({ ...entry, storageKey });
  281. }
  282. return output.sort((left, right) => left.relpath.localeCompare(right.relpath));
  283. }
  284. function storageFromEntry(entry, overrides = {}) {
  285. return {
  286. backend: 'maildir',
  287. key: entry.storageKey,
  288. relpath: entry.relpath,
  289. sha256: overrides.sha256 || '',
  290. size: overrides.size ?? entry.size,
  291. mtimeMs: entry.mtimeMs,
  292. indexedAt: new Date().toISOString()
  293. };
  294. }
  295. function validStorageKey(value) {
  296. return /^[a-z0-9][a-z0-9._-]{0,191}$/.test(String(value || ''));
  297. }
  298. function sameStringSet(left, right) {
  299. return [...new Set(left || [])].sort().join('\0') === [...new Set(right || [])].sort().join('\0');
  300. }
  301. function missingCounterKey(mailboxId, storageKey) {
  302. return `${mailboxId}:${storageKey}`;
  303. }
  304. async function cleanupMaildirMigrationStaging({ root, batchSize }) {
  305. let afterMessageId = 0;
  306. while (true) {
  307. const staged = listInboundMaildirMigrationStaging(afterMessageId, batchSize);
  308. if (!staged.length) break;
  309. const files = staged.map((entry) => (
  310. maildirFilePath(root, entry.mailboxAddress, entry.storageRelpath)
  311. ));
  312. await removeMaildirFiles(files);
  313. await flushMaildirFiles([], {
  314. root,
  315. directories: files.map((filePath) => path.dirname(filePath))
  316. });
  317. afterMessageId = staged.at(-1).messageId;
  318. }
  319. clearInboundMaildirMigrationStaging();
  320. }
  321. async function removeMaildirFiles(filePaths) {
  322. const files = [...new Set(filePaths || [])];
  323. for (let offset = 0; offset < files.length; offset += 32) {
  324. await Promise.all(files.slice(offset, offset + 32).map(async (filePath) => {
  325. try {
  326. await unlink(filePath);
  327. } catch (error) {
  328. if (error?.code !== 'ENOENT') throw error;
  329. }
  330. }));
  331. }
  332. }
  333. function maildirFilePath(root, address, relpath) {
  334. const home = maildirHomePath(root, address);
  335. const filePath = path.resolve(home, ...String(relpath || '').split('/'));
  336. if (!filePath.startsWith(`${home}${path.sep}`)) throw new Error('Maildir 存储路径不正确。');
  337. return filePath;
  338. }