229 lines
7.9 KiB
JavaScript
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]);
|
|
}
|