import { test, expect, type APIRequestContext } from '@playwright/test'; import { WORKER_URL, WORKER_URL_ENV_OFF, createTestAddress, deleteAddress, hashPassword, seedTestMail, } from '../../fixtures/test-helpers'; const addressHeaders = (jwt: string) => ({ Authorization: `Bearer ${jwt}` }); async function listAddressMails( request: APIRequestContext, jwt: string, state = 'all', baseUrl = WORKER_URL, ) { const response = await request.get( `${baseUrl}/api/mails?limit=100&offset=0&mail_state=${state}`, { headers: addressHeaders(jwt) }, ); expect(response.ok()).toBe(true); return await response.json(); } async function updateAddressMailState( request: APIRequestContext, jwt: string, ids: number[], state: string, ) { const response = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: addressHeaders(jwt), data: { ids, state }, }); expect(response.ok()).toBe(true); return await response.json(); } async function getStoredFlags(request: APIRequestContext, mailId: number) { const response = await request.get(`${WORKER_URL}/admin/test/mail_flags?mail_id=${mailId}`); expect(response.ok()).toBe(true); return (await response.json()).results as { mail_id: number; address_id: number; flag: number }[]; } test.describe('Mail Read Status', () => { test('new mail is unread and can be marked as read without changing other mailboxes', async ({ request }) => { const first = await createTestAddress(request, 'mail-flags-first'); const second = await createTestAddress(request, 'mail-flags-second'); try { const statesRes = await request.get(`${WORKER_URL}/api/mail-states`, { headers: { Authorization: `Bearer ${first.jwt}` }, }); expect(statesRes.ok()).toBe(true); expect((await statesRes.json()).results.map((state: { value: string }) => state.value)) .toEqual(['all', 'unread', 'read']); await seedTestMail(request, first.address, { subject: 'Unread mail' }); const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { headers: { Authorization: `Bearer ${first.jwt}` }, }); expect(listRes.ok()).toBe(true); const { results } = await listRes.json(); expect(results).toHaveLength(1); expect(results[0].flags).toBeUndefined(); expect(results[0].unread).toBe(true); expect(await getStoredFlags(request, results[0].id)).toEqual([{ mail_id: results[0].id, address_id: first.address_id, flag: 0, }]); const detailRes = await request.get(`${WORKER_URL}/api/mails/${results[0].id}`, { headers: addressHeaders(first.jwt), }); expect(detailRes.ok()).toBe(true); expect((await detailRes.json()).unread).toBe(true); const parsedDetailRes = await request.get(`${WORKER_URL}/api/parsed_mail/${results[0].id}`, { headers: addressHeaders(first.jwt), }); expect(parsedDetailRes.ok()).toBe(true); expect((await parsedDetailRes.json()).unread).toBe(true); const unreadRes = await request.get( `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, { headers: { Authorization: `Bearer ${first.jwt}` } }, ); expect((await unreadRes.json()).results).toHaveLength(1); const deniedRes = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${second.jwt}` }, data: { ids: [results[0].id], state: 'read' }, }); expect(deniedRes.ok()).toBe(true); expect((await deniedRes.json()).changes).toBe(0); const updateRes = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${first.jwt}` }, data: { ids: [results[0].id], state: 'read' }, }); expect(updateRes.ok()).toBe(true); const updateResult = await updateRes.json(); expect(updateResult.changes).toBe(1); expect(updateResult.results[0].unread).toBe(false); expect(await getStoredFlags(request, results[0].id)).toEqual([]); const updatedListRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { headers: { Authorization: `Bearer ${first.jwt}` }, }); expect((await updatedListRes.json()).results[0].unread).toBe(false); const unreadAfterUpdateRes = await request.get( `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`, { headers: { Authorization: `Bearer ${first.jwt}` } }, ); expect((await unreadAfterUpdateRes.json()).results).toHaveLength(0); const unreadStateRes = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${first.jwt}` }, data: { ids: [results[0].id], state: 'unread' }, }); expect(unreadStateRes.ok()).toBe(true); expect((await unreadStateRes.json()).results[0].unread).toBe(true); expect(await getStoredFlags(request, results[0].id)).toHaveLength(1); } finally { await deleteAddress(request, first.jwt); await deleteAddress(request, second.jwt); } }); test('supports batch and idempotent updates with accurate filters and cleanup', async ({ request }) => { const mailbox = await createTestAddress(request, 'mail-flags-batch'); try { for (let index = 0; index < 3; index += 1) { await seedTestMail(request, mailbox.address, { subject: `Flag batch ${index}` }); } const initial = await listAddressMails(request, mailbox.jwt); const ids = initial.results.map((mail: { id: number }) => mail.id); expect(initial.count).toBe(3); expect(initial.results.every((mail: { unread: boolean }) => mail.unread)).toBe(true); const firstUpdate = await updateAddressMailState(request, mailbox.jwt, ids.slice(0, 2), 'read'); expect(firstUpdate.changes).toBe(2); expect(firstUpdate.results).toHaveLength(2); expect(firstUpdate.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true); const duplicateUpdate = await updateAddressMailState(request, mailbox.jwt, ids.slice(0, 2), 'read'); expect(duplicateUpdate.changes).toBe(0); const read = await listAddressMails(request, mailbox.jwt, 'read'); expect(read.count).toBe(2); expect(read.results.map((mail: { id: number }) => mail.id).sort()).toEqual(ids.slice(0, 2).sort()); expect(read.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true); const unread = await listAddressMails(request, mailbox.jwt, 'unread'); expect(unread.count).toBe(1); expect(unread.results[0].id).toBe(ids[2]); expect(unread.results[0].unread).toBe(true); const duplicateUnread = await updateAddressMailState(request, mailbox.jwt, [ids[2]], 'unread'); expect(duplicateUnread.changes).toBe(0); const deleteRes = await request.delete(`${WORKER_URL}/api/mails/${ids[2]}`, { headers: addressHeaders(mailbox.jwt), }); expect(deleteRes.ok()).toBe(true); expect(await getStoredFlags(request, ids[2])).toEqual([]); expect((await updateAddressMailState(request, mailbox.jwt, [ids[2]], 'unread')).changes).toBe(0); } finally { await deleteAddress(request, mailbox.jwt); } }); test('rejects unsupported mail states', async ({ request }) => { const { jwt } = await createTestAddress(request, 'mail-flags-invalid'); try { for (const data of [ { ids: [1], state: 'invalid' }, { ids: [1] }, { ids: [], state: 'read' }, { ids: [0], state: 'read' }, { ids: [1.5], state: 'read' }, { ids: ['1'], state: 'read' }, { ids: Array.from({ length: 101 }, (_, index) => index + 1), state: 'read' }, ]) { const res = await request.patch(`${WORKER_URL}/api/mails/state`, { headers: { Authorization: `Bearer ${jwt}` }, data, }); expect(res.status()).toBe(400); } } finally { await deleteAddress(request, jwt); } }); test('mail without a flag relation is read by default', async ({ request }) => { const mailbox = await createTestAddress(request, 'mail-flags-history'); try { const seedRes = await request.post(`${WORKER_URL}/admin/test/seed_mail`, { data: { address: mailbox.address, raw: [ `From: sender@example.com`, `To: ${mailbox.address}`, `Subject: Historical mail`, `Message-ID: `, ``, `Historical body`, ].join('\r\n'), }, }); expect(seedRes.ok()).toBe(true); const list = async (state: string) => { const response = await request.get( `${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=${state}`, { headers: { Authorization: `Bearer ${mailbox.jwt}` } }, ); expect(response.ok()).toBe(true); return await response.json(); }; const historical = (await list('all')).results[0]; expect(historical.unread).toBe(false); expect(await getStoredFlags(request, historical.id)).toEqual([]); expect((await list('read')).results).toHaveLength(1); expect((await list('unread')).results).toHaveLength(0); } finally { await deleteAddress(request, mailbox.jwt); } }); test('user APIs query and mutate flags only for bound addresses', async ({ request }) => { const addresses: Awaited>[] = []; let outsider: Awaited> | undefined; let originalSettings: Record | undefined; let userId: number | undefined; try { const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`); expect(settingsRes.ok()).toBe(true); originalSettings = await settingsRes.json(); const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, { data: { ...originalSettings, enable: true, enableMailVerify: false, maxAddressCount: 0 }, }); expect(enableRes.ok()).toBe(true); const email = `mail-flags-user-${Date.now()}@test.example.com`; const password = hashPassword('mail-flags-password'); const registerRes = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password }, }); expect(registerRes.ok()).toBe(true); const loginRes = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password }, }); expect(loginRes.ok()).toBe(true); const { jwt: userJwt } = await loginRes.json(); const payload = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString('utf8')); userId = payload.user_id; addresses.push( await createTestAddress(request, 'mail-flags-user-a'), await createTestAddress(request, 'mail-flags-user-b'), ); outsider = await createTestAddress(request, 'mail-flags-user-outsider'); for (const mailbox of addresses) { const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, { headers: { ...addressHeaders(mailbox.jwt), 'x-user-token': userJwt, }, }); expect(bindRes.ok()).toBe(true); await seedTestMail(request, mailbox.address, { subject: `Bound ${mailbox.address}` }); } await seedTestMail(request, outsider.address, { subject: 'Outsider unread' }); const statesRes = await request.get(`${WORKER_URL}/user_api/mail-states`, { headers: { 'x-user-token': userJwt }, }); expect(statesRes.ok()).toBe(true); expect((await statesRes.json()).results.map((state: { value: string }) => state.value)) .toEqual(['all', 'unread', 'read']); const userList = async (state: string, address?: string) => { const addressQuery = address ? `&address=${encodeURIComponent(address)}` : ''; const response = await request.get( `${WORKER_URL}/user_api/mails?limit=20&offset=0&mail_state=${state}${addressQuery}`, { headers: { 'x-user-token': userJwt } }, ); expect(response.ok()).toBe(true); return await response.json(); }; const unread = await userList('unread'); expect(unread.count).toBe(2); expect(new Set(unread.results.map((mail: { address: string }) => mail.address))) .toEqual(new Set(addresses.map(mailbox => mailbox.address))); expect((await userList('unread', addresses[0].address)).results).toHaveLength(1); expect((await userList('unread', outsider.address)).results).toHaveLength(0); const firstMail = unread.results.find( (mail: { address: string }) => mail.address === addresses[0].address, ); const updateRes = await request.patch(`${WORKER_URL}/user_api/mails/state`, { headers: { 'x-user-token': userJwt }, data: { ids: [firstMail.id], state: 'read' }, }); expect(updateRes.ok()).toBe(true); expect((await updateRes.json()).results[0].unread).toBe(false); expect((await userList('read')).results.map((mail: { id: number }) => mail.id)) .toContain(firstMail.id); const outsiderMail = (await listAddressMails(request, outsider.jwt)).results[0]; const deniedRes = await request.patch(`${WORKER_URL}/user_api/mails/state`, { headers: { 'x-user-token': userJwt }, data: { ids: [outsiderMail.id], state: 'read' }, }); expect(deniedRes.ok()).toBe(true); expect((await deniedRes.json()).changes).toBe(0); expect((await listAddressMails(request, outsider.jwt, 'unread')).results).toHaveLength(1); } finally { await Promise.allSettled( [...addresses, outsider].filter((mailbox): mailbox is NonNullable => mailbox !== undefined) .map((mailbox) => deleteAddress(request, mailbox.jwt)), ); if (userId !== undefined) { await request.delete(`${WORKER_URL}/admin/users/${userId}`); } if (originalSettings) { await request.post(`${WORKER_URL}/admin/user_settings`, { data: originalSettings }); } } }); test('disabled feature keeps legacy mail responses and rejects state APIs', async ({ request }) => { test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured'); const createRes = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, { data: { name: `mail-flags-off-${Date.now()}`, domain: 'test.example.com' }, }); expect(createRes.ok()).toBe(true); const mailbox = await createRes.json(); try { const raw = [ `From: sender@example.com`, `To: ${mailbox.address}`, `Subject: Flags disabled`, `Message-ID: `, ``, `Disabled body`, ].join('\r\n'); const receiveRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, { data: { from: 'sender@example.com', to: mailbox.address, raw }, }); expect(receiveRes.ok()).toBe(true); const listRes = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, { headers: addressHeaders(mailbox.jwt), }); expect(listRes.ok()).toBe(true); const list = await listRes.json(); expect(list.results).toHaveLength(1); expect(list.results[0]).not.toHaveProperty('unread'); expect(list.results[0]).not.toHaveProperty('flags'); const statesRes = await request.get(`${WORKER_URL_ENV_OFF}/api/mail-states`, { headers: addressHeaders(mailbox.jwt), }); expect(statesRes.status()).toBe(403); const filterRes = await request.get( `${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0&mail_state=unread`, { headers: addressHeaders(mailbox.jwt) }, ); expect(filterRes.status()).toBe(403); const updateRes = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/state`, { headers: addressHeaders(mailbox.jwt), data: { ids: [list.results[0].id], state: 'read' }, }); expect(updateRes.status()).toBe(403); } finally { await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${mailbox.address_id}`); } }); });