vesta-maildir-import.test.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import assert from 'node:assert/strict';
  2. import {
  3. mkdirSync,
  4. mkdtempSync,
  5. renameSync,
  6. utimesSync,
  7. writeFileSync
  8. } from 'node:fs';
  9. import { tmpdir } from 'node:os';
  10. import path from 'node:path';
  11. import { test } from 'node:test';
  12. import {
  13. createVestaMessageSourceKey,
  14. decodeModifiedUtf7,
  15. importVestaSnapshot,
  16. iterateVestaMaildirMessages,
  17. parseVestaConfigLine,
  18. readVestaSnapshotMetadata
  19. } from '../src/vesta-maildir-import.js';
  20. test('Vesta metadata reader safely parses domains, accounts and legacy hashes', async () => {
  21. const fixture = createSnapshotFixture();
  22. const snapshot = await readVestaSnapshotMetadata({ root: fixture.root });
  23. assert.equal(snapshot.domains.length, 2);
  24. assert.deepEqual(snapshot.domains[0], {
  25. source: 'vesta',
  26. vestaUser: 'admin',
  27. name: 'example.com',
  28. domain: 'example.com',
  29. catchAll: 'alice@example.com',
  30. catchAllAddress: 'alice@example.com',
  31. suspended: false,
  32. status: 'active',
  33. config: {
  34. DOMAIN: 'example.com',
  35. CATCHALL: 'alice',
  36. SUSPENDED: 'no'
  37. },
  38. sourceFile: path.join(fixture.root, 'usr/local/vesta/data/users/admin/mail.conf')
  39. });
  40. assert.equal(snapshot.mailboxes.length, 1);
  41. assert.equal(snapshot.domains[1].catchAll, '');
  42. assert.match(snapshot.mailboxes[0].legacyPasswordHash, /^\{MD5\}\$1\$/);
  43. assert.equal(snapshot.mailboxes[0].passwordScheme, 'md5-crypt');
  44. assert.deepEqual(snapshot.mailboxes[0].aliases, ['sales@example.com', 'help@example.com']);
  45. assert.deepEqual(snapshot.mailboxes[0].forwardTo, ['archive@elsewhere.test']);
  46. assert.equal(snapshot.mailboxes[0].keepForwarded, true);
  47. assert.equal(snapshot.mailboxes[0].quotaMb, null);
  48. assert.equal(snapshot.mailboxes[0].maildirPath, fixture.maildir);
  49. assert.ok(snapshot.mailboxes[0].folders.includes('Empty'));
  50. });
  51. test('Maildir reader preserves folders, raw bytes, mtime, flags and keywords', async () => {
  52. const fixture = createSnapshotFixture();
  53. const snapshot = await readVestaSnapshotMetadata({ root: fixture.root });
  54. const messages = [];
  55. for await (const message of iterateVestaMaildirMessages(snapshot.mailboxes[0])) messages.push(message);
  56. assert.equal(messages.length, 3);
  57. const inbox = messages.find((message) => message.folder === 'INBOX');
  58. assert.deepEqual(inbox.flags, ['\\Answered', '\\Seen', 'X']);
  59. assert.deepEqual(inbox.keywords, ['$Label1', 'b']);
  60. assert.equal(inbox.maildirFlags, 'RSXab');
  61. assert.equal(inbox.modifiedAt, '2024-01-02T03:04:05.000Z');
  62. assert.ok(inbox.rawMessageBytes.equals(fixture.inboxBytes));
  63. const sent = messages.find((message) => message.folder === 'Sent');
  64. assert.equal(sent.maildirFlags, 'S');
  65. const chinese = messages.find((message) => message.folder === '中文');
  66. assert.ok(chinese);
  67. assert.match(chinese.sourcePath, /^\.&Ti1lhw-\/new\//);
  68. const originalKey = inbox.sourceKey;
  69. const renamed = path.join(fixture.maildir, 'new', '1700000000.M1P1.host:2,S');
  70. renameSync(fixture.inboxPath, renamed);
  71. const renamedBytesHash = inbox.contentSha256;
  72. assert.equal(originalKey, createVestaMessageSourceKey({
  73. address: 'alice@example.com',
  74. folder: 'INBOX',
  75. fileName: path.basename(renamed),
  76. contentSha256: renamedBytesHash
  77. }));
  78. });
  79. test('importVestaSnapshot is dry-run safe, MIME-aware and idempotent by sourceKey', async () => {
  80. const fixture = createSnapshotFixture();
  81. const stored = new Map();
  82. const folders = [];
  83. const adapter = {
  84. async ensureDomain(domain) {
  85. return { id: domain.name };
  86. },
  87. async ensureMailbox(mailbox) {
  88. return { id: mailbox.address };
  89. },
  90. async ensureFolder(_mailbox, folder) {
  91. folders.push(folder);
  92. },
  93. async hasMessage(sourceKey) {
  94. return stored.has(sourceKey);
  95. },
  96. async createMessage(message) {
  97. stored.set(message.sourceKey, message);
  98. }
  99. };
  100. const checkpoints = [];
  101. const first = await importVestaSnapshot({
  102. root: fixture.root,
  103. adapter,
  104. onCheckpoint: (sourceKey) => checkpoints.push(sourceKey)
  105. });
  106. assert.equal(first.messages, 3);
  107. assert.equal(first.importedMessages, 3);
  108. assert.equal(first.skippedMessages, 0);
  109. assert.equal(first.plannedMessages, 0);
  110. assert.equal(stored.size, 3);
  111. assert.ok(folders.includes('Empty'));
  112. assert.equal(checkpoints.at(-1), first.lastSourceKey);
  113. assert.ok([...stored.values()].every((message) => Buffer.isBuffer(message.rawMessageBytes)));
  114. const inbox = [...stored.values()].find((message) => message.folder === 'INBOX');
  115. assert.equal(inbox.read, true);
  116. assert.equal(inbox.receivedAt, '2024-01-02T03:04:05.000Z');
  117. const chinese = [...stored.values()].find((message) => message.folder === '中文');
  118. assert.equal(chinese.messageId, '');
  119. assert.equal(chinese.subject, 'No message id');
  120. const sent = [...stored.values()].find((message) => message.folder === 'Sent');
  121. assert.deepEqual(sent.recipients, ['bob@example.net']);
  122. const second = await importVestaSnapshot({ root: fixture.root, adapter });
  123. assert.equal(second.importedMessages, 0);
  124. assert.equal(second.skippedMessages, 3);
  125. assert.equal(stored.size, 3);
  126. const neverWrite = new Proxy({}, {
  127. get() {
  128. return () => {
  129. throw new Error('dry-run called adapter');
  130. };
  131. }
  132. });
  133. const dryRun = await importVestaSnapshot({ root: fixture.root, adapter: neverWrite, dryRun: true });
  134. assert.equal(dryRun.importedMessages, 0);
  135. assert.equal(dryRun.plannedMessages, 3);
  136. assert.equal(dryRun.messages, 3);
  137. const orderedMessages = [];
  138. const snapshot = await readVestaSnapshotMetadata({ root: fixture.root });
  139. for await (const message of iterateVestaMaildirMessages(snapshot.mailboxes[0])) orderedMessages.push(message);
  140. const resumed = await importVestaSnapshot({
  141. root: fixture.root,
  142. dryRun: true,
  143. resumeAfterSourceKey: orderedMessages[0].sourceKey
  144. });
  145. assert.equal(resumed.resumeSkippedMessages, 1);
  146. assert.equal(resumed.plannedMessages, 2);
  147. await assert.rejects(
  148. importVestaSnapshot({ root: fixture.root, dryRun: true, resumeAfterSourceKey: 'missing' }),
  149. /断点不存在/
  150. );
  151. });
  152. test('malformed MIME is imported as raw mail and concurrent deduplication is counted as skipped', async () => {
  153. const fixture = createSnapshotFixture();
  154. const rawMessages = [];
  155. const result = await importVestaSnapshot({
  156. root: fixture.root,
  157. parseMessage: async () => {
  158. throw new Error('malformed MIME');
  159. },
  160. adapter: {
  161. async ensureDomain(domain) {
  162. return domain;
  163. },
  164. async ensureMailbox(mailbox) {
  165. return mailbox;
  166. },
  167. async hasMessage() {
  168. return false;
  169. },
  170. async createMessage(message) {
  171. rawMessages.push(message.rawMessageBytes);
  172. return { created: false };
  173. }
  174. }
  175. });
  176. assert.equal(result.importedMessages, 0);
  177. assert.equal(result.skippedMessages, 3);
  178. assert.equal(result.warningCount, result.warnings.length + 3);
  179. assert.equal(rawMessages.length, 3);
  180. assert.ok(rawMessages.every(Buffer.isBuffer));
  181. });
  182. test('config and Modified UTF-7 parsing do not execute shell syntax', () => {
  183. assert.deepEqual(
  184. parseVestaConfigLine("ACCOUNT='alice' FWD='archive@example.net' SUSPENDED='no'"),
  185. { ACCOUNT: 'alice', FWD: 'archive@example.net', SUSPENDED: 'no' }
  186. );
  187. assert.equal(decodeModifiedUtf7('&Ti1lhw-'), '中文');
  188. assert.equal(decodeModifiedUtf7('R&-D &- QA'), 'R&D & QA');
  189. });
  190. function createSnapshotFixture() {
  191. const root = mkdtempSync(path.join(tmpdir(), 'mailhub-vesta-snapshot-'));
  192. const userData = path.join(root, 'usr/local/vesta/data/users/admin');
  193. const domainConfig = path.join(userData, 'mail');
  194. const confMail = path.join(root, 'home/admin/conf/mail/example.com');
  195. const maildir = path.join(root, 'home/admin/mail/example.com/alice');
  196. for (const directory of [
  197. domainConfig,
  198. confMail,
  199. path.join(maildir, 'cur'),
  200. path.join(maildir, 'new'),
  201. path.join(maildir, 'tmp'),
  202. path.join(maildir, '.Sent/cur'),
  203. path.join(maildir, '.Sent/new'),
  204. path.join(maildir, '.Empty/cur'),
  205. path.join(maildir, '.Empty/new'),
  206. path.join(maildir, '.&Ti1lhw-/cur'),
  207. path.join(maildir, '.&Ti1lhw-/new')
  208. ]) mkdirSync(directory, { recursive: true });
  209. writeFileSync(
  210. path.join(userData, 'mail.conf'),
  211. "DOMAIN='example.com' CATCHALL='alice' SUSPENDED='no'\n" +
  212. "DOMAIN='nocatch.example' CATCHALL='no' SUSPENDED='no'\n"
  213. );
  214. const legacyHash = '{MD5}$1$salt1234$abcdefghijklmnopqrstuv';
  215. writeFileSync(
  216. path.join(domainConfig, 'example.com.conf'),
  217. `ACCOUNT='alice' ALIAS='sales,help' AUTOREPLY='no' FWD='archive@elsewhere.test' FWD_ONLY='no' MD5='${legacyHash}' QUOTA='unlimited' U_DISK='0' SUSPENDED='no' TIME='03:04:05' DATE='2024-01-02'\n`
  218. );
  219. writeFileSync(
  220. path.join(confMail, 'passwd'),
  221. `alice:${legacyHash}:admin:mail::/home/admin:0:userdb_quota_rule=*:storage=0M\n`
  222. );
  223. writeFileSync(path.join(maildir, 'dovecot-keywords'), '0 $Label1\n');
  224. const inboxBytes = Buffer.concat([
  225. Buffer.from([
  226. 'From: Bob <bob@example.net>',
  227. 'To: alice@example.com',
  228. 'Subject: Inbox message',
  229. 'Message-ID: <inbox@example.net>',
  230. 'Content-Type: text/plain; charset=iso-8859-1',
  231. '',
  232. 'caf'
  233. ].join('\r\n'), 'ascii'),
  234. Buffer.from([0xe9, 0x0d, 0x0a])
  235. ]);
  236. const inboxPath = path.join(maildir, 'cur', '1700000000.M1P1.host:2,RSXab');
  237. writeFileSync(inboxPath, inboxBytes);
  238. const timestamp = new Date('2024-01-02T03:04:05.000Z');
  239. utimesSync(inboxPath, timestamp, timestamp);
  240. writeFileSync(path.join(maildir, '.Sent/cur', '1700000001.M2P1.host:2,S'), [
  241. 'From: alice@example.com',
  242. 'To: bob@example.net',
  243. 'Subject: Sent message',
  244. 'Message-ID: <sent@example.com>',
  245. '',
  246. 'Sent body'
  247. ].join('\r\n'));
  248. writeFileSync(path.join(maildir, '.&Ti1lhw-/new', '1700000002.M3P1.host'), [
  249. 'From: sender@example.net',
  250. 'To: alice@example.com',
  251. 'Subject: No message id',
  252. '',
  253. 'Custom folder body'
  254. ].join('\r\n'));
  255. return { root, maildir, inboxBytes, inboxPath };
  256. }