Skip to content
File

Blob: src/worker/api/emails.ts

typescript185 lines
1import { and, count, desc, eq } from "drizzle-orm";
2import type { Hono } from "hono";
3import { EMAIL_PAGE_SIZE, EmailPage, ErrorResponse, OkResponse } from "@/shared/contracts";
4import { attachments, emails } from "@/worker/db/schema";
5import { requireInboxRouteAccess } from "@/worker/middleware/auth";
6import { toEmailDetail, toEmailSummary } from "@/worker/serializers/email";
7import { deleteEmailWithStorage } from "@/worker/services/inbox";
8import { getRawStorageKey, readEmailBody } from "@/worker/services/storage";
9import type { AppBindings } from "@/worker/types";
10 
11export function registerEmailRoutes(app: Hono<AppBindings>) {
12 app.get("/api/protected/inboxes/:address/emails", requireInboxRouteAccess, async (c) => {
13 const inbox = c.get("inbox");
14 const db = c.get("db");
15 const page = Number.parseInt(c.req.query("page") ?? "0", 10);
16 const includeTotal = c.req.query("includeTotal") === "1";
17 const currentPage = Number.isFinite(page) && page >= 0 ? page : 0;
18 
19 const emailRows = await db
20 .select({
21 id: emails.id,
22 recipientAddress: emails.recipientAddress,
23 fromAddress: emails.fromAddress,
24 fromName: emails.fromName,
25 subject: emails.subject,
26 receivedAt: emails.receivedAt,
27 isRead: emails.isRead,
28 hasAttachments: emails.hasAttachments,
29 sizeBytes: emails.sizeBytes,
30 })
31 .from(emails)
32 .where(eq(emails.inboxId, inbox.id))
33 .orderBy(desc(emails.receivedAt), desc(emails.id))
34 .limit(EMAIL_PAGE_SIZE)
35 .offset(currentPage * EMAIL_PAGE_SIZE);
36 
37 const total = includeTotal
38 ? ((await db.select({ total: count() }).from(emails).where(eq(emails.inboxId, inbox.id)))[0]?.total ?? 0)
39 : null;
40 
41 return c.json(
42 EmailPage.create({
43 emails: emailRows.map(toEmailSummary),
44 total,
45 page: currentPage,
46 }),
47 );
48 });
49 
50 app.get("/api/protected/inboxes/:address/emails/:id", requireInboxRouteAccess, async (c) => {
51 const inbox = c.get("inbox");
52 const session = c.get("session");
53 const db = c.get("db");
54 const emailId = c.req.param("id");
55 
56 const emailRecord = await db.query.emails.findFirst({
57 where: and(eq(emails.id, emailId), eq(emails.inboxId, inbox.id)),
58 with: {
59 attachments: true,
60 },
61 });
62 
63 if (!emailRecord) {
64 return c.json(ErrorResponse.create({ error: "Email not found" }), 404);
65 }
66 
67 const body = await readEmailBody(c.env.STORAGE, emailRecord.bodyKey);
68 const shouldMarkRead = session.type !== "admin";
69 
70 if (shouldMarkRead && !emailRecord.isRead) {
71 await db.update(emails).set({ isRead: true }).where(eq(emails.id, emailRecord.id));
72 }
73 
74 return c.json(toEmailDetail(emailRecord, body, shouldMarkRead ? true : emailRecord.isRead));
75 });
76 
77 app.get("/api/protected/inboxes/:address/emails/:id/raw", requireInboxRouteAccess, async (c) => {
78 const inbox = c.get("inbox");
79 const session = c.get("session");
80 const db = c.get("db");
81 const emailId = c.req.param("id");
82 
83 if (session.type !== "admin") {
84 return c.json(ErrorResponse.create({ error: "Forbidden" }), 403);
85 }
86 
87 const emailRecord = await db.query.emails.findFirst({
88 where: and(eq(emails.id, emailId), eq(emails.inboxId, inbox.id)),
89 });
90 
91 if (!emailRecord) {
92 return c.json(ErrorResponse.create({ error: "Email not found" }), 404);
93 }
94 
95 const object = await c.env.STORAGE.get(getRawStorageKey(emailRecord.id));
96 if (!object?.body) {
97 return c.json(ErrorResponse.create({ error: "Raw email is missing from storage" }), 404);
98 }
99 
100 const headers = new Headers();
101 headers.set("content-type", "message/rfc822");
102 headers.set("content-disposition", `attachment; filename="email-${emailRecord.id}.eml"`);
103 
104 return new Response(object.body, {
105 status: 200,
106 headers,
107 });
108 });
109 
110 app.delete("/api/protected/inboxes/:address/emails/:id", requireInboxRouteAccess, async (c) => {
111 const inbox = c.get("inbox");
112 const session = c.get("session");
113 const db = c.get("db");
114 const emailId = c.req.param("id");
115 
116 if (session.type === "admin" && !inbox.isPermanent) {
117 return c.json(ErrorResponse.create({ error: "Admin inspection for temporary inboxes is read-only" }), 403);
118 }
119 
120 const emailRecord = await db.query.emails.findFirst({
121 where: and(eq(emails.id, emailId), eq(emails.inboxId, inbox.id)),
122 with: {
123 attachments: true,
124 },
125 });
126 
127 if (!emailRecord) {
128 return c.json(ErrorResponse.create({ error: "Email not found" }), 404);
129 }
130 
131 await deleteEmailWithStorage(c.env, emailRecord.id, db);
132 
133 return c.json(OkResponse.create({ ok: true }));
134 });
135 
136 app.get("/api/protected/inboxes/:address/emails/:id/attachments/:attId", requireInboxRouteAccess, async (c) => {
137 const inbox = c.get("inbox");
138 const db = c.get("db");
139 const emailId = c.req.param("id");
140 const attachmentId = c.req.param("attId");
141 
142 const attachment = await db
143 .select({
144 id: attachments.id,
145 filename: attachments.filename,
146 contentType: attachments.contentType,
147 storageKey: attachments.storageKey,
148 })
149 .from(attachments)
150 .innerJoin(emails, eq(attachments.emailId, emails.id))
151 .where(and(eq(attachments.id, attachmentId), eq(emails.id, emailId), eq(emails.inboxId, inbox.id)))
152 .limit(1);
153 
154 const attachmentRecord = attachment[0];
155 if (!attachmentRecord) {
156 const emailRecord = await db.query.emails.findFirst({
157 where: and(eq(emails.id, emailId), eq(emails.inboxId, inbox.id)),
158 });
159 
160 if (!emailRecord) {
161 return c.json(ErrorResponse.create({ error: "Email not found" }), 404);
162 }
163 
164 return c.json(ErrorResponse.create({ error: "Attachment not found" }), 404);
165 }
166 
167 const object = await c.env.STORAGE.get(attachmentRecord.storageKey);
168 if (!object?.body) {
169 return c.json(ErrorResponse.create({ error: "Attachment is missing from storage" }), 404);
170 }
171 
172 const headers = new Headers();
173 headers.set("content-type", attachmentRecord.contentType ?? "application/octet-stream");
174 headers.set(
175 "content-disposition",
176 `attachment; filename="${(attachmentRecord.filename ?? "attachment.bin").replace(/\"/g, "")}"`,
177 );
178
179 return new Response(object.body, {
180 status: 200,
181 headers,
182 });
183 });
184}
185