mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-11 18:10:01 +08:00
refactor: modularize IMAP server with fixes and E2E tests - Modularize IMAP server into imap_server, imap_mailbox, imap_message, imap_http_client, parse_email, config, models - Support dual login: JWT token and address+password via backend - Add STARTTLS support with configurable TLS cert/key - Fix FETCH/STORE returning UID instead of sequence number (RFC 3501) - Implement IMessageFile.open() for correct BODY[] raw MIME delivery - Add UIDNEXT to SELECT response via _cbSelectWork override - Use per-restart UIDVALIDITY to force client resync - Pass raw MIME to SimpleMessage for accurate RFC822.SIZE - Fix SENT mailbox returning empty source - Handle CREATE command gracefully for Thunderbird compatibility - Add IMAP E2E tests: auth, LIST, SELECT, STATUS, FETCH, SEARCH, STORE, UID FETCH, BODY[] integrity, size, seq numbers, SENT mailbox - Add SMTP E2E tests using nodemailer: send plain/HTML, auth failure, sendbox verification - Add sendTestMail helper using admin/send_mail Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
275 lines
8.7 KiB
TypeScript
275 lines
8.7 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { ImapFlow } from 'imapflow';
|
|
import { createTestAddress, seedTestMail, sendTestMail, deleteAddress, deleteAllMailpitMessages, onMailpitMessage } from '../../fixtures/test-helpers';
|
|
|
|
const IMAP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
|
|
const IMAP_PORT = parseInt(process.env.SMTP_PROXY_IMAP_PORT || '11143', 10);
|
|
|
|
function createClient(user: string, pass: string) {
|
|
return new ImapFlow({
|
|
host: IMAP_HOST,
|
|
port: IMAP_PORT,
|
|
secure: false,
|
|
auth: { user, pass },
|
|
logger: false,
|
|
});
|
|
}
|
|
|
|
test.describe('IMAP Proxy', () => {
|
|
let jwt: string;
|
|
let address: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
({ jwt, address } = await createTestAddress(request, 'imap-e2e'));
|
|
await seedTestMail(request, address, { subject: 'IMAP Test 1', text: 'First test email' });
|
|
await seedTestMail(request, address, { subject: 'IMAP Test 2', text: 'Second test email' });
|
|
});
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
await deleteAddress(request, jwt);
|
|
});
|
|
|
|
test('login with JWT token', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
expect(client.usable).toBe(true);
|
|
await client.logout();
|
|
});
|
|
|
|
test('login with wrong password fails', async () => {
|
|
const client = createClient(address, 'wrong-password');
|
|
await expect(client.connect()).rejects.toThrow();
|
|
});
|
|
|
|
test('LIST returns INBOX', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const mailboxes = await client.list();
|
|
const names = mailboxes.map(m => m.path);
|
|
expect(names).toContain('INBOX');
|
|
await client.logout();
|
|
});
|
|
|
|
test('SELECT INBOX returns message count', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
expect(client.mailbox).toBeTruthy();
|
|
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('STATUS returns MESSAGES and UIDNEXT', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const status = await client.status('INBOX', { messages: true, uidNext: true });
|
|
expect(status.messages).toBeGreaterThanOrEqual(2);
|
|
expect(status.uidNext).toBeGreaterThan(0);
|
|
await client.logout();
|
|
});
|
|
|
|
test('FETCH headers returns Subject', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const msg = await client.fetchOne('1', { headers: true });
|
|
const headers = msg.headers.toString();
|
|
expect(headers).toContain('Subject:');
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('FETCH body returns content', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const msg = await client.fetchOne('1', { source: true });
|
|
expect(msg.source.length).toBeGreaterThan(0);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('SEARCH ALL returns message numbers', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const results = await client.search({ all: true });
|
|
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('STORE sets flags', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const result = await client.messageFlagsAdd('1', ['\\Seen']);
|
|
expect(result).toBe(true);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('UID FETCH works', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const results = await client.search({ all: true });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true });
|
|
expect(msg.uid).toBe(results[0]);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('FETCH source contains valid MIME with Content-Type and seeded body', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const msg = await client.fetchOne('1', { source: true, envelope: true });
|
|
const source = msg.source.toString('utf-8');
|
|
expect(source).toContain('Content-Type:');
|
|
expect(source).toContain('Subject:');
|
|
// No duplicate From headers (regression: getBodyFile returned full MIME)
|
|
const fromMatches = source.match(/^From:/gm);
|
|
expect(fromMatches).toHaveLength(1);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('RFC822.SIZE matches actual source length', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const msg = await client.fetchOne('1', { source: true, size: true });
|
|
const source = msg.source.toString('utf-8');
|
|
expect(msg.size).toBe(Buffer.byteLength(source, 'utf-8'));
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('FETCH all messages returns correct sequence numbers', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
const messages: { seq: number; uid: number }[] = [];
|
|
for await (const msg of client.fetch('1:*', { uid: true })) {
|
|
messages.push({ seq: msg.seq, uid: msg.uid });
|
|
}
|
|
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
// Sequence numbers must be consecutive starting from 1
|
|
for (let i = 0; i < messages.length; i++) {
|
|
expect(messages[i].seq).toBe(i + 1);
|
|
}
|
|
// UIDs must be strictly ascending
|
|
for (let i = 1; i < messages.length; i++) {
|
|
expect(messages[i].uid).toBeGreaterThan(messages[i - 1].uid);
|
|
}
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('LIST returns SENT mailbox', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const mailboxes = await client.list();
|
|
const names = mailboxes.map(m => m.path);
|
|
expect(names).toContain('SENT');
|
|
await client.logout();
|
|
});
|
|
|
|
test('SELECT INBOX includes UIDVALIDITY and UIDNEXT', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('INBOX');
|
|
try {
|
|
expect(client.mailbox!.uidValidity).toBeGreaterThan(0);
|
|
expect(client.mailbox!.uidNext).toBeGreaterThan(0);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
});
|
|
|
|
test.describe('IMAP Proxy — SENT mailbox', () => {
|
|
let jwt: string;
|
|
let address: string;
|
|
const sentSubject = `IMAP Sent Test ${Date.now()}`;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
await deleteAllMailpitMessages(request);
|
|
({ jwt, address } = await createTestAddress(request, 'imap-sent'));
|
|
|
|
const listener = onMailpitMessage((m) => m.Subject === sentSubject);
|
|
await listener.ready;
|
|
|
|
await sendTestMail(request, address, {
|
|
to_mail: `recipient@test.example.com`,
|
|
subject: sentSubject,
|
|
content: 'E2E sent mail body',
|
|
});
|
|
await listener.message;
|
|
});
|
|
|
|
test.afterAll(async ({ request }) => {
|
|
await deleteAddress(request, jwt);
|
|
});
|
|
|
|
test('SELECT SENT returns message count', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('SENT');
|
|
try {
|
|
expect(client.mailbox).toBeTruthy();
|
|
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(1);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
|
|
test('FETCH SENT source contains valid MIME', async () => {
|
|
const client = createClient(address, jwt);
|
|
await client.connect();
|
|
const lock = await client.getMailboxLock('SENT');
|
|
try {
|
|
const msg = await client.fetchOne('1', { source: true, envelope: true });
|
|
const source = msg.source.toString('utf-8');
|
|
expect(source.length).toBeGreaterThan(50);
|
|
expect(source).toContain('Content-Type:');
|
|
expect(source).toContain('Subject:');
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
await client.logout();
|
|
});
|
|
});
|