import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; import { bucketPathParts, importAppleMailEmlx, importAppleMailFromIndex, mailboxPathForUrl, readEmlxRawMessage, } from '../src/localMailImport.js'; import { importMbox, splitMbox } from '../src/mboxImport.js'; const sample = await fs.readFile('test/fixtures/sample-bank-email.eml'); const execFileAsync = promisify(execFile); test('reads raw message from emlx declared byte size', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-emlx-')); const emlxPath = path.join(tmp, '1.emlx'); await fs.writeFile(emlxPath, Buffer.concat([ Buffer.from(`${sample.length}\n`, 'utf8'), sample, Buffer.from('\n\n', 'utf8'), ])); const raw = await readEmlxRawMessage(emlxPath); assert.equal(raw.toString(), sample.toString()); }); test('imports apple mail emlx folder into archive', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-emlx-import-')); const source = path.join(tmp, 'INBOX.mbox', 'Data', 'Messages'); const archive = path.join(tmp, 'archive'); await fs.mkdir(source, { recursive: true }); await fs.writeFile(path.join(source, '42.emlx'), Buffer.concat([ Buffer.from(`${sample.length}\n`, 'utf8'), sample, ])); const result = await importAppleMailEmlx({ account: { id: 'vjoati-gmail' }, sourcePath: path.join(tmp, 'INBOX.mbox'), archiveRoot: archive, limit: 10, }); assert.equal(result.imported, 1); assert.equal(result.records[0].attachments.length, 1); }); test('imports apple mail by account and envelope index', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-apple-index-')); const mailRoot = path.join(tmp, 'Mail', 'V10'); const archive = path.join(tmp, 'archive'); const accountsDb = path.join(tmp, 'Accounts4.sqlite'); const envelopeIndex = path.join(mailRoot, 'MailData', 'Envelope Index'); const accountUuid = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'; const sourcePath = path.join(mailRoot, accountUuid, '[Gmail].mbox', 'Todos.mbox'); const rowid = 12345; const emlxPath = path.join(sourcePath, 'Data', ...bucketPathParts(rowid), 'Messages', `${rowid}.emlx`); await fs.mkdir(path.dirname(envelopeIndex), { recursive: true }); await fs.mkdir(path.dirname(emlxPath), { recursive: true }); await fs.writeFile(emlxPath, Buffer.concat([ Buffer.from(`${sample.length}\n`, 'utf8'), sample, ])); await execSql(accountsDb, ` create table ZACCOUNT ( Z_PK integer primary key, ZPARENTACCOUNT integer, ZACCOUNTDESCRIPTION varchar, ZUSERNAME varchar, ZIDENTIFIER varchar, ZACCOUNTTYPE integer ); create table ZACCOUNTTYPE (Z_PK integer primary key, ZIDENTIFIER varchar); insert into ZACCOUNTTYPE values (1, 'com.apple.account.Google'); insert into ZACCOUNTTYPE values (2, 'com.apple.account.IMAP'); insert into ZACCOUNT values (25, null, 'Google', 'vjoati@gmail.com', 'parent-google', 1); insert into ZACCOUNT values (26, 25, null, null, '${accountUuid}', 2); `); await execSql(envelopeIndex, ` create table mailboxes ( ROWID integer primary key, url text, total_count integer, unread_count integer, unseen_count integer ); create table messages ( ROWID integer primary key, remote_id integer, date_received integer, mailbox integer, deleted integer ); insert into mailboxes values (16, 'imap://${accountUuid}/%5BGmail%5D/Todos', 1, 0, 0); insert into messages values (${rowid}, 999, 1764547200, 16, 0); `); const result = await importAppleMailFromIndex({ account: { id: 'vjoati-gmail', email: 'vjoati@gmail.com' }, mailRoot, indexPath: envelopeIndex, accountsDbPath: accountsDb, archiveRoot: archive, since: '2025-01-01', limit: 10, }); assert.equal(result.imported, 1); assert.equal(result.records[0].appleMail.rowid, rowid); assert.equal(result.records[0].attachments.length, 1); }); test('prefers populated direct IMAP account over empty child IMAP account', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-apple-direct-index-')); const mailRoot = path.join(tmp, 'Mail', 'V10'); const archive = path.join(tmp, 'archive'); const accountsDb = path.join(tmp, 'Accounts4.sqlite'); const envelopeIndex = path.join(mailRoot, 'MailData', 'Envelope Index'); const directUuid = 'DIRECT-IMAP-ACCOUNT'; const childUuid = 'CHILD-EMPTY-ACCOUNT'; const rowid = 22345; const sourcePath = path.join(mailRoot, directUuid, 'INBOX.mbox'); const emlxPath = path.join(sourcePath, 'Data', ...bucketPathParts(rowid), 'Messages', `${rowid}.emlx`); await fs.mkdir(path.dirname(envelopeIndex), { recursive: true }); await fs.mkdir(path.dirname(emlxPath), { recursive: true }); await fs.writeFile(emlxPath, Buffer.concat([ Buffer.from(`${sample.length}\n`, 'utf8'), sample, ])); await execSql(accountsDb, ` create table ZACCOUNT ( Z_PK integer primary key, ZPARENTACCOUNT integer, ZACCOUNTDESCRIPTION varchar, ZUSERNAME varchar, ZIDENTIFIER varchar, ZACCOUNTTYPE integer ); create table ZACCOUNTTYPE (Z_PK integer primary key, ZIDENTIFIER varchar); insert into ZACCOUNTTYPE values (1, 'com.apple.account.Google'); insert into ZACCOUNTTYPE values (2, 'com.apple.account.IMAP'); insert into ZACCOUNT values (22, null, 'Kdoi Email', 'kdoi@email.com', '${directUuid}', 2); insert into ZACCOUNT values (39, null, 'kdoi@email.com', 'kdoi@email.com', 'parent-google', 1); insert into ZACCOUNT values (40, 39, null, null, '${childUuid}', 2); `); await execSql(envelopeIndex, ` create table mailboxes ( ROWID integer primary key, url text, total_count integer, unread_count integer, unseen_count integer ); create table messages ( ROWID integer primary key, remote_id integer, date_received integer, mailbox integer, deleted integer ); insert into mailboxes values (10, 'imap://${directUuid}/INBOX', 10, 0, 0); insert into mailboxes values (48, 'imap://${childUuid}/INBOX', 0, 0, 0); insert into messages values (${rowid}, 888, 1764547200, 10, 0); `); const result = await importAppleMailFromIndex({ account: { id: 'kdoi-email', email: 'kdoi@email.com' }, mailRoot, indexPath: envelopeIndex, accountsDbPath: accountsDb, archiveRoot: archive, since: '2025-01-01', limit: 10, }); assert.equal(result.imported, 1); assert.equal(result.imapAccount.identifier, directUuid); assert.equal(result.mailbox.url, `imap://${directUuid}/INBOX`); }); test('maps apple mailbox urls and message buckets to filesystem paths', () => { assert.deepEqual(bucketPathParts(563), []); assert.deepEqual(bucketPathParts(27431), ['7', '2']); assert.deepEqual(bucketPathParts(258680), ['8', '5', '2']); assert.equal( mailboxPathForUrl('/Mail/V10', 'imap://ACCOUNT/%5BGmail%5D/Todos'), path.join('/Mail/V10', 'ACCOUNT', '[Gmail].mbox', 'Todos.mbox') ); }); test('splits and imports mbox messages', async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'kua-mbox-')); const mboxPath = path.join(tmp, 'takeout.mbox'); const archive = path.join(tmp, 'archive'); const mbox = Buffer.concat([ Buffer.from('From sender@example Tue Dec 30 10:30:00 2025\n'), sample, Buffer.from('\nFrom sender@example Wed Dec 31 10:30:00 2025\n'), sample, ]); await fs.writeFile(mboxPath, mbox); assert.equal(splitMbox(mbox).length, 2); const result = await importMbox({ account: { id: 'vjoati-gmail' }, mboxPath, archiveRoot: archive, limit: 1, }); assert.equal(result.scanned, 2); assert.equal(result.imported, 1); }); async function execSql(dbPath, sql) { await execFileAsync('sqlite3', [dbPath, sql]); }