@open-workspace/email 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/alchemy.ts +126 -0
- package/containers/office-preview/Dockerfile +10 -0
- package/index.ts +3 -0
- package/migrations/0000_email.sql +53 -0
- package/migrations/meta/0000_snapshot.json +408 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +91 -4
- package/shared/attachment-preview.ts +67 -0
- package/shared/mail-authentication.ts +145 -0
- package/shared/mail-feedback.ts +90 -0
- package/shared/mail-headers.ts +250 -0
- package/shared/mail-received.ts +298 -0
- package/shared/mail-remote.ts +17 -0
- package/shared/mail-rules.ts +156 -0
- package/shared/mail-sender-intel.ts +125 -0
- package/shared/mail-translation.ts +2 -0
- package/shared/mail.ts +179 -0
- package/shared/office-preview.ts +26 -0
- package/worker/admin/report-routes.ts +164 -0
- package/worker/bindings.d.ts +11 -0
- package/worker/db/schema.ts +97 -0
- package/worker/mail/access.ts +30 -0
- package/worker/mail/ai-classification.ts +119 -0
- package/worker/mail/ai-translation.ts +31 -0
- package/worker/mail/auth-headers.ts +557 -0
- package/worker/mail/bimi.ts +172 -0
- package/worker/mail/classification-signals.ts +116 -0
- package/worker/mail/conversation-cursor.ts +43 -0
- package/worker/mail/delivery-events.ts +99 -0
- package/worker/mail/delivery-facts.ts +122 -0
- package/worker/mail/demo-routes.ts +33 -0
- package/worker/mail/doh.ts +74 -0
- package/worker/mail/downloads.ts +47 -0
- package/worker/mail/header-metadata.ts +77 -0
- package/worker/mail/impersonation.ts +175 -0
- package/worker/mail/inbound-delivery.ts +22 -0
- package/worker/mail/inbound-threading.ts +48 -0
- package/worker/mail/inbound.ts +255 -0
- package/worker/mail/ip-intel.ts +135 -0
- package/worker/mail/link-domains.ts +57 -0
- package/worker/mail/mailbox-deletion.ts +18 -0
- package/worker/mail/mailbox-directory.ts +73 -0
- package/worker/mail/mailing-list.ts +120 -0
- package/worker/mail/message-security.ts +190 -0
- package/worker/mail/message-view.ts +63 -0
- package/worker/mail/office-conversion.ts +38 -0
- package/worker/mail/office-preview-container.ts +57 -0
- package/worker/mail/office-preview-queue.ts +49 -0
- package/worker/mail/org-domain.ts +15 -0
- package/worker/mail/outbound-content.ts +208 -0
- package/worker/mail/outbound-delivery.ts +83 -0
- package/worker/mail/outbound-service.ts +145 -0
- package/worker/mail/outbound-threading.ts +70 -0
- package/worker/mail/outbound.ts +446 -0
- package/worker/mail/outgoing-context.ts +122 -0
- package/worker/mail/participants.ts +115 -0
- package/worker/mail/push-notifications.ts +484 -0
- package/worker/mail/quote-parser.ts +50 -0
- package/worker/mail/r2-presigned-urls.ts +69 -0
- package/worker/mail/receive.ts +59 -0
- package/worker/mail/recipients.ts +34 -0
- package/worker/mail/remote-proxy.ts +140 -0
- package/worker/mail/reply-plan.ts +136 -0
- package/worker/mail/reported-messages.ts +68 -0
- package/worker/mail/rfc.ts +73 -0
- package/worker/mail/routes.ts +1434 -0
- package/worker/mail/safety-routes.ts +350 -0
- package/worker/mail/schemas.ts +339 -0
- package/worker/mail/search.ts +14 -0
- package/worker/mail/security-settings.ts +38 -0
- package/worker/mail/sender-intel.ts +189 -0
- package/worker/mail/spamhaus.ts +97 -0
- package/worker/mail/unsubscribe.ts +118 -0
- package/worker/mail/uploads.ts +537 -0
- package/worker/mail/webhook-source.ts +71 -0
- package/worker/mail/workspace-safety.ts +92 -0
- package/worker/mailbox/demo-html.ts +136 -0
- package/worker/mailbox/demo-seed.ts +79 -0
- package/worker/mailbox/demo.ts +978 -0
- package/worker/mailbox/folder-model.ts +72 -0
- package/worker/mailbox/index.ts +2508 -0
- package/worker/mailbox/legacy-migrations.ts +41 -0
- package/worker/mailbox/migrations.ts +10 -0
- package/worker/mailbox/model.ts +65 -0
- package/worker/mailbox/office-preview-jobs.ts +73 -0
- package/worker/mailbox/rules.ts +110 -0
- package/worker/mailbox/schema.ts +353 -0
- package/worker/mailbox/senders.ts +240 -0
- package/worker/mcp/mail-action-tools.ts +210 -0
- package/worker/mcp/mail-compose-tools.ts +196 -0
- package/worker/mcp/mail-query-tools.ts +196 -0
- package/worker/mcp/mail-safety-tools.ts +452 -0
- package/worker/mcp/tools.ts +15 -0
- package/worker/module.ts +59 -0
|
@@ -0,0 +1,1434 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zValidator } from "@hono/zod-validator";
|
|
3
|
+
import { attachmentPreview } from "../../shared/attachment-preview";
|
|
4
|
+
import { officePreviewKey } from "../../shared/office-preview";
|
|
5
|
+
import { desc, eq } from "drizzle-orm";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import {
|
|
8
|
+
baseSubject,
|
|
9
|
+
isComposerInlineImageContentType,
|
|
10
|
+
} from "../../shared/mail";
|
|
11
|
+
import { requireAuth } from "@open-workspace/core/worker/auth/middleware.ts";
|
|
12
|
+
import { createDb } from "@open-workspace/core/worker/db/client.ts";
|
|
13
|
+
import { domains, mailboxAccess, mailboxes } from "@open-workspace/core/worker/db/schema.ts";
|
|
14
|
+
import {
|
|
15
|
+
mailboxAddressSql,
|
|
16
|
+
mailboxKind,
|
|
17
|
+
mailboxKindOrderSql,
|
|
18
|
+
} from "@open-workspace/core/worker/db/mailboxes.ts";
|
|
19
|
+
import type { AppEnv } from "@open-workspace/core/worker/env.ts";
|
|
20
|
+
import { apiError } from "@open-workspace/core/worker/lib/http.ts";
|
|
21
|
+
import { createId, normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
|
|
22
|
+
import { mailboxStub } from "../mailbox";
|
|
23
|
+
import type { Email } from "../mailbox/schema";
|
|
24
|
+
import { getMailboxAccess } from "./access";
|
|
25
|
+
import { aiObjectRunner } from "@open-workspace/core/worker/ai/generate.ts";
|
|
26
|
+
import { aiFeatureEnabled, aiModel } from "@open-workspace/core/worker/ai/models.ts";
|
|
27
|
+
import { MAX_TRANSLATION_CHARACTERS } from "../../shared/mail-translation";
|
|
28
|
+
import { translateEmailSegments } from "./ai-translation";
|
|
29
|
+
import { listMailboxUsersByIds } from "./mailbox-directory";
|
|
30
|
+
import {
|
|
31
|
+
decodeConversationCursor,
|
|
32
|
+
encodeConversationCursor,
|
|
33
|
+
} from "./conversation-cursor";
|
|
34
|
+
import {
|
|
35
|
+
ComposerAttachmentLimitError,
|
|
36
|
+
preflightOutgoingAttachments,
|
|
37
|
+
} from "./outbound";
|
|
38
|
+
import {
|
|
39
|
+
deferEmailSentWebhook,
|
|
40
|
+
OutgoingRequestConflictError,
|
|
41
|
+
submitOutgoing,
|
|
42
|
+
} from "./outbound-service";
|
|
43
|
+
import {
|
|
44
|
+
OutgoingContextError,
|
|
45
|
+
resolveForwardContext,
|
|
46
|
+
resolveReplyContext,
|
|
47
|
+
} from "./outgoing-context";
|
|
48
|
+
import { participantLabels } from "./participants";
|
|
49
|
+
import { buildReplyPlan } from "./reply-plan";
|
|
50
|
+
import { headerAuthentication, withHeaderMetadata, withSenderIntel } from "./header-metadata";
|
|
51
|
+
import { messageSecurity, type SenderHistory } from "./message-security";
|
|
52
|
+
import { BIMI_LOGO_HEADERS, bimiLogoPath, bimiObjectKey } from "./bimi";
|
|
53
|
+
import { storedSenderIntel } from "./sender-intel";
|
|
54
|
+
import { emailListKey, emailSecurity, senderStatus, type MessageSafetyContext } from "./message-view";
|
|
55
|
+
import { workspaceBlockPatterns } from "./workspace-safety";
|
|
56
|
+
import { mailSafetyRoutes } from "./safety-routes";
|
|
57
|
+
import { spamhausDqsKey, workspaceIdentity, type WorkspaceIdentity } from "./security-settings";
|
|
58
|
+
import {
|
|
59
|
+
attachmentPreflightSchema,
|
|
60
|
+
bulkConversationActionSchema,
|
|
61
|
+
composeSchema,
|
|
62
|
+
conversationListQuerySchema,
|
|
63
|
+
conversationMessagesQuerySchema,
|
|
64
|
+
createFolderSchema,
|
|
65
|
+
createUploadSchema,
|
|
66
|
+
forwardSchema,
|
|
67
|
+
mailboxAiConfigurationSchema,
|
|
68
|
+
mailboxQuerySchema,
|
|
69
|
+
messageReadSchema,
|
|
70
|
+
messageTranslationSchema,
|
|
71
|
+
recipientSuggestionQuerySchema,
|
|
72
|
+
renameFolderSchema,
|
|
73
|
+
remoteProxyQuerySchema,
|
|
74
|
+
reorderFoldersSchema,
|
|
75
|
+
replySchema,
|
|
76
|
+
uploadIdSchema,
|
|
77
|
+
} from "./schemas";
|
|
78
|
+
import {
|
|
79
|
+
assertProxyableRemoteUrl,
|
|
80
|
+
fetchProxiedRemoteMedia,
|
|
81
|
+
} from "./remote-proxy";
|
|
82
|
+
import { checkRateLimit } from "@open-workspace/core/worker/lib/rate-limit.ts";
|
|
83
|
+
import {
|
|
84
|
+
createComposerUploadIntent,
|
|
85
|
+
DirectUploadUnavailableError,
|
|
86
|
+
discardComposerUploads,
|
|
87
|
+
finalizeComposerUpload,
|
|
88
|
+
storeComposerUploadContent,
|
|
89
|
+
UploadValidationError,
|
|
90
|
+
} from "./uploads";
|
|
91
|
+
import { signR2GetUrl } from "./r2-presigned-urls";
|
|
92
|
+
|
|
93
|
+
function mediaType(contentType: string) {
|
|
94
|
+
return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requestContentLength(value: string | undefined) {
|
|
98
|
+
return value === undefined ? undefined : Number(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function returnedObjectRange(object: R2ObjectBody, totalBytes: number) {
|
|
102
|
+
if (!object.range) return { offset: 0, length: totalBytes };
|
|
103
|
+
const suffix = "suffix" in object.range ? object.range.suffix : undefined;
|
|
104
|
+
if (typeof suffix === "number") {
|
|
105
|
+
const length = Math.min(suffix, totalBytes);
|
|
106
|
+
return { offset: totalBytes - length, length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const offset = "offset" in object.range ? object.range.offset ?? 0 : 0;
|
|
110
|
+
const available = Math.max(0, totalBytes - offset);
|
|
111
|
+
const requestedLength = "length" in object.range
|
|
112
|
+
? object.range.length
|
|
113
|
+
: undefined;
|
|
114
|
+
const length = Math.min(requestedLength ?? available, available);
|
|
115
|
+
return { offset, length };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function storedObjectResponse(input: {
|
|
119
|
+
object: R2ObjectBody;
|
|
120
|
+
contentType: string;
|
|
121
|
+
contentDisposition: string;
|
|
122
|
+
totalBytes: number;
|
|
123
|
+
rangeRequested: boolean;
|
|
124
|
+
}) {
|
|
125
|
+
const { object } = input;
|
|
126
|
+
const returnedRange = returnedObjectRange(object, input.totalBytes);
|
|
127
|
+
const headers = new Headers({
|
|
128
|
+
"accept-ranges": "bytes",
|
|
129
|
+
"cache-control": "private, no-store",
|
|
130
|
+
"content-disposition": input.contentDisposition,
|
|
131
|
+
"content-length": String(returnedRange.length),
|
|
132
|
+
"content-type": input.contentType,
|
|
133
|
+
"x-content-type-options": "nosniff",
|
|
134
|
+
});
|
|
135
|
+
if (!input.rangeRequested || !object.range) {
|
|
136
|
+
return new Response(object.body, { headers });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
headers.set(
|
|
140
|
+
"content-range",
|
|
141
|
+
`bytes ${returnedRange.offset}-${returnedRange.offset + returnedRange.length - 1}/${input.totalBytes}`,
|
|
142
|
+
);
|
|
143
|
+
return new Response(object.body, { status: 206, headers });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getStoredObject(
|
|
147
|
+
bucket: R2Bucket,
|
|
148
|
+
key: string,
|
|
149
|
+
range: string | undefined,
|
|
150
|
+
) {
|
|
151
|
+
return range
|
|
152
|
+
? bucket.get(key, { range: new Headers({ range }) })
|
|
153
|
+
: bucket.get(key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Our own route for a BIMI logo, when DMARC allowed one for this message. */
|
|
157
|
+
function senderLogoUrl(email: Email) {
|
|
158
|
+
const bimi = email.direction === "incoming" ? storedSenderIntel(email.senderIntelJson)?.bimi : null;
|
|
159
|
+
return bimi?.status === "ok" ? bimiLogoPath(bimi.domain) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toMessageSummary(email: Email) {
|
|
163
|
+
const from = email.fromJson[0] ?? { address: "unknown@invalid", name: null };
|
|
164
|
+
return {
|
|
165
|
+
id: email.id,
|
|
166
|
+
conversationId: email.conversationId,
|
|
167
|
+
direction: email.direction,
|
|
168
|
+
fromAddress: from.address,
|
|
169
|
+
fromName: from.name,
|
|
170
|
+
toAddresses: email.toJson.map((item) => item.address),
|
|
171
|
+
ccAddresses: email.ccJson.map((item) => item.address),
|
|
172
|
+
bccAddresses: email.bccJson.map((item) => item.address),
|
|
173
|
+
replyToAddresses: email.replyToJson.map((item) => item.address),
|
|
174
|
+
subject: email.subject,
|
|
175
|
+
preview: email.preview,
|
|
176
|
+
transportState: email.transportState,
|
|
177
|
+
transportError: email.transportError,
|
|
178
|
+
deliveryStatuses: email.deliveryStatusJson,
|
|
179
|
+
timelineAt: email.timelineAt,
|
|
180
|
+
senderLogoUrl: senderLogoUrl(email),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The workspace mailing list a received copy came through, if any. */
|
|
185
|
+
function workspaceListAddress(email: Email) {
|
|
186
|
+
return email.direction === "incoming"
|
|
187
|
+
&& email.listPostAddress
|
|
188
|
+
&& email.envelopeTo
|
|
189
|
+
&& normalizeMailboxAddress(email.envelopeTo) === email.listPostAddress
|
|
190
|
+
? email.listPostAddress
|
|
191
|
+
: null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toMessageDetail(
|
|
195
|
+
email: Email,
|
|
196
|
+
ownAddress: string,
|
|
197
|
+
identity: WorkspaceIdentity,
|
|
198
|
+
history: Record<string, SenderHistory>,
|
|
199
|
+
safety: MessageSafetyContext,
|
|
200
|
+
bodyHtml: string | null,
|
|
201
|
+
readReceipt: {
|
|
202
|
+
isRead: boolean;
|
|
203
|
+
viewedBy: Array<{
|
|
204
|
+
userId: string;
|
|
205
|
+
name: string;
|
|
206
|
+
readAt: Date;
|
|
207
|
+
}>;
|
|
208
|
+
},
|
|
209
|
+
) {
|
|
210
|
+
return {
|
|
211
|
+
...toMessageSummary(email),
|
|
212
|
+
bodyText: email.bodyText,
|
|
213
|
+
quotedText: email.quotedText,
|
|
214
|
+
bodyHtml,
|
|
215
|
+
aiClassification: email.aiClassificationJson,
|
|
216
|
+
security: emailSecurity(email, identity, history, safety),
|
|
217
|
+
sender: senderStatus(email, safety),
|
|
218
|
+
listId: email.listId,
|
|
219
|
+
viaList: workspaceListAddress(email),
|
|
220
|
+
unsubscription: safety.unsubscriptions[emailListKey(email) ?? ""] ?? null,
|
|
221
|
+
reportLabel: email.reportLabel,
|
|
222
|
+
screening: email.screeningJson,
|
|
223
|
+
senderIntel: email.direction === "incoming" ? storedSenderIntel(email.senderIntelJson) : null,
|
|
224
|
+
hasOriginal: Boolean(email.rawMimeR2Key),
|
|
225
|
+
attachments: email.attachmentsJson.map((file) => ({
|
|
226
|
+
id: file.id,
|
|
227
|
+
filename: file.filename,
|
|
228
|
+
contentType: file.contentType,
|
|
229
|
+
size: file.size,
|
|
230
|
+
contentId: file.contentId,
|
|
231
|
+
disposition: file.disposition,
|
|
232
|
+
pdfPreviewStatus: file.pdfPreviewStatus ?? null,
|
|
233
|
+
})),
|
|
234
|
+
replyPlan: buildReplyPlan(ownAddress, email),
|
|
235
|
+
...readReceipt,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function conversationLabel(ownAddress: string, latest: Email) {
|
|
240
|
+
const labels = participantLabels(
|
|
241
|
+
[...latest.fromJson, ...latest.toJson, ...latest.ccJson],
|
|
242
|
+
ownAddress,
|
|
243
|
+
);
|
|
244
|
+
if (!labels.length) return latest.fromJson[0]?.name || latest.fromJson[0]?.address || "Unknown sender";
|
|
245
|
+
if (labels.length <= 3) return labels.join(", ");
|
|
246
|
+
return `${labels.slice(0, 3).join(", ")} +${labels.length - 3}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function conversationSubject(subject: string) {
|
|
250
|
+
return baseSubject(subject) || "(no subject)";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function outboundTransportState(email: Email) {
|
|
254
|
+
if (email.transportState === "received") {
|
|
255
|
+
throw new Error("Outgoing message has an inbound transport state");
|
|
256
|
+
}
|
|
257
|
+
return email.transportState;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function accessibleMailbox(
|
|
261
|
+
env: Env,
|
|
262
|
+
userId: string,
|
|
263
|
+
mailboxId: string,
|
|
264
|
+
permission: "read" | "send",
|
|
265
|
+
) {
|
|
266
|
+
const access = await getMailboxAccess(createDb(env.DB), userId, mailboxId);
|
|
267
|
+
if (!access || (permission === "send" && !access.canSend)) return null;
|
|
268
|
+
return access;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function outgoingResponse(email: Email, detached: boolean) {
|
|
272
|
+
return {
|
|
273
|
+
ok: true as const,
|
|
274
|
+
messageId: email.id,
|
|
275
|
+
conversationId: email.conversationId,
|
|
276
|
+
transportState: outboundTransportState(email),
|
|
277
|
+
transportError: email.transportError,
|
|
278
|
+
detached,
|
|
279
|
+
externalizedAttachments: email.attachmentsJson.filter(
|
|
280
|
+
(file) => file.delivery === "download_link",
|
|
281
|
+
).length,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function outgoingFailure(
|
|
286
|
+
c: Parameters<typeof apiError>[0],
|
|
287
|
+
error: unknown,
|
|
288
|
+
) {
|
|
289
|
+
if (error instanceof DirectUploadUnavailableError) {
|
|
290
|
+
return apiError(c, 503, "UNAVAILABLE", error.message);
|
|
291
|
+
}
|
|
292
|
+
if (
|
|
293
|
+
error instanceof ComposerAttachmentLimitError
|
|
294
|
+
|| error instanceof UploadValidationError
|
|
295
|
+
) {
|
|
296
|
+
return apiError(c, 400, "BAD_REQUEST", error.message);
|
|
297
|
+
}
|
|
298
|
+
if (error instanceof OutgoingRequestConflictError) {
|
|
299
|
+
return apiError(c, 409, "CONFLICT", error.message);
|
|
300
|
+
}
|
|
301
|
+
if (error instanceof OutgoingContextError) {
|
|
302
|
+
return apiError(c, error.status, error.code, error.message);
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const mailRoutes = new Hono<AppEnv>()
|
|
308
|
+
.use("*", requireAuth)
|
|
309
|
+
.get("/mailboxes", async (c) => {
|
|
310
|
+
const userId = c.get("user").id;
|
|
311
|
+
const rows = await createDb(c.env.DB)
|
|
312
|
+
.select({
|
|
313
|
+
id: mailboxes.id,
|
|
314
|
+
address: mailboxAddressSql,
|
|
315
|
+
displayName: mailboxes.displayName,
|
|
316
|
+
ownerUserId: mailboxes.ownerUserId,
|
|
317
|
+
isPrimary: mailboxes.isPrimary,
|
|
318
|
+
canSend: mailboxAccess.canSend,
|
|
319
|
+
})
|
|
320
|
+
.from(mailboxAccess)
|
|
321
|
+
.innerJoin(mailboxes, eq(mailboxAccess.mailboxId, mailboxes.id))
|
|
322
|
+
.innerJoin(domains, eq(mailboxes.domainId, domains.id))
|
|
323
|
+
.where(eq(mailboxAccess.userId, userId))
|
|
324
|
+
.orderBy(
|
|
325
|
+
mailboxKindOrderSql,
|
|
326
|
+
desc(mailboxes.isPrimary),
|
|
327
|
+
mailboxes.displayName,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const rowsWithUnreadCounts = await Promise.all(rows.map(async (mailbox) => {
|
|
331
|
+
const inbox = await mailboxStub(c.env, mailbox.id)
|
|
332
|
+
.getFolderCounts(userId, "inbox");
|
|
333
|
+
const { ownerUserId, isPrimary: _isPrimary, ...publicMailbox } = mailbox;
|
|
334
|
+
return {
|
|
335
|
+
...publicMailbox,
|
|
336
|
+
kind: mailboxKind(ownerUserId),
|
|
337
|
+
unreadCount: inbox?.unreadCount ?? 0,
|
|
338
|
+
};
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
return c.json({ ok: true as const, mailboxes: rowsWithUnreadCounts });
|
|
342
|
+
})
|
|
343
|
+
.get("/mailboxes/:id/realtime", async (c) => {
|
|
344
|
+
if (c.get("authKind") !== "session") {
|
|
345
|
+
return apiError(
|
|
346
|
+
c,
|
|
347
|
+
401,
|
|
348
|
+
"UNAUTHORIZED",
|
|
349
|
+
"A browser session is required for realtime updates",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const origin = c.req.header("origin");
|
|
353
|
+
if (!origin || origin !== new URL(c.req.url).origin) {
|
|
354
|
+
return apiError(c, 403, "FORBIDDEN", "Cross-origin WebSocket rejected");
|
|
355
|
+
}
|
|
356
|
+
if (c.req.header("upgrade")?.toLowerCase() !== "websocket") {
|
|
357
|
+
return apiError(c, 426, "UPGRADE_REQUIRED", "WebSocket upgrade required");
|
|
358
|
+
}
|
|
359
|
+
const mailboxId = c.req.param("id");
|
|
360
|
+
const user = c.get("user");
|
|
361
|
+
if (!await accessibleMailbox(c.env, user.id, mailboxId, "read")) {
|
|
362
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
363
|
+
}
|
|
364
|
+
const headers = new Headers(c.req.raw.headers);
|
|
365
|
+
headers.set("x-openworkspace-user-id", user.id);
|
|
366
|
+
headers.set("x-openworkspace-session-id", c.get("sessionId"));
|
|
367
|
+
headers.set("x-openworkspace-session-token-hash", c.get("sessionTokenHash"));
|
|
368
|
+
headers.set(
|
|
369
|
+
"x-openworkspace-visibility",
|
|
370
|
+
c.req.query("visibility") === "hidden" ? "hidden" : "visible",
|
|
371
|
+
);
|
|
372
|
+
return mailboxStub(c.env, mailboxId).fetch(
|
|
373
|
+
new Request(c.req.raw, { headers }),
|
|
374
|
+
);
|
|
375
|
+
})
|
|
376
|
+
.post(
|
|
377
|
+
"/uploads",
|
|
378
|
+
zValidator(
|
|
379
|
+
"query",
|
|
380
|
+
mailboxQuerySchema.extend({
|
|
381
|
+
directOnly: z.literal("true").optional(),
|
|
382
|
+
}),
|
|
383
|
+
),
|
|
384
|
+
zValidator("json", createUploadSchema),
|
|
385
|
+
async (c) => {
|
|
386
|
+
const { mailboxId, directOnly } = c.req.valid("query");
|
|
387
|
+
const body = c.req.valid("json");
|
|
388
|
+
const user = c.get("user");
|
|
389
|
+
const access = await accessibleMailbox(c.env, user.id, mailboxId, "send");
|
|
390
|
+
if (!access) return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
391
|
+
|
|
392
|
+
const rate = await checkRateLimit(c.env.DB, {
|
|
393
|
+
action: "mail-upload",
|
|
394
|
+
identifier: user.id,
|
|
395
|
+
limit: 30,
|
|
396
|
+
windowMs: 60_000,
|
|
397
|
+
});
|
|
398
|
+
if (!rate.allowed) {
|
|
399
|
+
c.header("retry-after", String(rate.retryAfterSeconds));
|
|
400
|
+
return apiError(c, 429, "RATE_LIMITED", "Too many attachment uploads");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const upload = await createComposerUploadIntent({
|
|
405
|
+
env: c.env,
|
|
406
|
+
requestOrigin: new URL(c.req.url).origin,
|
|
407
|
+
mailboxId,
|
|
408
|
+
userId: user.id,
|
|
409
|
+
filename: body.filename,
|
|
410
|
+
contentType: body.contentType,
|
|
411
|
+
size: body.size,
|
|
412
|
+
directOnly: directOnly === "true",
|
|
413
|
+
});
|
|
414
|
+
return c.json({ ok: true as const, upload }, 201);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return outgoingFailure(c, error);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
.put(
|
|
421
|
+
"/uploads/content",
|
|
422
|
+
zValidator(
|
|
423
|
+
"query",
|
|
424
|
+
mailboxQuerySchema.extend({
|
|
425
|
+
uploadId: uploadIdSchema,
|
|
426
|
+
}),
|
|
427
|
+
),
|
|
428
|
+
async (c) => {
|
|
429
|
+
const { mailboxId, uploadId } = c.req.valid("query");
|
|
430
|
+
const user = c.get("user");
|
|
431
|
+
const access = await accessibleMailbox(c.env, user.id, mailboxId, "send");
|
|
432
|
+
if (!access) return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
433
|
+
try {
|
|
434
|
+
await storeComposerUploadContent({
|
|
435
|
+
env: c.env,
|
|
436
|
+
mailboxId,
|
|
437
|
+
userId: user.id,
|
|
438
|
+
uploadId,
|
|
439
|
+
body: c.req.raw.body,
|
|
440
|
+
contentType: c.req.header("content-type") ?? undefined,
|
|
441
|
+
contentLength: requestContentLength(
|
|
442
|
+
c.req.header("content-length") ?? undefined,
|
|
443
|
+
),
|
|
444
|
+
});
|
|
445
|
+
return c.json({ ok: true as const });
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return outgoingFailure(c, error);
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
)
|
|
451
|
+
.post(
|
|
452
|
+
"/uploads/:id/complete",
|
|
453
|
+
zValidator("param", z.object({ id: uploadIdSchema })),
|
|
454
|
+
zValidator("query", mailboxQuerySchema),
|
|
455
|
+
async (c) => {
|
|
456
|
+
const { mailboxId } = c.req.valid("query");
|
|
457
|
+
const { id: uploadId } = c.req.valid("param");
|
|
458
|
+
const user = c.get("user");
|
|
459
|
+
const access = await accessibleMailbox(c.env, user.id, mailboxId, "send");
|
|
460
|
+
if (!access) {
|
|
461
|
+
return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const upload = await finalizeComposerUpload({
|
|
465
|
+
env: c.env,
|
|
466
|
+
mailboxId,
|
|
467
|
+
userId: user.id,
|
|
468
|
+
uploadId,
|
|
469
|
+
defer: (task) => c.executionCtx.waitUntil(task),
|
|
470
|
+
});
|
|
471
|
+
return c.json({ ok: true as const, upload });
|
|
472
|
+
} catch (error) {
|
|
473
|
+
return outgoingFailure(c, error);
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
)
|
|
477
|
+
.delete(
|
|
478
|
+
"/uploads/:id",
|
|
479
|
+
zValidator("param", z.object({ id: uploadIdSchema })),
|
|
480
|
+
zValidator("query", mailboxQuerySchema),
|
|
481
|
+
async (c) => {
|
|
482
|
+
const { mailboxId } = c.req.valid("query");
|
|
483
|
+
const user = c.get("user");
|
|
484
|
+
const access = await accessibleMailbox(c.env, user.id, mailboxId, "send");
|
|
485
|
+
if (!access) {
|
|
486
|
+
return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
487
|
+
}
|
|
488
|
+
const { id: uploadId } = c.req.valid("param");
|
|
489
|
+
await discardComposerUploads({
|
|
490
|
+
env: c.env,
|
|
491
|
+
mailboxId,
|
|
492
|
+
userId: user.id,
|
|
493
|
+
uploadIds: [uploadId],
|
|
494
|
+
});
|
|
495
|
+
return c.json({ ok: true as const });
|
|
496
|
+
},
|
|
497
|
+
)
|
|
498
|
+
.get(
|
|
499
|
+
"/mailboxes/:id/recipient-suggestions",
|
|
500
|
+
zValidator("query", recipientSuggestionQuerySchema),
|
|
501
|
+
async (c) => {
|
|
502
|
+
const mailboxId = c.req.param("id");
|
|
503
|
+
const access = await accessibleMailbox(
|
|
504
|
+
c.env,
|
|
505
|
+
c.get("user").id,
|
|
506
|
+
mailboxId,
|
|
507
|
+
"read",
|
|
508
|
+
);
|
|
509
|
+
if (!access) {
|
|
510
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
511
|
+
}
|
|
512
|
+
const query = c.req.valid("query");
|
|
513
|
+
return c.json({
|
|
514
|
+
ok: true as const,
|
|
515
|
+
suggestions: await mailboxStub(c.env, mailboxId).suggestRecipients(
|
|
516
|
+
access.address,
|
|
517
|
+
query.q,
|
|
518
|
+
query.limit,
|
|
519
|
+
),
|
|
520
|
+
});
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
.get("/mailboxes/:id/folders", async (c) => {
|
|
524
|
+
const mailboxId = c.req.param("id");
|
|
525
|
+
const access = await accessibleMailbox(
|
|
526
|
+
c.env,
|
|
527
|
+
c.get("user").id,
|
|
528
|
+
mailboxId,
|
|
529
|
+
"read",
|
|
530
|
+
);
|
|
531
|
+
if (!access) {
|
|
532
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
533
|
+
}
|
|
534
|
+
const storedFolders = await mailboxStub(c.env, mailboxId).listFolders(
|
|
535
|
+
c.get("user").id,
|
|
536
|
+
);
|
|
537
|
+
return c.json({
|
|
538
|
+
ok: true as const,
|
|
539
|
+
folders: storedFolders.map((folder) => ({
|
|
540
|
+
id: folder.id,
|
|
541
|
+
name: folder.name,
|
|
542
|
+
kind: folder.kind,
|
|
543
|
+
systemType: folder.systemType,
|
|
544
|
+
totalCount: folder.totalCount,
|
|
545
|
+
unreadCount: folder.unreadCount,
|
|
546
|
+
})),
|
|
547
|
+
});
|
|
548
|
+
})
|
|
549
|
+
.get("/mailboxes/:id/ai", async (c) => {
|
|
550
|
+
const mailboxId = c.req.param("id");
|
|
551
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
552
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
553
|
+
}
|
|
554
|
+
return c.json({
|
|
555
|
+
ok: true as const,
|
|
556
|
+
settings: await mailboxStub(c.env, mailboxId).getMailboxAiSettings(),
|
|
557
|
+
});
|
|
558
|
+
})
|
|
559
|
+
.put(
|
|
560
|
+
"/mailboxes/:id/ai",
|
|
561
|
+
zValidator("json", mailboxAiConfigurationSchema),
|
|
562
|
+
async (c) => {
|
|
563
|
+
const mailboxId = c.req.param("id");
|
|
564
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
565
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
566
|
+
}
|
|
567
|
+
return c.json({
|
|
568
|
+
ok: true as const,
|
|
569
|
+
settings: await mailboxStub(c.env, mailboxId)
|
|
570
|
+
.setMailboxAiConfiguration(c.req.valid("json")),
|
|
571
|
+
});
|
|
572
|
+
},
|
|
573
|
+
)
|
|
574
|
+
.post(
|
|
575
|
+
"/mailboxes/:id/folders",
|
|
576
|
+
zValidator("json", createFolderSchema),
|
|
577
|
+
async (c) => {
|
|
578
|
+
const mailboxId = c.req.param("id");
|
|
579
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
580
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
581
|
+
}
|
|
582
|
+
const result = await mailboxStub(c.env, mailboxId).createFolder(
|
|
583
|
+
createId("fld"),
|
|
584
|
+
c.req.valid("json").name,
|
|
585
|
+
);
|
|
586
|
+
if (result.status === "conflict") {
|
|
587
|
+
return apiError(c, 409, "CONFLICT", "Folder name already exists");
|
|
588
|
+
}
|
|
589
|
+
if (result.status === "limit") {
|
|
590
|
+
return apiError(c, 409, "CONFLICT", "Mailbox folder limit reached");
|
|
591
|
+
}
|
|
592
|
+
if (result.status === "invalid") {
|
|
593
|
+
return apiError(c, 400, "BAD_REQUEST", "Folder name is invalid");
|
|
594
|
+
}
|
|
595
|
+
return c.json({ ok: true as const, folder: result.folder }, 201);
|
|
596
|
+
},
|
|
597
|
+
)
|
|
598
|
+
.put(
|
|
599
|
+
"/mailboxes/:id/folders/order",
|
|
600
|
+
zValidator("json", reorderFoldersSchema),
|
|
601
|
+
async (c) => {
|
|
602
|
+
const mailboxId = c.req.param("id");
|
|
603
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
604
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
605
|
+
}
|
|
606
|
+
const result = await mailboxStub(c.env, mailboxId).reorderFolders(
|
|
607
|
+
c.req.valid("json").folderIds,
|
|
608
|
+
);
|
|
609
|
+
if (result.status === "conflict") {
|
|
610
|
+
return apiError(
|
|
611
|
+
c,
|
|
612
|
+
409,
|
|
613
|
+
"CONFLICT",
|
|
614
|
+
"Folders changed; refresh and try again",
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return c.json({ ok: true as const });
|
|
618
|
+
},
|
|
619
|
+
)
|
|
620
|
+
.patch(
|
|
621
|
+
"/mailboxes/:id/folders/:folderId",
|
|
622
|
+
zValidator("json", renameFolderSchema),
|
|
623
|
+
async (c) => {
|
|
624
|
+
const mailboxId = c.req.param("id");
|
|
625
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
626
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
627
|
+
}
|
|
628
|
+
const result = await mailboxStub(c.env, mailboxId).renameFolder(
|
|
629
|
+
c.req.param("folderId"),
|
|
630
|
+
c.req.valid("json").name,
|
|
631
|
+
);
|
|
632
|
+
if (result.status === "not_found") {
|
|
633
|
+
return apiError(c, 404, "NOT_FOUND", "Folder not found");
|
|
634
|
+
}
|
|
635
|
+
if (result.status === "conflict") {
|
|
636
|
+
return apiError(c, 409, "CONFLICT", "Folder name already exists");
|
|
637
|
+
}
|
|
638
|
+
if (result.status === "invalid") {
|
|
639
|
+
return apiError(c, 400, "BAD_REQUEST", "Folder name is invalid");
|
|
640
|
+
}
|
|
641
|
+
return c.json({ ok: true as const, folder: result.folder });
|
|
642
|
+
},
|
|
643
|
+
)
|
|
644
|
+
.delete("/mailboxes/:id/folders/:folderId", async (c) => {
|
|
645
|
+
const mailboxId = c.req.param("id");
|
|
646
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
647
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
648
|
+
}
|
|
649
|
+
const result = await mailboxStub(c.env, mailboxId).deleteFolder(
|
|
650
|
+
c.req.param("folderId"),
|
|
651
|
+
);
|
|
652
|
+
if (result.status === "not_found") {
|
|
653
|
+
return apiError(c, 404, "NOT_FOUND", "Folder not found");
|
|
654
|
+
}
|
|
655
|
+
return c.json({ ok: true as const });
|
|
656
|
+
})
|
|
657
|
+
.get("/conversations", zValidator("query", conversationListQuerySchema), async (c) => {
|
|
658
|
+
const query = c.req.valid("query");
|
|
659
|
+
const access = await accessibleMailbox(c.env, c.get("user").id, query.mailboxId, "read");
|
|
660
|
+
if (!access) {
|
|
661
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
662
|
+
}
|
|
663
|
+
const cursor = query.cursor ? decodeConversationCursor(query.cursor) : null;
|
|
664
|
+
if (query.cursor && !cursor) {
|
|
665
|
+
return apiError(c, 400, "BAD_REQUEST", "Conversation cursor is invalid");
|
|
666
|
+
}
|
|
667
|
+
const page = await mailboxStub(c.env, query.mailboxId).listConversations(
|
|
668
|
+
c.get("user").id,
|
|
669
|
+
query.folder,
|
|
670
|
+
query.limit,
|
|
671
|
+
cursor,
|
|
672
|
+
query.search,
|
|
673
|
+
query.unreadOnly === "true",
|
|
674
|
+
);
|
|
675
|
+
if (!page) return apiError(c, 404, "NOT_FOUND", "Folder not found");
|
|
676
|
+
return c.json({
|
|
677
|
+
ok: true as const,
|
|
678
|
+
conversations: page.items.map((item) => ({
|
|
679
|
+
...toMessageSummary(item.email),
|
|
680
|
+
subject: conversationSubject(item.email.subject),
|
|
681
|
+
conversationLabel: conversationLabel(
|
|
682
|
+
access.address,
|
|
683
|
+
item.email,
|
|
684
|
+
),
|
|
685
|
+
messageCount: item.messageCount,
|
|
686
|
+
unreadCount: item.unreadCount,
|
|
687
|
+
isUnread: item.unreadCount > 0,
|
|
688
|
+
hasIncoming: item.hasIncoming,
|
|
689
|
+
})),
|
|
690
|
+
nextCursor: page.next ? encodeConversationCursor(page.next) : null,
|
|
691
|
+
});
|
|
692
|
+
})
|
|
693
|
+
.patch(
|
|
694
|
+
"/conversations/bulk",
|
|
695
|
+
zValidator("query", mailboxQuerySchema),
|
|
696
|
+
zValidator("json", bulkConversationActionSchema),
|
|
697
|
+
async (c) => {
|
|
698
|
+
const { mailboxId } = c.req.valid("query");
|
|
699
|
+
const user = c.get("user");
|
|
700
|
+
const access = await accessibleMailbox(c.env, user.id, mailboxId, "read");
|
|
701
|
+
if (!access) {
|
|
702
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
703
|
+
}
|
|
704
|
+
const input = c.req.valid("json");
|
|
705
|
+
const stub = mailboxStub(c.env, mailboxId);
|
|
706
|
+
if (input.type === "read") {
|
|
707
|
+
const updatedCount = await stub.bulkSetConversationRead(
|
|
708
|
+
user.id,
|
|
709
|
+
input.conversationIds,
|
|
710
|
+
input.isRead,
|
|
711
|
+
);
|
|
712
|
+
return c.json({ ok: true as const, updatedCount });
|
|
713
|
+
}
|
|
714
|
+
if (input.type === "update") {
|
|
715
|
+
const updatedCount = await stub.bulkUpdateConversations(
|
|
716
|
+
input.conversationIds,
|
|
717
|
+
input.sourceFolderId,
|
|
718
|
+
input.update,
|
|
719
|
+
);
|
|
720
|
+
if (updatedCount === null) {
|
|
721
|
+
return apiError(c, 404, "NOT_FOUND", "Source or target folder not found");
|
|
722
|
+
}
|
|
723
|
+
return c.json({ ok: true as const, updatedCount });
|
|
724
|
+
}
|
|
725
|
+
if (!access.canSend) {
|
|
726
|
+
return apiError(
|
|
727
|
+
c,
|
|
728
|
+
403,
|
|
729
|
+
"FORBIDDEN",
|
|
730
|
+
"Send access is required to delete conversations permanently",
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const result = await stub.permanentlyDeleteConversations(
|
|
735
|
+
input.conversationIds,
|
|
736
|
+
);
|
|
737
|
+
if (result.outcome === "not_found") {
|
|
738
|
+
return apiError(c, 404, "NOT_FOUND", "Conversations not found");
|
|
739
|
+
}
|
|
740
|
+
if (result.outcome === "not_in_trash") {
|
|
741
|
+
return apiError(
|
|
742
|
+
c,
|
|
743
|
+
409,
|
|
744
|
+
"CONFLICT",
|
|
745
|
+
"Only conversations in Trash can be deleted permanently",
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
return c.json({
|
|
749
|
+
ok: true as const,
|
|
750
|
+
updatedCount: result.deletedCount,
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
)
|
|
754
|
+
.get(
|
|
755
|
+
"/conversations/:id",
|
|
756
|
+
zValidator("query", conversationMessagesQuerySchema),
|
|
757
|
+
async (c) => {
|
|
758
|
+
const query = c.req.valid("query");
|
|
759
|
+
const { mailboxId } = query;
|
|
760
|
+
const currentUserId = c.get("user").id;
|
|
761
|
+
const access = await accessibleMailbox(c.env, currentUserId, mailboxId, "read");
|
|
762
|
+
if (!access) {
|
|
763
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
764
|
+
}
|
|
765
|
+
const cursor = query.cursor ? decodeConversationCursor(query.cursor) : null;
|
|
766
|
+
if (query.cursor && !cursor) {
|
|
767
|
+
return apiError(c, 400, "BAD_REQUEST", "Message cursor is invalid");
|
|
768
|
+
}
|
|
769
|
+
const snapshot = await mailboxStub(c.env, mailboxId)
|
|
770
|
+
.getConversationPageSnapshot(
|
|
771
|
+
c.req.param("id"),
|
|
772
|
+
currentUserId,
|
|
773
|
+
query.limit,
|
|
774
|
+
cursor,
|
|
775
|
+
);
|
|
776
|
+
if (!snapshot) return apiError(c, 404, "NOT_FOUND", "Conversation not found");
|
|
777
|
+
const readerIds = [...new Set(
|
|
778
|
+
snapshot.readStates.map((readState) => readState.userId),
|
|
779
|
+
)];
|
|
780
|
+
const readers = await listMailboxUsersByIds(
|
|
781
|
+
c.env.DB,
|
|
782
|
+
mailboxId,
|
|
783
|
+
readerIds,
|
|
784
|
+
);
|
|
785
|
+
const readerById = new Map(readers.map((reader) => [reader.id, reader]));
|
|
786
|
+
const readStatesByEmail = new Map<
|
|
787
|
+
string,
|
|
788
|
+
typeof snapshot.readStates
|
|
789
|
+
>();
|
|
790
|
+
for (const readState of snapshot.readStates) {
|
|
791
|
+
const states = readStatesByEmail.get(readState.emailId) ?? [];
|
|
792
|
+
states.push(readState);
|
|
793
|
+
readStatesByEmail.set(readState.emailId, states);
|
|
794
|
+
}
|
|
795
|
+
const mailbox = mailboxStub(c.env, mailboxId);
|
|
796
|
+
const senderAddresses = snapshot.messages.flatMap((message) =>
|
|
797
|
+
message.direction === "incoming" && message.fromJson[0] ? [message.fromJson[0].address] : []
|
|
798
|
+
);
|
|
799
|
+
const [identity, dqsKey, history, localSafety, workspaceBlocks] = await Promise.all([
|
|
800
|
+
workspaceIdentity(c.env.DB),
|
|
801
|
+
spamhausDqsKey(c.env),
|
|
802
|
+
mailbox.getSenderHistory(senderAddresses),
|
|
803
|
+
mailbox.getMessageSafetyContext(
|
|
804
|
+
senderAddresses,
|
|
805
|
+
snapshot.messages.flatMap((message) => emailListKey(message) ?? []),
|
|
806
|
+
),
|
|
807
|
+
workspaceBlockPatterns(c.env.DB),
|
|
808
|
+
]);
|
|
809
|
+
const safety: MessageSafetyContext = { ...localSafety, workspaceBlocks };
|
|
810
|
+
const messages = await Promise.all(snapshot.messages.map(async (stored) => {
|
|
811
|
+
const object = stored.bodyHtmlR2Key
|
|
812
|
+
? await c.env.MAIL_STORAGE.get(stored.bodyHtmlR2Key)
|
|
813
|
+
: null;
|
|
814
|
+
const bodyHtml = object ? await object.text() : null;
|
|
815
|
+
const email = await withSenderIntel(
|
|
816
|
+
c.env,
|
|
817
|
+
mailbox,
|
|
818
|
+
await withHeaderMetadata(c.env, mailbox, stored),
|
|
819
|
+
{ bodyHtml, workspaceDomains: identity.domains, dqsKey },
|
|
820
|
+
);
|
|
821
|
+
const readStates = readStatesByEmail.get(email.id) ?? [];
|
|
822
|
+
return toMessageDetail(
|
|
823
|
+
email,
|
|
824
|
+
access.address,
|
|
825
|
+
identity,
|
|
826
|
+
history,
|
|
827
|
+
safety,
|
|
828
|
+
bodyHtml,
|
|
829
|
+
{
|
|
830
|
+
isRead: readStates.some(
|
|
831
|
+
(readState) => readState.userId === currentUserId,
|
|
832
|
+
),
|
|
833
|
+
viewedBy: readStates.flatMap((readState) => {
|
|
834
|
+
const reader = readerById.get(readState.userId);
|
|
835
|
+
return reader
|
|
836
|
+
? [{
|
|
837
|
+
userId: reader.id,
|
|
838
|
+
name: reader.name,
|
|
839
|
+
readAt: readState.readAt,
|
|
840
|
+
}]
|
|
841
|
+
: [];
|
|
842
|
+
}),
|
|
843
|
+
},
|
|
844
|
+
);
|
|
845
|
+
}));
|
|
846
|
+
return c.json({
|
|
847
|
+
ok: true as const,
|
|
848
|
+
mailboxState: snapshot.mailboxState,
|
|
849
|
+
folderId: snapshot.folderId,
|
|
850
|
+
hasIncoming: snapshot.hasIncoming,
|
|
851
|
+
messageCount: snapshot.messageCount,
|
|
852
|
+
unreadCount: snapshot.unreadCount,
|
|
853
|
+
nextCursor: snapshot.next
|
|
854
|
+
? encodeConversationCursor(snapshot.next)
|
|
855
|
+
: null,
|
|
856
|
+
messages,
|
|
857
|
+
});
|
|
858
|
+
},
|
|
859
|
+
)
|
|
860
|
+
.patch(
|
|
861
|
+
"/messages/:id/read",
|
|
862
|
+
zValidator("query", mailboxQuerySchema),
|
|
863
|
+
zValidator("json", messageReadSchema),
|
|
864
|
+
async (c) => {
|
|
865
|
+
const { mailboxId } = c.req.valid("query");
|
|
866
|
+
const user = c.get("user");
|
|
867
|
+
if (!await accessibleMailbox(c.env, user.id, mailboxId, "read")) {
|
|
868
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
869
|
+
}
|
|
870
|
+
const updated = await mailboxStub(c.env, mailboxId).setMessageRead(
|
|
871
|
+
user.id,
|
|
872
|
+
c.req.param("id"),
|
|
873
|
+
c.req.valid("json").isRead,
|
|
874
|
+
);
|
|
875
|
+
if (!updated) return apiError(c, 404, "NOT_FOUND", "Message not found");
|
|
876
|
+
return c.json({ ok: true as const });
|
|
877
|
+
},
|
|
878
|
+
)
|
|
879
|
+
.post(
|
|
880
|
+
"/messages/:id/translate",
|
|
881
|
+
zValidator("query", mailboxQuerySchema),
|
|
882
|
+
zValidator("json", messageTranslationSchema),
|
|
883
|
+
async (c) => {
|
|
884
|
+
const { mailboxId } = c.req.valid("query");
|
|
885
|
+
const userId = c.get("user").id;
|
|
886
|
+
if (!await accessibleMailbox(c.env, userId, mailboxId, "read")) {
|
|
887
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
888
|
+
}
|
|
889
|
+
if (!aiFeatureEnabled("translation")) {
|
|
890
|
+
return apiError(c, 409, "CONFLICT", "Translation is not configured for this workspace");
|
|
891
|
+
}
|
|
892
|
+
const limit = await checkRateLimit(c.env.DB, {
|
|
893
|
+
action: "mail-translation",
|
|
894
|
+
identifier: userId,
|
|
895
|
+
limit: 20,
|
|
896
|
+
windowMs: 60_000,
|
|
897
|
+
});
|
|
898
|
+
if (!limit.allowed) {
|
|
899
|
+
c.header("retry-after", String(limit.retryAfterSeconds));
|
|
900
|
+
return apiError(c, 429, "RATE_LIMITED", "Too many translation requests");
|
|
901
|
+
}
|
|
902
|
+
const message = await mailboxStub(c.env, mailboxId).getEmail(c.req.param("id"));
|
|
903
|
+
if (!message) return apiError(c, 404, "NOT_FOUND", "Message not found");
|
|
904
|
+
const { language, segments } = c.req.valid("json");
|
|
905
|
+
if (segments.join("").length > MAX_TRANSLATION_CHARACTERS) {
|
|
906
|
+
return apiError(c, 422, "BAD_REQUEST", "Message is too long to translate");
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
const translations = await translateEmailSegments({
|
|
910
|
+
segments,
|
|
911
|
+
language,
|
|
912
|
+
run: aiObjectRunner(aiModel(c.env, "translation")!),
|
|
913
|
+
});
|
|
914
|
+
return c.json({ ok: true as const, translations });
|
|
915
|
+
} catch {
|
|
916
|
+
return apiError(c, 502, "BAD_GATEWAY", "Could not translate this message");
|
|
917
|
+
}
|
|
918
|
+
},
|
|
919
|
+
)
|
|
920
|
+
.get(
|
|
921
|
+
"/remote",
|
|
922
|
+
zValidator("query", remoteProxyQuerySchema),
|
|
923
|
+
async (c) => {
|
|
924
|
+
const { mailboxId, url } = c.req.valid("query");
|
|
925
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
926
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
assertProxyableRemoteUrl(url);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
return apiError(
|
|
932
|
+
c,
|
|
933
|
+
400,
|
|
934
|
+
"BAD_REQUEST",
|
|
935
|
+
error instanceof Error ? error.message : "Remote URL is invalid",
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
const limited = await checkRateLimit(c.env.DB, {
|
|
939
|
+
action: "mail-remote-proxy",
|
|
940
|
+
identifier: c.get("user").id,
|
|
941
|
+
limit: 120,
|
|
942
|
+
windowMs: 60_000,
|
|
943
|
+
});
|
|
944
|
+
if (!limited.allowed) {
|
|
945
|
+
return apiError(
|
|
946
|
+
c,
|
|
947
|
+
429,
|
|
948
|
+
"RATE_LIMITED",
|
|
949
|
+
"Too many remote content requests. Try again shortly.",
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
try {
|
|
953
|
+
const remote = await fetchProxiedRemoteMedia(url);
|
|
954
|
+
return new Response(remote.body, {
|
|
955
|
+
headers: {
|
|
956
|
+
"cache-control": "private, max-age=3600",
|
|
957
|
+
"content-type": remote.contentType,
|
|
958
|
+
"content-disposition": "inline",
|
|
959
|
+
"cross-origin-resource-policy": "same-origin",
|
|
960
|
+
"x-content-type-options": "nosniff",
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
} catch (error) {
|
|
964
|
+
return apiError(
|
|
965
|
+
c,
|
|
966
|
+
502,
|
|
967
|
+
"BAD_GATEWAY",
|
|
968
|
+
error instanceof Error ? error.message : "Remote content unavailable",
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
)
|
|
973
|
+
.get(
|
|
974
|
+
"/messages/:messageId/authentication",
|
|
975
|
+
zValidator("query", mailboxQuerySchema),
|
|
976
|
+
async (c) => {
|
|
977
|
+
const { mailboxId } = c.req.valid("query");
|
|
978
|
+
const access = await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read");
|
|
979
|
+
if (!access) {
|
|
980
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
981
|
+
}
|
|
982
|
+
const mailbox = mailboxStub(c.env, mailboxId);
|
|
983
|
+
const stored = await mailbox.getEmail(c.req.param("messageId"));
|
|
984
|
+
if (!stored?.rawMimeR2Key || stored.direction !== "incoming") {
|
|
985
|
+
return apiError(c, 404, "NOT_FOUND", "Original message not found");
|
|
986
|
+
}
|
|
987
|
+
const message = await withHeaderMetadata(c.env, mailbox, stored);
|
|
988
|
+
const from = message.fromJson[0]?.address ?? "";
|
|
989
|
+
const [identity, history] = await Promise.all([
|
|
990
|
+
workspaceIdentity(c.env.DB),
|
|
991
|
+
mailbox.getSenderHistory(from ? [from] : []),
|
|
992
|
+
]);
|
|
993
|
+
return c.json({
|
|
994
|
+
ok: true as const,
|
|
995
|
+
original: {
|
|
996
|
+
subject: message.subject,
|
|
997
|
+
from: message.fromJson[0]?.address ?? "unknown@invalid",
|
|
998
|
+
to: message.toJson.map((recipient) => recipient.address),
|
|
999
|
+
messageId: message.messageIdHeader,
|
|
1000
|
+
receivedAt: message.timelineAt.toISOString(),
|
|
1001
|
+
envelopeFrom: message.envelopeFrom,
|
|
1002
|
+
envelopeTo: message.envelopeTo,
|
|
1003
|
+
},
|
|
1004
|
+
authentication: headerAuthentication(message),
|
|
1005
|
+
security: emailSecurity(message, identity, history),
|
|
1006
|
+
senderIntel: storedSenderIntel(message.senderIntelJson),
|
|
1007
|
+
});
|
|
1008
|
+
},
|
|
1009
|
+
)
|
|
1010
|
+
.get("/bimi/:domain", async (c) => {
|
|
1011
|
+
const domain = c.req.param("domain").toLocaleLowerCase();
|
|
1012
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u.test(domain)) {
|
|
1013
|
+
return apiError(c, 400, "BAD_REQUEST", "Domain is invalid");
|
|
1014
|
+
}
|
|
1015
|
+
const object = await c.env.MAIL_STORAGE.get(bimiObjectKey(domain));
|
|
1016
|
+
if (!object || object.customMetadata?.status !== "ok" || object.size === 0) {
|
|
1017
|
+
return apiError(c, 404, "NOT_FOUND", "No logo for this domain");
|
|
1018
|
+
}
|
|
1019
|
+
return new Response(object.body, { headers: BIMI_LOGO_HEADERS });
|
|
1020
|
+
})
|
|
1021
|
+
.get(
|
|
1022
|
+
"/messages/:messageId/original",
|
|
1023
|
+
zValidator("query", mailboxQuerySchema),
|
|
1024
|
+
async (c) => {
|
|
1025
|
+
const { mailboxId } = c.req.valid("query");
|
|
1026
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
1027
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
1028
|
+
}
|
|
1029
|
+
const message = await mailboxStub(c.env, mailboxId)
|
|
1030
|
+
.getEmail(c.req.param("messageId"));
|
|
1031
|
+
if (!message?.rawMimeR2Key) {
|
|
1032
|
+
return apiError(c, 404, "NOT_FOUND", "Original message not found");
|
|
1033
|
+
}
|
|
1034
|
+
const requestedRange = c.req.header("range");
|
|
1035
|
+
const object = await getStoredObject(
|
|
1036
|
+
c.env.MAIL_STORAGE,
|
|
1037
|
+
message.rawMimeR2Key,
|
|
1038
|
+
requestedRange,
|
|
1039
|
+
);
|
|
1040
|
+
if (!object) {
|
|
1041
|
+
return apiError(c, 404, "NOT_FOUND", "Original message is missing");
|
|
1042
|
+
}
|
|
1043
|
+
return storedObjectResponse({
|
|
1044
|
+
object,
|
|
1045
|
+
contentDisposition: `inline; filename*=UTF-8''${encodeURIComponent(`${message.id}.eml`)}`,
|
|
1046
|
+
contentType: "text/plain; charset=utf-8",
|
|
1047
|
+
totalBytes: object.size,
|
|
1048
|
+
rangeRequested: Boolean(requestedRange),
|
|
1049
|
+
});
|
|
1050
|
+
},
|
|
1051
|
+
)
|
|
1052
|
+
.get(
|
|
1053
|
+
"/messages/:messageId/attachments/:attachmentId",
|
|
1054
|
+
zValidator("query", mailboxQuerySchema),
|
|
1055
|
+
async (c) => {
|
|
1056
|
+
const { mailboxId } = c.req.valid("query");
|
|
1057
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
1058
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
1059
|
+
}
|
|
1060
|
+
const file = await mailboxStub(c.env, mailboxId).getAttachment(
|
|
1061
|
+
c.req.param("messageId"),
|
|
1062
|
+
c.req.param("attachmentId"),
|
|
1063
|
+
);
|
|
1064
|
+
if (!file) return apiError(c, 404, "NOT_FOUND", "Attachment not found");
|
|
1065
|
+
const requestedRange = c.req.header("range");
|
|
1066
|
+
const object = await getStoredObject(
|
|
1067
|
+
c.env.MAIL_STORAGE,
|
|
1068
|
+
file.r2Key,
|
|
1069
|
+
requestedRange,
|
|
1070
|
+
);
|
|
1071
|
+
if (!object) return apiError(c, 404, "NOT_FOUND", "Attachment data is missing");
|
|
1072
|
+
return storedObjectResponse({
|
|
1073
|
+
object,
|
|
1074
|
+
contentType: file.contentType,
|
|
1075
|
+
contentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(file.filename)}`,
|
|
1076
|
+
totalBytes: file.size,
|
|
1077
|
+
rangeRequested: Boolean(requestedRange),
|
|
1078
|
+
});
|
|
1079
|
+
},
|
|
1080
|
+
)
|
|
1081
|
+
.get(
|
|
1082
|
+
"/messages/:messageId/attachments/:attachmentId/preview",
|
|
1083
|
+
zValidator("query", mailboxQuerySchema),
|
|
1084
|
+
async (c) => {
|
|
1085
|
+
const { mailboxId } = c.req.valid("query");
|
|
1086
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
1087
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
1088
|
+
}
|
|
1089
|
+
const file = await mailboxStub(c.env, mailboxId).getAttachment(
|
|
1090
|
+
c.req.param("messageId"),
|
|
1091
|
+
c.req.param("attachmentId"),
|
|
1092
|
+
);
|
|
1093
|
+
if (!file) return apiError(c, 404, "NOT_FOUND", "Attachment not found");
|
|
1094
|
+
const preview = attachmentPreview(file);
|
|
1095
|
+
if (!preview) {
|
|
1096
|
+
return apiError(c, 422, "BAD_REQUEST", "Preview is not available for this file");
|
|
1097
|
+
}
|
|
1098
|
+
if (file.pdfPreviewStatus === "pending") {
|
|
1099
|
+
return apiError(c, 409, "NOT_READY", "Preview is being generated");
|
|
1100
|
+
}
|
|
1101
|
+
if (file.pdfPreviewStatus === "failed") {
|
|
1102
|
+
return apiError(c, 422, "UNAVAILABLE", "Preview could not be generated");
|
|
1103
|
+
}
|
|
1104
|
+
const requestedRange = c.req.header("range");
|
|
1105
|
+
const previewKey = file.pdfPreviewStatus === "ready" ? officePreviewKey(file.r2Key) : file.r2Key;
|
|
1106
|
+
const object = await getStoredObject(c.env.MAIL_STORAGE, previewKey, requestedRange);
|
|
1107
|
+
if (!object) return apiError(c, 404, "NOT_FOUND", "Attachment data is missing");
|
|
1108
|
+
const response = storedObjectResponse({
|
|
1109
|
+
object,
|
|
1110
|
+
contentType: preview.contentType,
|
|
1111
|
+
contentDisposition: `inline; filename*=UTF-8''${encodeURIComponent(file.pdfPreviewStatus === "ready" ? `${file.filename}.pdf` : file.filename)}`,
|
|
1112
|
+
totalBytes: object.size,
|
|
1113
|
+
rangeRequested: Boolean(requestedRange),
|
|
1114
|
+
});
|
|
1115
|
+
response.headers.set("cross-origin-resource-policy", "same-origin");
|
|
1116
|
+
response.headers.set("content-security-policy", "frame-ancestors 'self'");
|
|
1117
|
+
return response;
|
|
1118
|
+
},
|
|
1119
|
+
)
|
|
1120
|
+
.get(
|
|
1121
|
+
"/messages/:messageId/attachments/:attachmentId/download-url",
|
|
1122
|
+
zValidator("query", mailboxQuerySchema),
|
|
1123
|
+
async (c) => {
|
|
1124
|
+
const { mailboxId } = c.req.valid("query");
|
|
1125
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
1126
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
1127
|
+
}
|
|
1128
|
+
const file = await mailboxStub(c.env, mailboxId).getAttachment(
|
|
1129
|
+
c.req.param("messageId"),
|
|
1130
|
+
c.req.param("attachmentId"),
|
|
1131
|
+
);
|
|
1132
|
+
if (!file) return apiError(c, 404, "NOT_FOUND", "Attachment not found");
|
|
1133
|
+
if (!await c.env.MAIL_STORAGE.head(file.r2Key)) {
|
|
1134
|
+
return apiError(c, 404, "NOT_FOUND", "Attachment data is missing");
|
|
1135
|
+
}
|
|
1136
|
+
const download = await signR2GetUrl({
|
|
1137
|
+
env: c.env,
|
|
1138
|
+
r2Key: file.r2Key,
|
|
1139
|
+
});
|
|
1140
|
+
if (!download) {
|
|
1141
|
+
return apiError(
|
|
1142
|
+
c,
|
|
1143
|
+
503,
|
|
1144
|
+
"UNAVAILABLE",
|
|
1145
|
+
"Direct R2 downloads are not configured",
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
return c.json({
|
|
1149
|
+
ok: true as const,
|
|
1150
|
+
attachment: {
|
|
1151
|
+
id: file.id,
|
|
1152
|
+
filename: file.filename,
|
|
1153
|
+
contentType: file.contentType,
|
|
1154
|
+
size: file.size,
|
|
1155
|
+
url: download.url,
|
|
1156
|
+
expiresAt: download.expiresAt,
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
},
|
|
1160
|
+
)
|
|
1161
|
+
.get(
|
|
1162
|
+
"/messages/:messageId/attachments/:attachmentId/inline",
|
|
1163
|
+
zValidator("query", mailboxQuerySchema),
|
|
1164
|
+
async (c) => {
|
|
1165
|
+
const { mailboxId } = c.req.valid("query");
|
|
1166
|
+
if (!await accessibleMailbox(c.env, c.get("user").id, mailboxId, "read")) {
|
|
1167
|
+
return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
|
|
1168
|
+
}
|
|
1169
|
+
const file = await mailboxStub(c.env, mailboxId).getAttachment(
|
|
1170
|
+
c.req.param("messageId"),
|
|
1171
|
+
c.req.param("attachmentId"),
|
|
1172
|
+
);
|
|
1173
|
+
const contentType = file ? mediaType(file.contentType) : "";
|
|
1174
|
+
if (
|
|
1175
|
+
!file?.contentId
|
|
1176
|
+
|| !isComposerInlineImageContentType(contentType)
|
|
1177
|
+
) {
|
|
1178
|
+
return apiError(c, 404, "NOT_FOUND", "Inline image not found");
|
|
1179
|
+
}
|
|
1180
|
+
const object = await c.env.MAIL_STORAGE.get(file.r2Key);
|
|
1181
|
+
if (!object) {
|
|
1182
|
+
return apiError(c, 404, "NOT_FOUND", "Inline image data is missing");
|
|
1183
|
+
}
|
|
1184
|
+
return new Response(object.body, {
|
|
1185
|
+
headers: {
|
|
1186
|
+
"cache-control": "private, no-store",
|
|
1187
|
+
"content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.filename)}`,
|
|
1188
|
+
"content-type": contentType,
|
|
1189
|
+
"cross-origin-resource-policy": "same-origin",
|
|
1190
|
+
"x-content-type-options": "nosniff",
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
},
|
|
1194
|
+
)
|
|
1195
|
+
.post(
|
|
1196
|
+
"/messages/:id/send-again",
|
|
1197
|
+
zValidator("query", mailboxQuerySchema),
|
|
1198
|
+
async (c) => {
|
|
1199
|
+
const { mailboxId } = c.req.valid("query");
|
|
1200
|
+
const access = await accessibleMailbox(c.env, c.get("user").id, mailboxId, "send");
|
|
1201
|
+
if (!access) {
|
|
1202
|
+
return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
1203
|
+
}
|
|
1204
|
+
const stub = mailboxStub(c.env, mailboxId);
|
|
1205
|
+
const message = await stub.getEmail(c.req.param("id"));
|
|
1206
|
+
if (!message || message.direction !== "outgoing") {
|
|
1207
|
+
return apiError(c, 404, "NOT_FOUND", "Outgoing message not found");
|
|
1208
|
+
}
|
|
1209
|
+
const resent = await stub.resendOutgoing(message.id);
|
|
1210
|
+
if (!resent) {
|
|
1211
|
+
return apiError(c, 409, "CONFLICT", "Only failed or unconfirmed messages can be sent again");
|
|
1212
|
+
}
|
|
1213
|
+
deferEmailSentWebhook({
|
|
1214
|
+
env: c.env,
|
|
1215
|
+
mailboxId,
|
|
1216
|
+
email: resent,
|
|
1217
|
+
defer: (task) => c.executionCtx.waitUntil(task),
|
|
1218
|
+
});
|
|
1219
|
+
return c.json({
|
|
1220
|
+
ok: true as const,
|
|
1221
|
+
messageId: resent.id,
|
|
1222
|
+
transportState: outboundTransportState(resent),
|
|
1223
|
+
transportError: resent.transportError,
|
|
1224
|
+
});
|
|
1225
|
+
},
|
|
1226
|
+
)
|
|
1227
|
+
.post(
|
|
1228
|
+
"/attachment-preflight",
|
|
1229
|
+
zValidator("json", attachmentPreflightSchema),
|
|
1230
|
+
async (c) => {
|
|
1231
|
+
const input = c.req.valid("json");
|
|
1232
|
+
const user = c.get("user");
|
|
1233
|
+
const access = await accessibleMailbox(
|
|
1234
|
+
c.env,
|
|
1235
|
+
user.id,
|
|
1236
|
+
input.mailboxId,
|
|
1237
|
+
"send",
|
|
1238
|
+
);
|
|
1239
|
+
if (!access) {
|
|
1240
|
+
return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
1241
|
+
}
|
|
1242
|
+
const rate = await checkRateLimit(c.env.DB, {
|
|
1243
|
+
action: "mail-attachment-preflight",
|
|
1244
|
+
identifier: `${user.id}:${input.mailboxId}`,
|
|
1245
|
+
limit: 120,
|
|
1246
|
+
windowMs: 60_000,
|
|
1247
|
+
});
|
|
1248
|
+
if (!rate.allowed) {
|
|
1249
|
+
c.header("retry-after", String(rate.retryAfterSeconds));
|
|
1250
|
+
return apiError(
|
|
1251
|
+
c,
|
|
1252
|
+
429,
|
|
1253
|
+
"RATE_LIMITED",
|
|
1254
|
+
"Too many attachment checks",
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
let subject = input.kind === "compose" ? input.subject : "";
|
|
1260
|
+
let related: Email | null = null;
|
|
1261
|
+
let forwarded: Email | null = null;
|
|
1262
|
+
let includeRelatedContext = false;
|
|
1263
|
+
if (input.kind === "forward") {
|
|
1264
|
+
const context = await resolveForwardContext({
|
|
1265
|
+
env: c.env,
|
|
1266
|
+
mailboxId: input.mailboxId,
|
|
1267
|
+
sourceEmailId: input.sourceEmailId,
|
|
1268
|
+
});
|
|
1269
|
+
forwarded = context.source;
|
|
1270
|
+
subject = context.subject;
|
|
1271
|
+
} else if (input.kind === "reply") {
|
|
1272
|
+
const context = await resolveReplyContext({
|
|
1273
|
+
env: c.env,
|
|
1274
|
+
mailboxId: input.mailboxId,
|
|
1275
|
+
ownAddress: access.address,
|
|
1276
|
+
sourceEmailId: input.sourceEmailId,
|
|
1277
|
+
mode: input.mode,
|
|
1278
|
+
cc: input.cc,
|
|
1279
|
+
bcc: input.bcc,
|
|
1280
|
+
});
|
|
1281
|
+
related = context.source;
|
|
1282
|
+
subject = context.subject;
|
|
1283
|
+
includeRelatedContext = context.includeRelatedContext;
|
|
1284
|
+
}
|
|
1285
|
+
const result = await preflightOutgoingAttachments({
|
|
1286
|
+
env: c.env,
|
|
1287
|
+
compose: {
|
|
1288
|
+
mailboxId: input.mailboxId,
|
|
1289
|
+
userId: user.id,
|
|
1290
|
+
subject,
|
|
1291
|
+
bodyText: input.bodyText,
|
|
1292
|
+
bodyHtml: input.bodyHtml,
|
|
1293
|
+
attachments: input.attachments,
|
|
1294
|
+
},
|
|
1295
|
+
related,
|
|
1296
|
+
forwarded,
|
|
1297
|
+
includeRelatedContext,
|
|
1298
|
+
});
|
|
1299
|
+
return c.json({ ok: true as const, ...result });
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
return outgoingFailure(c, error);
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
)
|
|
1305
|
+
.post("/messages", zValidator("json", composeSchema), async (c) => {
|
|
1306
|
+
const input = c.req.valid("json");
|
|
1307
|
+
const access = await accessibleMailbox(c.env, c.get("user").id, input.mailboxId, "send");
|
|
1308
|
+
if (!access) return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
1309
|
+
try {
|
|
1310
|
+
const submission = await submitOutgoing({
|
|
1311
|
+
env: c.env,
|
|
1312
|
+
requestUrl: c.req.url,
|
|
1313
|
+
compose: {
|
|
1314
|
+
...input,
|
|
1315
|
+
userId: c.get("user").id,
|
|
1316
|
+
},
|
|
1317
|
+
conversationId: createId("conv"),
|
|
1318
|
+
related: null,
|
|
1319
|
+
forwarded: null,
|
|
1320
|
+
includeRelatedContext: false,
|
|
1321
|
+
fromAddress: access.address,
|
|
1322
|
+
fromName: access.displayName,
|
|
1323
|
+
defer: (task) => c.executionCtx.waitUntil(task),
|
|
1324
|
+
});
|
|
1325
|
+
return c.json(
|
|
1326
|
+
outgoingResponse(submission.email, false),
|
|
1327
|
+
submission.inserted ? 201 : 200,
|
|
1328
|
+
);
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
return outgoingFailure(c, error);
|
|
1331
|
+
}
|
|
1332
|
+
})
|
|
1333
|
+
.post(
|
|
1334
|
+
"/messages/:id/replies",
|
|
1335
|
+
zValidator("json", replySchema),
|
|
1336
|
+
async (c) => {
|
|
1337
|
+
const input = c.req.valid("json");
|
|
1338
|
+
const access = await accessibleMailbox(
|
|
1339
|
+
c.env,
|
|
1340
|
+
c.get("user").id,
|
|
1341
|
+
input.mailboxId,
|
|
1342
|
+
"send",
|
|
1343
|
+
);
|
|
1344
|
+
if (!access) {
|
|
1345
|
+
return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
const context = await resolveReplyContext({
|
|
1349
|
+
env: c.env,
|
|
1350
|
+
mailboxId: input.mailboxId,
|
|
1351
|
+
ownAddress: access.address,
|
|
1352
|
+
sourceEmailId: c.req.param("id"),
|
|
1353
|
+
mode: input.mode,
|
|
1354
|
+
cc: input.cc,
|
|
1355
|
+
bcc: input.bcc,
|
|
1356
|
+
});
|
|
1357
|
+
const submission = await submitOutgoing({
|
|
1358
|
+
env: c.env,
|
|
1359
|
+
requestUrl: c.req.url,
|
|
1360
|
+
compose: {
|
|
1361
|
+
requestId: input.requestId,
|
|
1362
|
+
mailboxId: input.mailboxId,
|
|
1363
|
+
userId: c.get("user").id,
|
|
1364
|
+
...context.recipients,
|
|
1365
|
+
subject: context.subject,
|
|
1366
|
+
bodyText: input.bodyText,
|
|
1367
|
+
bodyHtml: input.bodyHtml,
|
|
1368
|
+
attachments: input.attachments,
|
|
1369
|
+
},
|
|
1370
|
+
conversationId: context.detached
|
|
1371
|
+
? createId("conv")
|
|
1372
|
+
: context.source.conversationId,
|
|
1373
|
+
related: context.source,
|
|
1374
|
+
forwarded: null,
|
|
1375
|
+
includeRelatedContext: context.includeRelatedContext,
|
|
1376
|
+
fromAddress: access.address,
|
|
1377
|
+
fromName: access.displayName,
|
|
1378
|
+
defer: (task) => c.executionCtx.waitUntil(task),
|
|
1379
|
+
});
|
|
1380
|
+
return c.json(
|
|
1381
|
+
outgoingResponse(submission.email, context.detached),
|
|
1382
|
+
submission.inserted ? 201 : 200,
|
|
1383
|
+
);
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
return outgoingFailure(c, error);
|
|
1386
|
+
}
|
|
1387
|
+
},
|
|
1388
|
+
)
|
|
1389
|
+
.post(
|
|
1390
|
+
"/messages/:id/forward",
|
|
1391
|
+
zValidator("json", forwardSchema),
|
|
1392
|
+
async (c) => {
|
|
1393
|
+
const input = c.req.valid("json");
|
|
1394
|
+
const access = await accessibleMailbox(
|
|
1395
|
+
c.env,
|
|
1396
|
+
c.get("user").id,
|
|
1397
|
+
input.mailboxId,
|
|
1398
|
+
"send",
|
|
1399
|
+
);
|
|
1400
|
+
if (!access) {
|
|
1401
|
+
return apiError(c, 403, "FORBIDDEN", "Send access is required");
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
const context = await resolveForwardContext({
|
|
1405
|
+
env: c.env,
|
|
1406
|
+
mailboxId: input.mailboxId,
|
|
1407
|
+
sourceEmailId: c.req.param("id"),
|
|
1408
|
+
});
|
|
1409
|
+
const submission = await submitOutgoing({
|
|
1410
|
+
env: c.env,
|
|
1411
|
+
requestUrl: c.req.url,
|
|
1412
|
+
compose: {
|
|
1413
|
+
...input,
|
|
1414
|
+
userId: c.get("user").id,
|
|
1415
|
+
subject: context.subject,
|
|
1416
|
+
},
|
|
1417
|
+
conversationId: createId("conv"),
|
|
1418
|
+
related: null,
|
|
1419
|
+
forwarded: context.source,
|
|
1420
|
+
includeRelatedContext: false,
|
|
1421
|
+
fromAddress: access.address,
|
|
1422
|
+
fromName: access.displayName,
|
|
1423
|
+
defer: (task) => c.executionCtx.waitUntil(task),
|
|
1424
|
+
});
|
|
1425
|
+
return c.json(
|
|
1426
|
+
outgoingResponse(submission.email, false),
|
|
1427
|
+
submission.inserted ? 201 : 200,
|
|
1428
|
+
);
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
return outgoingFailure(c, error);
|
|
1431
|
+
}
|
|
1432
|
+
},
|
|
1433
|
+
)
|
|
1434
|
+
.route("/", mailSafetyRoutes);
|