import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import net from 'node:net';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { test } from 'node:test';

import {
  createDomain,
  createInboundFolder,
  createInboundMailbox,
  createInboundMessage,
  createImportedInboundMessage,
  createUser,
  getInboundMessage,
  inboundFolderExists,
  initDatabase,
  listInboundMessages
} from '../src/db.js';
import { startMailboxAccessServers } from '../src/mail-access.js';

test('IMAP SELECT keeps message bodies lazy and FETCH hydrates one message', async () => {
  const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-test-')), 'mail-access-secret');
  const { user, mailbox } = createMailboxFixture('imap.example', 'imap-user');
  const storedMessage = createInboundMessage(mailbox, {
    sender: 'alice@example.net',
    recipients: ['admin@imap.example'],
    subject: 'IMAP hello',
    messageId: '<imap-hello@example.net>',
    rawMessage: [
      'From: Alice <alice@example.net>',
      'To: admin@imap.example',
      'Subject: IMAP hello',
      'Message-ID: <imap-hello@example.net>',
      '',
      'Hello through IMAP.'
    ].join('\r\n'),
    textBody: 'Hello through IMAP.'
  });

  const [server] = startMailboxAccessServers({
    hostname: 'mail.imap.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  try {
    const port = server.address().port;
    const client = await connectClient(port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    assert.match(await client.command('A1 LOGIN "admin@imap.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
    const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
    assert.match(selected, /\* 1 EXISTS/);
    database
      .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?')
      .run(Buffer.from(storedMessage.rawMessage.replace('Hello through IMAP.', 'Hallo through IMAP.'), 'utf8'), storedMessage.id);
    const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS RFC822.SIZE BODY.PEEK[])', /A3 OK/);
    assert.match(fetched, /\* 1 FETCH/);
    assert.match(fetched, /UID 1/);
    assert.match(fetched, /Subject: IMAP hello/);
    assert.match(fetched, /Hallo through IMAP\./);
    assert.doesNotMatch(fetched, /Hello through IMAP\./);
    await client.command('A4 LOGOUT', /A4 OK/);
    client.close();
    assert.equal(listInboundMessages(user.id).length, 1);
  } finally {
    await closeServer(server);
  }
});

test('IMAP exposes imported Maildir flags and Dovecot keywords', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-flags-test-')), 'mail-access-secret');
  const { mailbox } = createMailboxFixture('flags.example', 'flags-user');
  createImportedInboundMessage(mailbox, {
    importSource: 'vesta:flags',
    sourceKey: 'message-1',
    sender: 'sender@example.net',
    recipients: ['admin@flags.example'],
    subject: 'Imported flags',
    messageId: '<flags@example.net>',
    rawMessageBytes: Buffer.from('From: sender@example.net\r\nTo: admin@flags.example\r\nSubject: Imported flags\r\n\r\nBody', 'utf8'),
    flags: ['\\Answered', '\\Flagged', '\\Draft', '\\Seen'],
    keywords: ['$Label1', 'custom-keyword'],
    receivedAt: '2024-01-02T03:04:05.000Z'
  });

  const [server] = startMailboxAccessServers({
    hostname: 'mail.flags.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    await client.command('A1 LOGIN "admin@flags.example" "mailbox-pass-123"', /A1 OK/);
    const selected = await client.command('A2 SELECT INBOX', /A2 OK/);
    assert.match(selected, /\* FLAGS \([^\r\n]*\\Answered/);
    assert.match(selected, /\* FLAGS \([^\r\n]*\$Label1/);
    assert.match(selected, /\* FLAGS \([^\r\n]*custom-keyword/);
    const fetched = await client.command('A3 UID FETCH 1:* (UID FLAGS)', /A3 OK/);
    for (const flag of ['\\Answered', '\\Flagged', '\\Draft', '\\Seen', '$Label1', 'custom-keyword']) {
      assert.ok(fetched.includes(flag));
    }
    await client.command('A4 LOGOUT', /A4 OK/);
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('IMAP SEARCH filters seen state and rejects invalid contexts or criteria', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-search-test-')), 'mail-access-secret');
  const { mailbox: offsetMailbox } = createMailboxFixture('search-offset.example', 'search-offset-user');
  createInboundMessage(offsetMailbox, {
    sender: 'offset@example.net',
    recipients: ['admin@search-offset.example'],
    subject: 'UID offset',
    messageId: '<offset@search-offset.example>',
    rawMessage: 'From: offset@example.net\r\nTo: admin@search-offset.example\r\nSubject: UID offset\r\n\r\nOffset body.',
    textBody: 'Offset body.'
  });
  const { mailbox } = createMailboxFixture('search.example', 'search-user');
  const { message: seenMessage } = createImportedInboundMessage(mailbox, {
    importSource: 'imap-search-test',
    sourceKey: 'seen-message',
    sender: 'seen@example.net',
    recipients: ['admin@search.example'],
    subject: 'Already seen',
    messageId: '<seen@search.example>',
    rawMessageBytes: Buffer.from('From: seen@example.net\r\nTo: admin@search.example\r\nSubject: Already seen\r\n\r\nSeen body.', 'utf8'),
    flags: ['\\Seen'],
    receivedAt: '2026-07-15T01:00:00.000Z'
  });
  const unseenMessage = createInboundMessage(mailbox, {
    sender: 'unseen@example.net',
    recipients: ['admin@search.example'],
    subject: 'Still unread',
    messageId: '<unseen@search.example>',
    rawMessage: 'From: unseen@example.net\r\nTo: admin@search.example\r\nSubject: Still unread\r\n\r\nUnread body.',
    textBody: 'Unread body.'
  });

  const [server] = startMailboxAccessServers({
    hostname: 'mail.search.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    await client.command('A1 LOGIN "admin@search.example" "mailbox-pass-123"', /A1 OK/);

    const searchBeforeSelect = await client.command('A2 SEARCH ALL', /A2 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidSearchBeforeSelect = await client.command('A3 UID SEARCH ALL', /A3 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    await client.command('A4 SELECT INBOX', /A4 OK/);

    const all = await client.command('A5 SEARCH ALL', /A5 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const unseen = await client.command('A6 SEARCH UNSEEN', /A6 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const seen = await client.command('A7 SEARCH SEEN', /A7 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const allUnseen = await client.command('A8 SEARCH ALL UNSEEN', /A8 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidUnseen = await client.command('A9 UID SEARCH UNSEEN', /A9 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidCharsetUnseen = await client.command('A10 UID SEARCH CHARSET UTF-8 UNSEEN', /A10 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const sequenceNumber = await client.command('A10A SEARCH 2', /A10A (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const sequenceRangeUnseen = await client.command('A10B SEARCH 1:* UNSEEN', /A10B (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidSequenceNumber = await client.command('A10C UID SEARCH 2', /A10C (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidSequenceOutOfRange = await client.command('A10D UID SEARCH 16', /A10D (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidRangeUnseen = await client.command('A10E UID SEARCH 1:2 UNSEEN', /A10E (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidList = await client.command('A10F UID SEARCH 1,2', /A10F (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const uidCriterion = await client.command(`A10G UID SEARCH UID ${unseenMessage.id}`, /A10G (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const sequenceUidCriterion = await client.command(`A10H SEARCH UID ${unseenMessage.id}`, /A10H (?:OK|NO|BAD)[^\r\n]*\r\n$/);

    const stored = await client.command(
      `A11 UID STORE ${unseenMessage.id} +FLAGS.SILENT (\\Seen)`,
      /A11 (?:OK|NO|BAD)[^\r\n]*\r\n$/
    );
    const unseenAfterStore = await client.command('A12 SEARCH UNSEEN', /A12 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const seenAfterStore = await client.command('A13 SEARCH SEEN', /A13 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const returnCriteria = await client.command('A14 SEARCH RETURN (ALL) ALL', /A14 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    const unknownCriteria = await client.command('A15 SEARCH FROBNICATE', /A15 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
    await client.command('A16 LOGOUT', /A16 OK/);

    assert.match(searchBeforeSelect, /^A2 (?:NO|BAD) /m);
    assert.doesNotMatch(searchBeforeSelect, /^\* SEARCH/m);
    assert.match(uidSearchBeforeSelect, /^A3 (?:NO|BAD) /m);
    assert.doesNotMatch(uidSearchBeforeSelect, /^\* SEARCH/m);

    assertImapSearchResult(all, [1, 2]);
    assertImapSearchResult(unseen, [2]);
    assertImapSearchResult(seen, [1]);
    assertImapSearchResult(allUnseen, [2]);
    assertImapSearchResult(uidUnseen, [unseenMessage.id]);
    assertImapSearchResult(uidCharsetUnseen, [unseenMessage.id]);
    assertImapSearchResult(sequenceNumber, [2]);
    assertImapSearchResult(sequenceRangeUnseen, [2]);
    assertImapSearchResult(uidSequenceNumber, [unseenMessage.id]);
    assertImapSearchResult(uidSequenceOutOfRange, []);
    assertImapSearchResult(uidRangeUnseen, [unseenMessage.id]);
    assertImapSearchResult(uidList, [seenMessage.id, unseenMessage.id]);
    assertImapSearchResult(uidCriterion, [unseenMessage.id]);
    assertImapSearchResult(sequenceUidCriterion, [2]);
    assert.match(stored, /^A11 OK STORE completed\r?$/m);
    assert.doesNotMatch(stored, /^\* \d+ FETCH/m);
    assertImapSearchResult(unseenAfterStore, []);
    assertImapSearchResult(seenAfterStore, [1, 2]);

    assert.match(returnCriteria, /^A14 BAD /m);
    assert.doesNotMatch(returnCriteria, /^\* SEARCH/m);
    assert.match(unknownCriteria, /^A15 BAD /m);
    assert.doesNotMatch(unknownCriteria, /^\* SEARCH/m);
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
  const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
  createInboundMessage(mailbox, {
    sender: 'alice@example.net',
    recipients: ['admin@mime.example'],
    subject: 'MIME message',
    messageId: '<mime-message@example.net>',
    rawMessage: [
      'From: Alice <alice@example.net>',
      'To: admin@mime.example',
      'Subject: MIME message',
      'MIME-Version: 1.0',
      'Content-Type: multipart/alternative; boundary="mailhub-boundary"',
      '',
      '--mailhub-boundary',
      'Content-Type: text/plain; charset=UTF-8',
      'Content-Transfer-Encoding: quoted-printable',
      '',
      'Plain message body.',
      '--mailhub-boundary',
      'Content-Type: text/html; charset=UTF-8',
      '',
      '<p>HTML message body.</p>',
      '--mailhub-boundary--',
      ''
    ].join('\r\n'),
    textBody: 'Plain message body.',
    htmlBody: '<p>HTML message body.</p>'
  });

  const [server] = startMailboxAccessServers({
    hostname: 'mail.mime.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    assert.match(await client.command('A1 LOGIN "admin@mime.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
    await client.command('A2 SELECT INBOX', /A2 OK/);

    const structure = await client.command('A3 UID FETCH 1 (UID BODYSTRUCTURE)', /A3 OK/);
    assert.match(structure, /BODYSTRUCTURE \(\("TEXT" "PLAIN" \("CHARSET" "UTF-8"\).*\) \("TEXT" "HTML" \("CHARSET" "UTF-8"\).*\) "ALTERNATIVE" \("BOUNDARY" "mailhub-boundary"\)\)/);

    const textPart = await client.command('A4 UID FETCH 1 (BODY.PEEK[1])', /A4 OK/);
    assert.match(textPart, /BODY\[1\] \{\d+\}\r\nPlain message body\./);
    assert.doesNotMatch(textPart, /Content-Type: text\/plain/);

    const htmlPart = await client.command('A5 UID FETCH 1 (BODY.PEEK[2])', /A5 OK/);
    assert.match(htmlPart, /BODY\[2\] \{\d+\}\r\n<p>HTML message body\.<\/p>/);

    const mimeHeaders = await client.command('A6 UID FETCH 1 (BODY.PEEK[1.MIME])', /A6 OK/);
    assert.match(mimeHeaders, /BODY\[1\.MIME\] \{\d+\}\r\nContent-Type: text\/plain; charset=UTF-8/);
    await client.command('A7 LOGOUT', /A7 OK/);
    client.close();
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('IMAP exposes LF-only Maildir headers and text sections to Roundcube', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-lf-test-')), 'mail-access-secret');
  const { mailbox } = createMailboxFixture('lf.example', 'lf-user');
  const rawMessageBytes = Buffer.from([
    'From: Alice <alice@example.net>',
    'To: admin@lf.example',
    'Subject: LF-only imported',
    ' continuation',
    'Message-ID: <lf-only@example.net>',
    'Content-Type: text/plain; charset=UTF-8',
    'X-Not-Selected: private metadata',
    '',
    'LF-only body.',
    'Second line.'
  ].join('\n'), 'utf8');
  createImportedInboundMessage(mailbox, {
    importSource: 'vesta:lf-only',
    sourceKey: 'lf-only-message',
    sender: 'alice@example.net',
    recipients: ['admin@lf.example'],
    subject: 'LF-only imported continuation',
    messageId: '<lf-only@example.net>',
    rawMessageBytes,
    receivedAt: '2026-07-14T06:19:40.000Z'
  });

  const [server] = startMailboxAccessServers({
    hostname: 'mail.lf.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    await client.command('A1 LOGIN "admin@lf.example" "mailbox-pass-123"', /A1 OK/);
    await client.command('A2 SELECT INBOX', /A2 OK/);

    const headerFieldsLabel = 'BODY[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)]';
    const headerFieldsResponse = await client.commandBytes(
      `A3 UID FETCH 1 (UID FLAGS RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)])`,
      /A3 OK FETCH completed\r\n$/
    );
    assert.deepEqual(extractFetchLiteral(headerFieldsResponse, headerFieldsLabel), Buffer.from([
      'From: Alice <alice@example.net>',
      'To: admin@lf.example',
      'Subject: LF-only imported',
      ' continuation',
      'Message-ID: <lf-only@example.net>',
      'Content-Type: text/plain; charset=UTF-8',
      '',
      ''
    ].join('\r\n'), 'utf8'));

    const fullHeaderResponse = await client.commandBytes(
      'A4 UID FETCH 1 (RFC822.HEADER)',
      /A4 OK FETCH completed\r\n$/
    );
    assert.deepEqual(extractFetchLiteral(fullHeaderResponse, 'RFC822.HEADER'), Buffer.from([
      'From: Alice <alice@example.net>',
      'To: admin@lf.example',
      'Subject: LF-only imported',
      ' continuation',
      'Message-ID: <lf-only@example.net>',
      'Content-Type: text/plain; charset=UTF-8',
      'X-Not-Selected: private metadata',
      '',
      ''
    ].join('\r\n'), 'utf8'));

    const expectedBody = Buffer.from('LF-only body.\nSecond line.', 'utf8');
    const rfc822TextResponse = await client.commandBytes(
      'A5 UID FETCH 1 (RFC822.TEXT)',
      /A5 OK FETCH completed\r\n$/
    );
    assert.deepEqual(extractFetchLiteral(rfc822TextResponse, 'RFC822.TEXT'), expectedBody);

    const bodyTextResponse = await client.commandBytes(
      'A6 UID FETCH 1 (BODY.PEEK[TEXT])',
      /A6 OK FETCH completed\r\n$/
    );
    assert.deepEqual(extractFetchLiteral(bodyTextResponse, 'BODY[TEXT]'), expectedBody);

    const fullMessageResponse = await client.commandBytes(
      'A7 UID FETCH 1 (BODY.PEEK[])',
      /A7 OK FETCH completed\r\n$/
    );
    assert.deepEqual(extractFetchLiteral(fullMessageResponse, 'BODY[]'), rawMessageBytes);

    await client.command('A8 LOGOUT', /A8 OK/);
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('IMAP exposes standard folders expected by mainstream clients', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
  createMailboxFixture('folders.example', 'folders-user');

  const [server] = startMailboxAccessServers({
    hostname: 'mail.folders.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    assert.match(await client.command('A1 LOGIN "admin@folders.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);

    const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
    assert.match(listed, /\* LIST .* "INBOX"/);
    assert.match(listed, /\* LIST .*\\Sent.* "Sent"/);
    assert.match(listed, /\* LIST .*\\Drafts.* "Drafts"/);
    assert.match(listed, /\* LIST .*\\Trash.* "Trash"/);
    assert.match(listed, /\* LIST .*\\Junk.* "Junk"/);
    assert.match(listed, /\* LIST .*\\Archive.* "Archive"/);

    const selected = await client.command('A3 SELECT Sent', /A3 OK/);
    assert.match(selected, /\* 0 EXISTS/);
    await client.command('A4 LOGOUT', /A4 OK/);
    client.close();
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('IMAP uses Modified UTF-7 on the wire while storing Unicode folder names', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-utf7-test-')), 'mail-access-secret');
  const { user, mailbox } = createMailboxFixture('utf7.example', 'utf7-user');
  createInboundFolder(mailbox, '中文 & 项目');

  const [server] = startMailboxAccessServers({
    hostname: 'mail.utf7.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    await client.command('A1 LOGIN "admin@utf7.example" "mailbox-pass-123"', /A1 OK/);

    const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
    assert.match(listed, /"&Ti1lhw- &- &mHl27g-"/);
    assert.doesNotMatch(listed, /中文|项目/);
    const subscribed = await client.command('A2L LSUB "" "*"', /A2L OK/);
    assert.match(subscribed, /"&Ti1lhw- &- &mHl27g-"/);

    const selected = await client.command('A3 SELECT "&Ti1lhw- &- &mHl27g-"', /A3 OK/);
    assert.match(selected, /\* 0 EXISTS/);
    const status = await client.command('A4 STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES UNSEEN\)', /A4 OK/);
    assert.match(status, /\* STATUS "&Ti1lhw- &- &mHl27g-" \(MESSAGES 0 UNSEEN 0/);

    await client.command('A5 CREATE "&ZeVnLIqe-"', /A5 OK/);
    assert.equal(inboundFolderExists(mailbox, '日本語'), true);
    const rawMessage = [
      'From: Bob <bob@example.net>',
      'To: admin@utf7.example',
      'Subject: UTF-7 folder append',
      '',
      'Imported into a Unicode folder.'
    ].join('\r\n');
    await client.append(
      `A6 APPEND "&ZeVnLIqe-" {${Buffer.byteLength(rawMessage, 'utf8')}}`,
      rawMessage,
      /A6 OK/
    );
    assert.equal(listInboundMessages(user.id, { folder: '日本語' }).length, 1);

    await client.command('A7 LOGOUT', /A7 OK/);
    client.close();
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('IMAP APPEND stores sent messages in the Sent folder', async () => {
  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret');
  const { user } = createMailboxFixture('append.example', 'append-user');

  const [server] = startMailboxAccessServers({
    hostname: 'mail.append.example',
    imapEnabled: true,
    imapListeners: [{ port: 0, protocol: 'imap' }],
    pop3Enabled: false,
    pop3Listeners: [],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  let client;
  try {
    const sentMessage = [
      'From: Admin <admin@append.example>',
      'To: Bob <bob@example.net>',
      'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
      ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
      'Message-ID: <sent-copy@append.example>',
      'MIME-Version: 1.0',
      'Content-Type: multipart/alternative; boundary="sent-boundary"',
      '',
      '--sent-boundary',
      'Content-Type: text/plain; charset=UTF-8',
      'Content-Transfer-Encoding: base64',
      '',
      Buffer.from('工单正文', 'utf8').toString('base64'),
      '--sent-boundary',
      'Content-Type: text/html; charset=UTF-8',
      'Content-Transfer-Encoding: quoted-printable',
      '',
      '<p>Sent HTML body.</p>',
      '--sent-boundary--',
      ''
    ].join('\r\n');

    client = await connectClient(server.address().port);
    await client.readUntil(/\* OK .* IMAP ready\r\n/);
    assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
    await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/);

    const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/);
    assert.match(selectedSent, /\* 1 EXISTS/);
    const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/);
    assert.match(fetchedSent, /FLAGS \(\\Seen\)/);
    assert.match(fetchedSent, /Subject: =\?UTF-8\?Q\?/);
    assert.match(fetchedSent, /--sent-boundary/);

    const [storedSummary] = listInboundMessages(user.id, { folder: 'Sent' });
    const storedMessage = getInboundMessage(user.id, storedSummary.id);
    assert.equal(storedMessage.subject, '核云计算');
    assert.equal(storedMessage.textBody, '工单正文');
    assert.match(storedMessage.htmlBody, /Sent HTML body/);
    assert.equal(storedMessage.preview, '工单正文');
    assert.match(storedMessage.rawMessage, /--sent-boundary/);

    const latin1Message = Buffer.concat([
      Buffer.from([
        'From: Admin <admin@append.example>',
        'To: Bob <bob@example.net>',
        'Subject: Latin1 copy',
        'Content-Type: text/plain; charset=ISO-8859-1',
        'Content-Transfer-Encoding: 8bit',
        '',
        'caf'
      ].join('\r\n'), 'ascii'),
      Buffer.from([0xe9])
    ]);
    await client.append(`A5 APPEND Sent {${latin1Message.length}}`, latin1Message, /A5 OK/);
    const latin1Summary = listInboundMessages(user.id, { folder: 'Sent' })
      .find((message) => message.subject === 'Latin1 copy');
    assert.equal(getInboundMessage(user.id, latin1Summary.id).textBody, 'café');
    await client.command('A6 SELECT Sent', /A6 OK/);
    const latin1Fetch = await client.commandBytes('A7 UID FETCH 1:* (UID BODY.PEEK[])', /A7 OK/);
    assert.equal(latin1Fetch.includes(latin1Message), true);

    const selectedInbox = await client.command('A8 SELECT INBOX', /A8 OK/);
    assert.match(selectedInbox, /\* 0 EXISTS/);
    await client.command('A9 LOGOUT', /A9 OK/);
    client.close();
  } finally {
    client?.close();
    await closeServer(server);
  }
});

test('POP3 clients can retrieve and delete messages on quit', async () => {
  const database = initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
  const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
  const firstRawMessage = [
    'From: Bob <bob@example.net>',
    'To: admin@pop3.example',
    'Subject: POP3 hello',
    'Message-ID: <pop3-hello@example.net>',
    '',
    'Hello through POP3.'
  ].join('\r\n');
  const firstMessage = createInboundMessage(mailbox, {
    sender: 'bob@example.net',
    recipients: ['admin@pop3.example'],
    subject: 'POP3 hello',
    messageId: '<pop3-hello@example.net>',
    rawMessage: firstRawMessage,
    textBody: 'Hello through POP3.'
  });
  const latin1RawMessage = Buffer.concat([
    Buffer.from([
      'From: Alice <alice@example.net>',
      'To: admin@pop3.example',
      'Subject: Latin1 POP3',
      'Content-Type: text/plain; charset=ISO-8859-1',
      'Content-Transfer-Encoding: 8bit',
      '',
      'caf'
    ].join('\n'), 'ascii'),
    Buffer.from([0xe9])
  ]);
  createInboundMessage(mailbox, {
    sender: 'alice@example.net',
    recipients: ['admin@pop3.example'],
    subject: 'Latin1 POP3',
    rawMessage: latin1RawMessage.toString('latin1'),
    rawMessageBytes: latin1RawMessage,
    textBody: 'café'
  });

  const firstPop3Message = Buffer.from(`${firstRawMessage}\r\n`, 'utf8');
  const latin1Pop3Message = Buffer.concat([
    Buffer.from(latin1RawMessage.toString('latin1').replace(/\n/g, '\r\n'), 'latin1'),
    Buffer.from('\r\n')
  ]);
  const totalOctets = firstPop3Message.length + latin1Pop3Message.length;

  const [server] = startMailboxAccessServers({
    hostname: 'mail.pop3.example',
    imapEnabled: false,
    imapListeners: [],
    pop3Enabled: true,
    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
    allowInsecureAuth: true
  });
  await waitForListening(server);

  try {
    const client = await connectClient(server.address().port);
    await client.readUntil(/\+OK .* POP3 ready\r\n/);
    assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
    assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
    assert.match(await client.command('STAT', /\+OK \d+ \d+/), new RegExp(`\\+OK 2 ${totalOctets}`));
    const listed = await client.command('LIST', /\r\n\.\r\n/);
    assert.match(listed, new RegExp(`1 ${firstPop3Message.length}\\r\\n`));
    assert.match(listed, new RegExp(`2 ${latin1Pop3Message.length}\\r\\n`));
    assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
    database
      .prepare('UPDATE inbound_messages SET raw_message_bytes = ? WHERE id = ?')
      .run(Buffer.from(firstRawMessage.replace('Hello through POP3.', 'Hallo through POP3.'), 'utf8'), firstMessage.id);
    const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
    assert.match(retrieved, /Subject: POP3 hello/);
    assert.match(retrieved, /Hallo through POP3\./);
    assert.doesNotMatch(retrieved, /Hello through POP3\./);
    const latin1Retrieved = await client.commandBytes('RETR 2', /\r\n\.\r\n/);
    assert.deepEqual(latin1Retrieved, Buffer.concat([
      Buffer.from(`+OK ${latin1Pop3Message.length} octets\r\n`),
      latin1Pop3Message,
      Buffer.from('.\r\n')
    ]));
    assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
    assert.match(await client.command('DELE 2', /\+OK/), /deleted/);
    await client.command('QUIT', /\+OK Bye/);
    client.close();
    assert.equal(listInboundMessages(user.id).length, 0);
  } finally {
    await closeServer(server);
  }
});

test('POP3 AUTH PLAIN requires TLS when insecure authentication is disabled', async () => {
  const [server] = startMailboxAccessServers({
    hostname: 'mail.secure-pop3.example',
    imapEnabled: false,
    imapListeners: [],
    pop3Enabled: true,
    pop3Listeners: [{ port: 0, protocol: 'pop3' }],
    allowInsecureAuth: false
  });
  await waitForListening(server);

  let client;
  try {
    client = await connectClient(server.address().port);
    await client.readUntil(/\+OK .* POP3 ready\r\n/);
    const credentials = Buffer.from('\u0000user@example.com\u0000password').toString('base64');
    assert.equal(
      await client.command(`AUTH PLAIN ${credentials}`, /\+OK|\-ERR/),
      '-ERR Encryption required for authentication\r\n'
    );
  } finally {
    client?.close();
    await closeServer(server);
  }
});

function createMailboxFixture(domainName, username) {
  const user = createUser({ username, email: `${username}@example.com`, password: 'password123' });
  createDomain(user.id, {
    domain: domainName,
    selector: 'mh',
    verificationToken: 'verify',
    dkimPublic: 'public',
    dkimPrivate: 'private',
    senderHost: `mail.${domainName}`,
    sendingIp: '192.0.2.30',
    spfExtra: '',
    dmarcPolicy: 'none',
    dmarcRua: ''
  });
  const mailbox = createInboundMailbox(user.id, {
    address: `admin@${domainName}`,
    password: 'mailbox-pass-123'
  });
  return { user, mailbox };
}

function connectClient(port) {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection({ host: '127.0.0.1', port });
    socket.setTimeout(5000);
    let buffer = '';
    let rawBuffer = Buffer.alloc(0);
    const waiters = [];

    socket.on('data', (chunk) => {
      const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
      rawBuffer = Buffer.concat([rawBuffer, bytes]);
      buffer += bytes.toString('utf8');
      for (const waiter of [...waiters]) {
        if (waiter.pattern.test(buffer)) {
          waiters.splice(waiters.indexOf(waiter), 1);
          const output = buffer;
          const rawOutput = rawBuffer;
          buffer = '';
          rawBuffer = Buffer.alloc(0);
          waiter.resolve(waiter.raw ? rawOutput : output);
        }
      }
    });
    socket.once('connect', () => resolve({
      command(command, pattern) {
        socket.write(`${command}\r\n`);
        return this.readUntil(pattern);
      },
      commandBytes(command, pattern) {
        socket.write(`${command}\r\n`);
        return this.readUntil(pattern, true);
      },
      async append(command, literal, pattern) {
        socket.write(`${command}\r\n`);
        await this.readUntil(/^\+ /m);
        socket.write(literal);
        socket.write('\r\n');
        return this.readUntil(pattern);
      },
      readUntil(pattern, raw = false) {
        if (pattern.test(buffer)) {
          const output = buffer;
          const rawOutput = rawBuffer;
          buffer = '';
          rawBuffer = Buffer.alloc(0);
          return Promise.resolve(raw ? rawOutput : output);
        }
        return new Promise((waitResolve, waitReject) => {
          const waiter = {
            pattern,
            raw,
            resolve(output) {
              clearTimeout(waiter.timer);
              waitResolve(output);
            },
            reject(error) {
              clearTimeout(waiter.timer);
              waitReject(error);
            },
            timer: null
          };
          waiter.timer = setTimeout(() => {
            waiters.splice(waiters.indexOf(waiter), 1);
            waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
          }, 5000);
          waiters.push(waiter);
        });
      },
      close() {
        socket.destroy();
      }
    }));
    socket.once('error', reject);
    socket.once('timeout', () => reject(new Error('Mail access client timed out')));
  });
}

function extractFetchLiteral(response, label) {
  const bytes = Buffer.isBuffer(response) ? response : Buffer.from(response || '');
  const marker = Buffer.from(`${label} {`, 'ascii');
  const markerIndex = bytes.indexOf(marker);
  assert.notEqual(markerIndex, -1, `Missing ${label} literal marker`);
  const sizeStart = markerIndex + marker.length;
  const sizeEndMarker = Buffer.from('}\r\n', 'ascii');
  const sizeEnd = bytes.indexOf(sizeEndMarker, sizeStart);
  assert.notEqual(sizeEnd, -1, `Missing ${label} literal size terminator`);
  const size = Number(bytes.subarray(sizeStart, sizeEnd).toString('ascii'));
  assert.equal(Number.isInteger(size) && size >= 0, true, `Invalid ${label} literal size`);
  const literalStart = sizeEnd + sizeEndMarker.length;
  const literalEnd = literalStart + size;
  assert.ok(literalEnd <= bytes.length, `Truncated ${label} literal`);
  assert.deepEqual(bytes.subarray(literalEnd, literalEnd + 5), Buffer.from('\r\n)\r\n', 'ascii'));
  return bytes.subarray(literalStart, literalEnd);
}

function assertImapSearchResult(response, expected) {
  assert.match(response, /^\S+ OK SEARCH completed\r?$/m);
  const match = response.match(/^\* SEARCH(?: ([0-9 ]+))?\r?$/m);
  assert.ok(match, `Missing SEARCH response in: ${response}`);
  const actual = String(match[1] || '')
    .split(/\s+/)
    .filter(Boolean)
    .map(Number);
  assert.deepEqual(actual, expected);
}

function waitForListening(server) {
  if (server.listening) return Promise.resolve();
  return new Promise((resolve) => server.once('listening', resolve));
}

function closeServer(server) {
  return new Promise((resolve, reject) => {
    server.close((error) => error ? reject(error) : resolve());
  });
}
