Skip to content
File

Blob: tests/worker/api/emails.test.ts

typescript436 lines
1import { env } from "cloudflare:test";
2import { beforeEach, describe, expect, it } from "vitest";
3import { createDb } from "@/worker/db";
4import { getRawStorageKey } from "@/worker/services/storage";
5import {
6 apiRequest,
7 resetWorkerState,
8 seedAdminCookieSession,
9 seedDomain,
10 seedEmail,
11 seedInbox,
12 seedSession,
13} from "./helpers";
14 
15describe("worker api /api/protected/inboxes/:address/emails", () => {
16 beforeEach(async () => {
17 await resetWorkerState();
18 });
19 
20 it("returns a paginated email listing with totals", async () => {
21 await seedDomain("mail.test");
22 const inbox = await seedInbox({
23 address: "reader@mail.test",
24 });
25 const token = await seedSession({
26 type: "user",
27 address: inbox.fullAddress,
28 });
29 
30 await seedEmail({
31 id: "email-oldest",
32 address: inbox.fullAddress,
33 inboxId: inbox.id,
34 subject: "Oldest",
35 receivedAt: new Date("2026-03-15T10:00:00.000Z"),
36 });
37 await seedEmail({
38 id: "email-middle",
39 address: inbox.fullAddress,
40 inboxId: inbox.id,
41 subject: "Middle",
42 receivedAt: new Date("2026-03-15T11:00:00.000Z"),
43 isRead: true,
44 });
45 await seedEmail({
46 id: "email-latest",
47 address: inbox.fullAddress,
48 inboxId: inbox.id,
49 subject: "Latest",
50 receivedAt: new Date("2026-03-15T12:00:00.000Z"),
51 attachments: [
52 {
53 filename: "invoice.txt",
54 contentType: "text/plain",
55 content: "attachment body",
56 },
57 ],
58 });
59 
60 const response = await apiRequest(
61 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails?includeTotal=1&page=0`,
62 {
63 token,
64 },
65 );
66 
67 expect(response.status).toBe(200);
68 await expect(response.json()).resolves.toEqual({
69 emails: [
70 expect.objectContaining({
71 id: "email-latest",
72 subject: "Latest",
73 isRead: false,
74 hasAttachments: true,
75 }),
76 expect.objectContaining({
77 id: "email-middle",
78 subject: "Middle",
79 isRead: true,
80 hasAttachments: false,
81 }),
82 expect.objectContaining({
83 id: "email-oldest",
84 subject: "Oldest",
85 isRead: false,
86 hasAttachments: false,
87 }),
88 ],
89 total: 3,
90 page: 0,
91 });
92 });
93 
94 it("marks an email as read when a user fetches the detail view", async () => {
95 await seedDomain("mail.test");
96 const inbox = await seedInbox({
97 address: "reader@mail.test",
98 });
99 const token = await seedSession({
100 type: "user",
101 address: inbox.fullAddress,
102 });
103 const seededEmail = await seedEmail({
104 address: inbox.fullAddress,
105 inboxId: inbox.id,
106 text: "Plain text body",
107 html: "<p>HTML body</p>",
108 isRead: false,
109 });
110 
111 const response = await apiRequest(
112 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}`,
113 {
114 token,
115 },
116 );
117 
118 expect(response.status).toBe(200);
119 
120 const payload = (await response.json()) as {
121 html: string | null;
122 id: string;
123 isRead: boolean;
124 text: string | null;
125 };
126 
127 expect(payload.id).toBe(seededEmail.emailId);
128 expect(payload.text).toBe("Plain text body");
129 expect(payload.html).toBe("<p>HTML body</p>");
130 expect(payload.isRead).toBe(true);
131 
132 const db = createDb(env.DB.withSession("first-primary"));
133 const updatedEmail = await db.query.emails.findFirst({
134 where: (table, { eq }) => eq(table.id, seededEmail.emailId),
135 });
136 
137 expect(updatedEmail?.isRead).toBe(true);
138 });
139 
140 it("does not mark an email as read when an admin inspects the detail view", async () => {
141 await seedDomain("mail.test");
142 const inbox = await seedInbox({
143 address: "reader@mail.test",
144 });
145 const { cookie } = await seedAdminCookieSession();
146 const seededEmail = await seedEmail({
147 address: inbox.fullAddress,
148 inboxId: inbox.id,
149 text: "Plain text body",
150 isRead: false,
151 });
152 
153 const response = await apiRequest(
154 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}?admin=1`,
155 {
156 cookie,
157 },
158 );
159 
160 expect(response.status).toBe(200);
161 await expect(response.json()).resolves.toEqual(
162 expect.objectContaining({
163 id: seededEmail.emailId,
164 isRead: false,
165 text: "Plain text body",
166 }),
167 );
168 
169 const db = createDb(env.DB.withSession("first-primary"));
170 const storedEmail = await db.query.emails.findFirst({
171 where: (table, { eq }) => eq(table.id, seededEmail.emailId),
172 });
173 
174 expect(storedEmail?.isRead).toBe(false);
175 });
176 
177 it("returns 404 for a missing email detail", async () => {
178 await seedDomain("mail.test");
179 const inbox = await seedInbox({
180 address: "reader@mail.test",
181 });
182 const token = await seedSession({
183 type: "user",
184 address: inbox.fullAddress,
185 });
186 
187 const response = await apiRequest(
188 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/missing-email`,
189 {
190 token,
191 },
192 );
193 
194 expect(response.status).toBe(404);
195 await expect(response.json()).resolves.toEqual({
196 error: "Email not found",
197 });
198 });
199 
200 it("rejects raw email download for non-admin sessions", async () => {
201 await seedDomain("mail.test");
202 const inbox = await seedInbox({
203 address: "reader@mail.test",
204 });
205 const token = await seedSession({
206 type: "user",
207 address: inbox.fullAddress,
208 });
209 const seededEmail = await seedEmail({
210 address: inbox.fullAddress,
211 inboxId: inbox.id,
212 raw: "Raw MIME content",
213 });
214 
215 const response = await apiRequest(
216 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}/raw`,
217 {
218 token,
219 },
220 );
221 
222 expect(response.status).toBe(403);
223 await expect(response.json()).resolves.toEqual({
224 error: "Forbidden",
225 });
226 });
227 
228 it("returns raw email content for admin-inspect sessions", async () => {
229 await seedDomain("mail.test");
230 const inbox = await seedInbox({
231 address: "reader@mail.test",
232 });
233 const { cookie } = await seedAdminCookieSession();
234 const seededEmail = await seedEmail({
235 address: inbox.fullAddress,
236 inboxId: inbox.id,
237 raw: "Raw MIME content",
238 });
239 
240 const response = await apiRequest(
241 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}/raw?admin=1`,
242 {
243 cookie,
244 },
245 );
246 
247 expect(response.status).toBe(200);
248 expect(response.headers.get("content-type")).toBe("message/rfc822");
249 expect(response.headers.get("content-disposition")).toContain(`email-${seededEmail.emailId}.eml`);
250 await expect(response.text()).resolves.toBe("Raw MIME content");
251 });
252 
253 it("rejects temporary inbox email deletion in admin inspection mode", async () => {
254 await seedDomain("mail.test");
255 const inbox = await seedInbox({
256 address: "reader@mail.test",
257 });
258 const { cookie } = await seedAdminCookieSession();
259 const seededEmail = await seedEmail({
260 address: inbox.fullAddress,
261 inboxId: inbox.id,
262 });
263 
264 const response = await apiRequest(
265 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}?admin=1`,
266 {
267 method: "DELETE",
268 cookie,
269 },
270 );
271 
272 expect(response.status).toBe(403);
273 await expect(response.json()).resolves.toEqual({
274 error: "Admin inspection for temporary inboxes is read-only",
275 });
276 });
277 
278 it("allows admin-inspect to delete an email on a permanent inbox", async () => {
279 await seedDomain("mail.test");
280 const inbox = await seedInbox({
281 address: "admin@mail.test",
282 isPermanent: true,
283 });
284 const { cookie } = await seedAdminCookieSession();
285 const seededEmail = await seedEmail({
286 address: inbox.fullAddress,
287 inboxId: inbox.id,
288 });
289 
290 const response = await apiRequest(
291 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}?admin=1`,
292 {
293 method: "DELETE",
294 cookie,
295 },
296 );
297 
298 expect(response.status).toBe(200);
299 
300 const db = createDb(env.DB.withSession("first-primary"));
301 const stored = await db.query.emails.findFirst({
302 where: (table, { eq }) => eq(table.id, seededEmail.emailId),
303 });
304 expect(stored).toBeUndefined();
305 });
306 
307 it("rejects cross-origin admin-inspect email deletion", async () => {
308 await seedDomain("mail.test");
309 const inbox = await seedInbox({
310 address: "admin@mail.test",
311 isPermanent: true,
312 });
313 const { cookie } = await seedAdminCookieSession();
314 const seededEmail = await seedEmail({
315 address: inbox.fullAddress,
316 inboxId: inbox.id,
317 });
318 
319 const response = await apiRequest(
320 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}?admin=1`,
321 {
322 method: "DELETE",
323 cookie,
324 origin: "https://attacker.example",
325 },
326 );
327 
328 expect(response.status).toBe(403);
329 });
330 
331 it("deletes an email and its stored objects for the owning user", async () => {
332 await seedDomain("mail.test");
333 const inbox = await seedInbox({
334 address: "reader@mail.test",
335 });
336 const token = await seedSession({
337 type: "user",
338 address: inbox.fullAddress,
339 });
340 const seededEmail = await seedEmail({
341 address: inbox.fullAddress,
342 inboxId: inbox.id,
343 text: "Plain text body",
344 attachments: [
345 {
346 filename: "invoice.txt",
347 contentType: "text/plain",
348 content: "attachment body",
349 },
350 ],
351 });
352 
353 const response = await apiRequest(
354 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}`,
355 {
356 method: "DELETE",
357 token,
358 },
359 );
360 
361 expect(response.status).toBe(200);
362 await expect(response.json()).resolves.toEqual({ ok: true });
363 
364 const db = createDb(env.DB.withSession("first-primary"));
365 const deletedEmail = await db.query.emails.findFirst({
366 where: (table, { eq }) => eq(table.id, seededEmail.emailId),
367 });
368 
369 expect(deletedEmail).toBeUndefined();
370 expect(await env.STORAGE.get(seededEmail.bodyKey)).toBeNull();
371 expect(await env.STORAGE.get(getRawStorageKey(seededEmail.emailId))).toBeNull();
372 expect(await env.STORAGE.get(seededEmail.attachments[0]?.storageKey ?? "missing")).toBeNull();
373 });
374 
375 it("returns attachment content when present", async () => {
376 await seedDomain("mail.test");
377 const inbox = await seedInbox({
378 address: "reader@mail.test",
379 });
380 const token = await seedSession({
381 type: "user",
382 address: inbox.fullAddress,
383 });
384 const seededEmail = await seedEmail({
385 address: inbox.fullAddress,
386 inboxId: inbox.id,
387 attachments: [
388 {
389 filename: "invoice.txt",
390 contentType: "text/plain",
391 content: "attachment body",
392 },
393 ],
394 });
395 
396 const response = await apiRequest(
397 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}/attachments/${seededEmail.attachments[0]?.id}`,
398 {
399 token,
400 },
401 );
402 
403 expect(response.status).toBe(200);
404 expect(response.headers.get("content-type")).toBe("text/plain");
405 expect(response.headers.get("content-disposition")).toContain("invoice.txt");
406 await expect(response.text()).resolves.toBe("attachment body");
407 });
408 
409 it("returns 404 for a missing attachment on an existing email", async () => {
410 await seedDomain("mail.test");
411 const inbox = await seedInbox({
412 address: "reader@mail.test",
413 });
414 const token = await seedSession({
415 type: "user",
416 address: inbox.fullAddress,
417 });
418 const seededEmail = await seedEmail({
419 address: inbox.fullAddress,
420 inboxId: inbox.id,
421 });
422 
423 const response = await apiRequest(
424 `/api/protected/inboxes/${encodeURIComponent(inbox.fullAddress)}/emails/${seededEmail.emailId}/attachments/missing-attachment`,
425 {
426 token,
427 },
428 );
429 
430 expect(response.status).toBe(404);
431 await expect(response.json()).resolves.toEqual({
432 error: "Attachment not found",
433 });
434 });
435});