mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-04 06:56:45 +08:00
refactor: store mail flags in sparse relation table
This commit is contained in:
@@ -1,11 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
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');
|
||||
@@ -28,6 +66,23 @@ test.describe('Mail Read Status', () => {
|
||||
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`,
|
||||
@@ -50,6 +105,7 @@ test.describe('Mail Read Status', () => {
|
||||
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}` },
|
||||
@@ -68,18 +124,68 @@ test.describe('Mail Read Status', () => {
|
||||
});
|
||||
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}` },
|
||||
@@ -91,4 +197,199 @@ test.describe('Mail Read Status', () => {
|
||||
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-mail@test>`,
|
||||
``,
|
||||
`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<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||
let originalSettings: Record<string, unknown> | 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<typeof mailbox> => 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: <flags-disabled-${Date.now()}@test>`,
|
||||
``,
|
||||
`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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail state browser flow', () => {
|
||||
test('opens, toggles, filters and marks the current page read', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-flags-browser');
|
||||
jwt = mailbox.jwt;
|
||||
const subjects = [`Unread A ${Date.now()}`, `Unread B ${Date.now()}`];
|
||||
for (const subject of subjects) {
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
await expect(page.getByText('Unread', { exact: true })).toHaveCount(2);
|
||||
|
||||
const openStateResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByText(subjects[0], { exact: true }).click();
|
||||
expect((await openStateResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toBeVisible();
|
||||
|
||||
const unreadAfterOpen = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterOpen.json()).results).toHaveLength(1);
|
||||
|
||||
const toggleResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||
expect((await toggleResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Read' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to List' }).click();
|
||||
const pageReadResponse = page.waitForResponse((response) => {
|
||||
if (new URL(response.url()).pathname !== '/api/mails/state') return false;
|
||||
if (response.request().method() !== 'PATCH') return false;
|
||||
const body = response.request().postDataJSON();
|
||||
return body.state === 'read' && body.ids.length === 2;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark This Page as Read' }).click();
|
||||
expect((await pageReadResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark This Page as Read' })).toBeHidden();
|
||||
|
||||
const unreadAfterPage = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterPage.json()).results).toHaveLength(0);
|
||||
|
||||
const unreadFilterResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && url.searchParams.get('mail_state') === 'unread';
|
||||
});
|
||||
const stateSelect = page.locator('.n-select').filter({ hasText: 'All Mail' }).first();
|
||||
await stateSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: /^Unread$/ }).click();
|
||||
expect((await unreadFilterResponse).ok()).toBe(true);
|
||||
await expect(page.getByText('Your inbox is empty')).toBeVisible();
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user