kua-money-trace/test/localMailImport.test.js

229 lines
7.9 KiB
JavaScript

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<?xml version="1.0"?><plist></plist>\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]);
}