@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.
Files changed (96) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -2
  3. package/alchemy.ts +126 -0
  4. package/containers/office-preview/Dockerfile +10 -0
  5. package/index.ts +3 -0
  6. package/migrations/0000_email.sql +53 -0
  7. package/migrations/meta/0000_snapshot.json +408 -0
  8. package/migrations/meta/_journal.json +13 -0
  9. package/package.json +91 -4
  10. package/shared/attachment-preview.ts +67 -0
  11. package/shared/mail-authentication.ts +145 -0
  12. package/shared/mail-feedback.ts +90 -0
  13. package/shared/mail-headers.ts +250 -0
  14. package/shared/mail-received.ts +298 -0
  15. package/shared/mail-remote.ts +17 -0
  16. package/shared/mail-rules.ts +156 -0
  17. package/shared/mail-sender-intel.ts +125 -0
  18. package/shared/mail-translation.ts +2 -0
  19. package/shared/mail.ts +179 -0
  20. package/shared/office-preview.ts +26 -0
  21. package/worker/admin/report-routes.ts +164 -0
  22. package/worker/bindings.d.ts +11 -0
  23. package/worker/db/schema.ts +97 -0
  24. package/worker/mail/access.ts +30 -0
  25. package/worker/mail/ai-classification.ts +119 -0
  26. package/worker/mail/ai-translation.ts +31 -0
  27. package/worker/mail/auth-headers.ts +557 -0
  28. package/worker/mail/bimi.ts +172 -0
  29. package/worker/mail/classification-signals.ts +116 -0
  30. package/worker/mail/conversation-cursor.ts +43 -0
  31. package/worker/mail/delivery-events.ts +99 -0
  32. package/worker/mail/delivery-facts.ts +122 -0
  33. package/worker/mail/demo-routes.ts +33 -0
  34. package/worker/mail/doh.ts +74 -0
  35. package/worker/mail/downloads.ts +47 -0
  36. package/worker/mail/header-metadata.ts +77 -0
  37. package/worker/mail/impersonation.ts +175 -0
  38. package/worker/mail/inbound-delivery.ts +22 -0
  39. package/worker/mail/inbound-threading.ts +48 -0
  40. package/worker/mail/inbound.ts +255 -0
  41. package/worker/mail/ip-intel.ts +135 -0
  42. package/worker/mail/link-domains.ts +57 -0
  43. package/worker/mail/mailbox-deletion.ts +18 -0
  44. package/worker/mail/mailbox-directory.ts +73 -0
  45. package/worker/mail/mailing-list.ts +120 -0
  46. package/worker/mail/message-security.ts +190 -0
  47. package/worker/mail/message-view.ts +63 -0
  48. package/worker/mail/office-conversion.ts +38 -0
  49. package/worker/mail/office-preview-container.ts +57 -0
  50. package/worker/mail/office-preview-queue.ts +49 -0
  51. package/worker/mail/org-domain.ts +15 -0
  52. package/worker/mail/outbound-content.ts +208 -0
  53. package/worker/mail/outbound-delivery.ts +83 -0
  54. package/worker/mail/outbound-service.ts +145 -0
  55. package/worker/mail/outbound-threading.ts +70 -0
  56. package/worker/mail/outbound.ts +446 -0
  57. package/worker/mail/outgoing-context.ts +122 -0
  58. package/worker/mail/participants.ts +115 -0
  59. package/worker/mail/push-notifications.ts +484 -0
  60. package/worker/mail/quote-parser.ts +50 -0
  61. package/worker/mail/r2-presigned-urls.ts +69 -0
  62. package/worker/mail/receive.ts +59 -0
  63. package/worker/mail/recipients.ts +34 -0
  64. package/worker/mail/remote-proxy.ts +140 -0
  65. package/worker/mail/reply-plan.ts +136 -0
  66. package/worker/mail/reported-messages.ts +68 -0
  67. package/worker/mail/rfc.ts +73 -0
  68. package/worker/mail/routes.ts +1434 -0
  69. package/worker/mail/safety-routes.ts +350 -0
  70. package/worker/mail/schemas.ts +339 -0
  71. package/worker/mail/search.ts +14 -0
  72. package/worker/mail/security-settings.ts +38 -0
  73. package/worker/mail/sender-intel.ts +189 -0
  74. package/worker/mail/spamhaus.ts +97 -0
  75. package/worker/mail/unsubscribe.ts +118 -0
  76. package/worker/mail/uploads.ts +537 -0
  77. package/worker/mail/webhook-source.ts +71 -0
  78. package/worker/mail/workspace-safety.ts +92 -0
  79. package/worker/mailbox/demo-html.ts +136 -0
  80. package/worker/mailbox/demo-seed.ts +79 -0
  81. package/worker/mailbox/demo.ts +978 -0
  82. package/worker/mailbox/folder-model.ts +72 -0
  83. package/worker/mailbox/index.ts +2508 -0
  84. package/worker/mailbox/legacy-migrations.ts +41 -0
  85. package/worker/mailbox/migrations.ts +10 -0
  86. package/worker/mailbox/model.ts +65 -0
  87. package/worker/mailbox/office-preview-jobs.ts +73 -0
  88. package/worker/mailbox/rules.ts +110 -0
  89. package/worker/mailbox/schema.ts +353 -0
  90. package/worker/mailbox/senders.ts +240 -0
  91. package/worker/mcp/mail-action-tools.ts +210 -0
  92. package/worker/mcp/mail-compose-tools.ts +196 -0
  93. package/worker/mcp/mail-query-tools.ts +196 -0
  94. package/worker/mcp/mail-safety-tools.ts +452 -0
  95. package/worker/mcp/tools.ts +15 -0
  96. package/worker/module.ts +59 -0
@@ -0,0 +1,484 @@
1
+ import {
2
+ sendPushNotification,
3
+ topicFromString,
4
+ WebPushError,
5
+ } from "@mmmike/web-push/send";
6
+ import {
7
+ createVapidJwt,
8
+ urlBase64ToUint8Array,
9
+ } from "@mmmike/web-push/vapid";
10
+ import { zValidator } from "@hono/zod-validator";
11
+ import { and, desc, eq, isNull, or } from "drizzle-orm";
12
+ import { Hono } from "hono";
13
+ import { z } from "zod";
14
+ import type { MailboxPushJob } from "../../shared/mail";
15
+ import { requireSessionAuth } from "@open-workspace/core/worker/auth/middleware.ts";
16
+ import { createDb } from "@open-workspace/core/worker/db/client.ts";
17
+ import { domains, mailboxAccess, mailboxes, pushSubscriptions, users } from "@open-workspace/core/worker/db/schema.ts";
18
+ import { mailboxNotificationPreferences } from "../db/schema";
19
+ import { mailboxAddressSql, mailboxKindOrderSql } from "@open-workspace/core/worker/db/mailboxes.ts";
20
+ import type { AppEnv, PushBindings } from "@open-workspace/core/worker/env.ts";
21
+ import { apiError } from "@open-workspace/core/worker/lib/http.ts";
22
+ import { createId } from "@open-workspace/core/worker/lib/ids.ts";
23
+ import { mailboxStub } from "../mailbox";
24
+ import { getMailboxAccess } from "./access";
25
+
26
+ export const PUSH_NOTIFICATION_QUEUE = "openworkspace-push-notifications";
27
+
28
+ const pushSubscriptionSchema = z.object({
29
+ endpoint: z.url().max(4_096).refine(
30
+ (value) => new URL(value).protocol === "https:",
31
+ "Push endpoint must use HTTPS",
32
+ ),
33
+ keys: z.object({
34
+ p256dh: z.string().min(1).max(512).refine((value) => {
35
+ try {
36
+ const key = urlBase64ToUint8Array(value);
37
+ return key.length === 65 && key[0] === 4;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }, "p256dh must be an uncompressed P-256 public key"),
42
+ auth: z.string().min(1).max(512).refine((value) => {
43
+ try {
44
+ return urlBase64ToUint8Array(value).length >= 16;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }, "auth must contain at least 16 bytes"),
49
+ }),
50
+ });
51
+
52
+ const subscriptionEndpointSchema = z.object({
53
+ endpoint: z.string().url().max(4_096),
54
+ });
55
+
56
+ const notificationPreferenceSchema = z.object({ enabled: z.boolean() });
57
+
58
+ const pushJobBaseSchema = z.object({
59
+ mailboxId: z.string().min(1),
60
+ conversationId: z.string().min(1),
61
+ messageId: z.string().min(1),
62
+ occurredAt: z.number().int().nonnegative(),
63
+ sender: z.string().min(1).max(500),
64
+ subject: z.string().max(998),
65
+ });
66
+
67
+ const pushJobSchema = z.discriminatedUnion("type", [
68
+ pushJobBaseSchema.extend({ type: z.literal("dispatch") }),
69
+ pushJobBaseSchema.extend({
70
+ type: z.literal("deliver"),
71
+ targetSubscriptionId: z.string().min(1),
72
+ mailboxDisplayName: z.string().min(1).max(500),
73
+ }),
74
+ ]) satisfies z.ZodType<MailboxPushJob>;
75
+
76
+ const MAX_PUSH_JOB_AGE_MS = 15 * 60 * 1_000;
77
+ const MAX_QUEUE_SEND_BATCH_SIZE = 100;
78
+
79
+ type PushConfiguration = {
80
+ publicKey: string;
81
+ privateKey: string;
82
+ subject: string;
83
+ };
84
+
85
+ type PushConfigurationStatus = {
86
+ configuration: PushConfiguration | null;
87
+ error: string | null;
88
+ };
89
+
90
+ let cachedPushConfiguration: {
91
+ source: string;
92
+ status: Promise<PushConfigurationStatus>;
93
+ } | null = null;
94
+
95
+ class RetryablePushError extends Error {
96
+ constructor(
97
+ readonly delaySeconds: number,
98
+ cause: unknown,
99
+ ) {
100
+ super("Push delivery should be retried", { cause });
101
+ }
102
+ }
103
+
104
+ function retryDelaySeconds(error: unknown) {
105
+ if (
106
+ error instanceof WebPushError
107
+ && error.statusCode !== 408
108
+ && error.statusCode !== 429
109
+ && error.statusCode < 500
110
+ ) {
111
+ return null;
112
+ }
113
+ const timedOut = error instanceof DOMException
114
+ && (error.name === "AbortError" || error.name === "TimeoutError");
115
+ if (
116
+ !(error instanceof WebPushError)
117
+ && !(error instanceof TypeError)
118
+ && !timedOut
119
+ ) {
120
+ return null;
121
+ }
122
+ const delayMs = error instanceof WebPushError
123
+ ? error.retryAfterMs ?? 30_000
124
+ : 30_000;
125
+ return Math.max(1, Math.min(12 * 60 * 60, Math.ceil(delayMs / 1_000)));
126
+ }
127
+
128
+ function pushFailureDetails(error: unknown) {
129
+ if (error instanceof WebPushError) {
130
+ return {
131
+ name: error.name,
132
+ message: error.message,
133
+ statusCode: error.statusCode,
134
+ retryAfterMs: error.retryAfterMs,
135
+ };
136
+ }
137
+ return error instanceof Error
138
+ ? { name: error.name, message: error.message }
139
+ : { message: String(error) };
140
+ }
141
+
142
+ function pushConfiguration(env: PushBindings) {
143
+ const publicKey = env.VAPID_PUBLIC_KEY;
144
+ const privateKey = env.VAPID_PRIVATE_KEY;
145
+ const subject = env.VAPID_SUBJECT;
146
+ if (!publicKey || !privateKey || !subject) {
147
+ return Promise.resolve({
148
+ configuration: null,
149
+ error: null,
150
+ } satisfies PushConfigurationStatus);
151
+ }
152
+
153
+ const source = `${publicKey}\0${privateKey}\0${subject}`;
154
+ if (cachedPushConfiguration?.source === source) {
155
+ return cachedPushConfiguration.status;
156
+ }
157
+ const configuration = { publicKey, privateKey, subject };
158
+ const status = createVapidJwt({
159
+ audience: "https://push.invalid",
160
+ expiration: 60,
161
+ ...configuration,
162
+ }).then(() => ({
163
+ configuration,
164
+ error: null,
165
+ })).catch((error: unknown) => {
166
+ console.error("Invalid Web Push configuration", pushFailureDetails(error));
167
+ return {
168
+ configuration: null,
169
+ error: "Web Push server configuration is invalid.",
170
+ };
171
+ });
172
+ cachedPushConfiguration = { source, status };
173
+ return status;
174
+ }
175
+
176
+ export const pushNotificationRoutes = new Hono<AppEnv>()
177
+ .use("*", requireSessionAuth)
178
+ .get("/config", async (c) => {
179
+ const status = await pushConfiguration(c.env);
180
+ return c.json({
181
+ ok: true as const,
182
+ enabled: Boolean(status.configuration),
183
+ publicKey: status.configuration?.publicKey ?? null,
184
+ error: status.error,
185
+ });
186
+ })
187
+ .get("/preferences", async (c) => {
188
+ const userId = c.get("user").id;
189
+ const rows = await createDb(c.env.DB)
190
+ .select({
191
+ mailboxId: mailboxes.id,
192
+ displayName: mailboxes.displayName,
193
+ address: mailboxAddressSql,
194
+ enabled: mailboxNotificationPreferences.enabled,
195
+ })
196
+ .from(mailboxAccess)
197
+ .innerJoin(mailboxes, eq(mailboxAccess.mailboxId, mailboxes.id))
198
+ .innerJoin(domains, eq(mailboxes.domainId, domains.id))
199
+ .leftJoin(
200
+ mailboxNotificationPreferences,
201
+ and(
202
+ eq(mailboxNotificationPreferences.mailboxId, mailboxAccess.mailboxId),
203
+ eq(mailboxNotificationPreferences.userId, mailboxAccess.userId),
204
+ ),
205
+ )
206
+ .where(eq(mailboxAccess.userId, userId))
207
+ .orderBy(
208
+ mailboxKindOrderSql,
209
+ desc(mailboxes.isPrimary),
210
+ mailboxes.displayName,
211
+ );
212
+ return c.json({
213
+ ok: true as const,
214
+ preferences: rows.map((row) => ({ ...row, enabled: row.enabled ?? true })),
215
+ });
216
+ })
217
+ .put(
218
+ "/preferences/:mailboxId",
219
+ zValidator("json", notificationPreferenceSchema),
220
+ async (c) => {
221
+ const userId = c.get("user").id;
222
+ const mailboxId = c.req.param("mailboxId");
223
+ if (!await getMailboxAccess(createDb(c.env.DB), userId, mailboxId)) {
224
+ return apiError(c, 403, "FORBIDDEN", "Mailbox access is required");
225
+ }
226
+ const now = new Date();
227
+ await createDb(c.env.DB)
228
+ .insert(mailboxNotificationPreferences)
229
+ .values({ mailboxId, userId, enabled: c.req.valid("json").enabled, updatedAt: now })
230
+ .onConflictDoUpdate({
231
+ target: [
232
+ mailboxNotificationPreferences.mailboxId,
233
+ mailboxNotificationPreferences.userId,
234
+ ],
235
+ set: { enabled: c.req.valid("json").enabled, updatedAt: now },
236
+ });
237
+ return c.json({ ok: true as const });
238
+ },
239
+ )
240
+ .put(
241
+ "/subscriptions",
242
+ zValidator("json", pushSubscriptionSchema),
243
+ async (c) => {
244
+ const input = c.req.valid("json");
245
+ const now = new Date();
246
+ await createDb(c.env.DB)
247
+ .insert(pushSubscriptions)
248
+ .values({
249
+ id: createId("push"),
250
+ userId: c.get("user").id,
251
+ endpoint: input.endpoint,
252
+ p256dh: input.keys.p256dh,
253
+ auth: input.keys.auth,
254
+ createdAt: now,
255
+ updatedAt: now,
256
+ })
257
+ .onConflictDoUpdate({
258
+ target: pushSubscriptions.endpoint,
259
+ set: {
260
+ userId: c.get("user").id,
261
+ p256dh: input.keys.p256dh,
262
+ auth: input.keys.auth,
263
+ updatedAt: now,
264
+ },
265
+ });
266
+ return c.json({ ok: true as const });
267
+ },
268
+ )
269
+ .delete(
270
+ "/subscriptions",
271
+ zValidator("json", subscriptionEndpointSchema),
272
+ async (c) => {
273
+ await createDb(c.env.DB)
274
+ .delete(pushSubscriptions)
275
+ .where(and(
276
+ eq(pushSubscriptions.userId, c.get("user").id),
277
+ eq(pushSubscriptions.endpoint, c.req.valid("json").endpoint),
278
+ ));
279
+ return c.json({ ok: true as const });
280
+ },
281
+ );
282
+
283
+ async function subscriptionsForMailbox(
284
+ env: Env & PushBindings,
285
+ mailboxId: string,
286
+ ) {
287
+ const db = createDb(env.DB);
288
+ return db.select({
289
+ id: pushSubscriptions.id,
290
+ })
291
+ .from(pushSubscriptions)
292
+ .innerJoin(users, eq(pushSubscriptions.userId, users.id))
293
+ .innerJoin(
294
+ mailboxAccess,
295
+ and(
296
+ eq(mailboxAccess.userId, pushSubscriptions.userId),
297
+ eq(mailboxAccess.mailboxId, mailboxId),
298
+ ),
299
+ )
300
+ .leftJoin(
301
+ mailboxNotificationPreferences,
302
+ and(
303
+ eq(mailboxNotificationPreferences.userId, pushSubscriptions.userId),
304
+ eq(mailboxNotificationPreferences.mailboxId, mailboxId),
305
+ ),
306
+ )
307
+ .where(and(
308
+ eq(users.status, "active"),
309
+ or(
310
+ isNull(mailboxNotificationPreferences.enabled),
311
+ eq(mailboxNotificationPreferences.enabled, true),
312
+ ),
313
+ ));
314
+ }
315
+
316
+ async function dispatchPushJob(
317
+ env: Env & PushBindings,
318
+ job: Extract<MailboxPushJob, { type: "dispatch" }>,
319
+ ) {
320
+ const db = createDb(env.DB);
321
+ const [mailbox, subscriptions] = await Promise.all([
322
+ db.select({ displayName: mailboxes.displayName })
323
+ .from(mailboxes)
324
+ .where(eq(mailboxes.id, job.mailboxId))
325
+ .limit(1)
326
+ .then((rows) => rows[0]),
327
+ subscriptionsForMailbox(env, job.mailboxId),
328
+ ]);
329
+ if (!mailbox || !subscriptions.length) return;
330
+
331
+ const requests: MessageSendRequest<MailboxPushJob>[] = subscriptions.map(
332
+ (subscription) => ({
333
+ body: {
334
+ ...job,
335
+ type: "deliver",
336
+ targetSubscriptionId: subscription.id,
337
+ mailboxDisplayName: mailbox.displayName,
338
+ },
339
+ }),
340
+ );
341
+ for (let index = 0; index < requests.length; index += MAX_QUEUE_SEND_BATCH_SIZE) {
342
+ await env.PUSH_NOTIFICATIONS.sendBatch(
343
+ requests.slice(index, index + MAX_QUEUE_SEND_BATCH_SIZE),
344
+ );
345
+ }
346
+ }
347
+
348
+ async function targetSubscription(
349
+ env: Env & PushBindings,
350
+ job: Extract<MailboxPushJob, { type: "deliver" }>,
351
+ ) {
352
+ return createDb(env.DB).select({
353
+ id: pushSubscriptions.id,
354
+ userId: pushSubscriptions.userId,
355
+ endpoint: pushSubscriptions.endpoint,
356
+ p256dh: pushSubscriptions.p256dh,
357
+ auth: pushSubscriptions.auth,
358
+ })
359
+ .from(pushSubscriptions)
360
+ .innerJoin(users, eq(pushSubscriptions.userId, users.id))
361
+ .innerJoin(
362
+ mailboxAccess,
363
+ and(
364
+ eq(mailboxAccess.userId, pushSubscriptions.userId),
365
+ eq(mailboxAccess.mailboxId, job.mailboxId),
366
+ ),
367
+ )
368
+ .leftJoin(
369
+ mailboxNotificationPreferences,
370
+ and(
371
+ eq(mailboxNotificationPreferences.userId, pushSubscriptions.userId),
372
+ eq(mailboxNotificationPreferences.mailboxId, job.mailboxId),
373
+ ),
374
+ )
375
+ .where(and(
376
+ eq(users.status, "active"),
377
+ or(
378
+ isNull(mailboxNotificationPreferences.enabled),
379
+ eq(mailboxNotificationPreferences.enabled, true),
380
+ ),
381
+ eq(pushSubscriptions.id, job.targetSubscriptionId),
382
+ ))
383
+ .limit(1)
384
+ .then((rows) => rows[0]);
385
+ }
386
+
387
+ async function removeSubscription(
388
+ env: Env & PushBindings,
389
+ subscriptionId: string,
390
+ ) {
391
+ try {
392
+ await createDb(env.DB)
393
+ .delete(pushSubscriptions)
394
+ .where(eq(pushSubscriptions.id, subscriptionId));
395
+ } catch (error) {
396
+ console.error("Could not remove invalid push subscription", error);
397
+ }
398
+ }
399
+
400
+ async function deliverPushJob(
401
+ env: Env & PushBindings,
402
+ job: Extract<MailboxPushJob, { type: "deliver" }>,
403
+ configuration: PushConfiguration,
404
+ ageMs: number,
405
+ ) {
406
+ const subscription = await targetSubscription(env, job);
407
+ if (!subscription) return;
408
+ const suppressed = await mailboxStub(env, job.mailboxId).shouldSuppressPush(
409
+ job.messageId,
410
+ subscription.userId,
411
+ );
412
+ if (suppressed) return;
413
+
414
+ try {
415
+ const delivered = await sendPushNotification(
416
+ {
417
+ endpoint: subscription.endpoint,
418
+ keys: { p256dh: subscription.p256dh, auth: subscription.auth },
419
+ },
420
+ {
421
+ title: `New email in ${job.mailboxDisplayName}`,
422
+ body: `${job.sender} · ${job.subject || "(no subject)"}`,
423
+ url: `/mail/${encodeURIComponent(job.mailboxId)}?folder=inbox&conversation=${encodeURIComponent(job.conversationId)}`,
424
+ tag: `message-${job.messageId}`,
425
+ },
426
+ configuration,
427
+ {
428
+ ttl: Math.max(1, Math.ceil((MAX_PUSH_JOB_AGE_MS - ageMs) / 1_000)),
429
+ urgency: "high",
430
+ topic: await topicFromString(`message:${job.messageId}`),
431
+ },
432
+ );
433
+ if (!delivered) await removeSubscription(env, subscription.id);
434
+ } catch (error) {
435
+ const delaySeconds = retryDelaySeconds(error);
436
+ if (delaySeconds === null) {
437
+ console.error(
438
+ "Push notification delivery was rejected",
439
+ pushFailureDetails(error),
440
+ );
441
+ return;
442
+ }
443
+ throw new RetryablePushError(delaySeconds, error);
444
+ }
445
+ }
446
+
447
+ async function processPushJob(env: Env & PushBindings, job: MailboxPushJob) {
448
+ const ageMs = Math.max(0, Date.now() - job.occurredAt);
449
+ if (ageMs >= MAX_PUSH_JOB_AGE_MS) return;
450
+ const configuration = (await pushConfiguration(env)).configuration;
451
+ if (!configuration) return;
452
+ if (job.type === "dispatch") await dispatchPushJob(env, job);
453
+ else await deliverPushJob(env, job, configuration, ageMs);
454
+ }
455
+
456
+ export async function consumePushNotifications(
457
+ batch: MessageBatch<unknown>,
458
+ env: Env & PushBindings,
459
+ ) {
460
+ await Promise.all(batch.messages.map(async (message) => {
461
+ const parsed = pushJobSchema.safeParse(message.body);
462
+ if (!parsed.success) {
463
+ console.error("Ignoring invalid push notification job", parsed.error);
464
+ message.ack();
465
+ return;
466
+ }
467
+ try {
468
+ await processPushJob(env, parsed.data);
469
+ message.ack();
470
+ } catch (error) {
471
+ console.error(
472
+ "Could not deliver push notification job",
473
+ pushFailureDetails(
474
+ error instanceof RetryablePushError ? error.cause : error,
475
+ ),
476
+ );
477
+ message.retry({
478
+ delaySeconds: error instanceof RetryablePushError
479
+ ? error.delaySeconds
480
+ : 30,
481
+ });
482
+ }
483
+ }));
484
+ }
@@ -0,0 +1,50 @@
1
+ export type ParsedReplyText = {
2
+ bodyText: string;
3
+ quotedText: string | null;
4
+ };
5
+
6
+ const ATTRIBUTION_LINE = [
7
+ /^on .+wrote:\s*$/iu,
8
+ /^am .+schrieb .+:\s*$/iu,
9
+ /^le .+a écrit\s*:\s*$/iu,
10
+ /^в .+написал(?:а)?:\s*$/iu,
11
+ /^-----\s*original message\s*-----$/iu,
12
+ /^-----\s*исходное сообщение\s*-----$/iu,
13
+ ];
14
+
15
+ const OUTLOOK_HEADER = /^from:\s+.+$/iu;
16
+ const OUTLOOK_FOLLOWUP = /^(?:sent|date|to|subject):\s+.+$/iu;
17
+
18
+ function isAttribution(lines: string[], index: number) {
19
+ const line = lines[index]!.trim();
20
+ if (ATTRIBUTION_LINE.some((pattern) => pattern.test(line))) return true;
21
+ if (!OUTLOOK_HEADER.test(line)) return false;
22
+ return lines
23
+ .slice(index + 1, index + 5)
24
+ .some((candidate) => OUTLOOK_FOLLOWUP.test(candidate.trim()));
25
+ }
26
+
27
+ function quotedBlockStart(lines: string[]) {
28
+ for (let index = 0; index < lines.length; index += 1) {
29
+ if (isAttribution(lines, index)) return index;
30
+ }
31
+ for (let index = 0; index < lines.length; index += 1) {
32
+ if (!/^\s*>/u.test(lines[index]!)) continue;
33
+ const remaining = lines.slice(index).filter((line) => line.trim());
34
+ const quoted = remaining.filter((line) => /^\s*>/u.test(line)).length;
35
+ if (remaining.length && quoted / remaining.length >= 0.6) return index;
36
+ }
37
+ return -1;
38
+ }
39
+
40
+ export function parseReplyText(value: string | undefined | null): ParsedReplyText {
41
+ const normalized = (value ?? "").replaceAll("\r\n", "\n").trim();
42
+ if (!normalized) return { bodyText: "", quotedText: null };
43
+ const lines = normalized.split("\n");
44
+ const splitAt = quotedBlockStart(lines);
45
+ if (splitAt < 0) return { bodyText: normalized, quotedText: null };
46
+ const bodyText = lines.slice(0, splitAt).join("\n").trim();
47
+ const quotedText = lines.slice(splitAt).join("\n").trim();
48
+ return { bodyText, quotedText: quotedText || null };
49
+ }
50
+
@@ -0,0 +1,69 @@
1
+ import { AwsClient } from "aws4fetch";
2
+ import type { AppEnv } from "@open-workspace/core/worker/env.ts";
3
+
4
+ type Bindings = AppEnv["Bindings"];
5
+
6
+ const PRESIGN_TTL_SECONDS = 15 * 60;
7
+
8
+ function r2S3Credentials(env: Bindings) {
9
+ const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
10
+ const secretAccessKey = env.R2_SECRET_ACCESS_KEY?.trim();
11
+ const accountId = env.R2_ACCOUNT_ID?.trim();
12
+ const bucketName = env.R2_BUCKET_NAME?.trim() || "openworkspace";
13
+ if (!accessKeyId || !secretAccessKey || !accountId) return null;
14
+ return { accessKeyId, secretAccessKey, accountId, bucketName };
15
+ }
16
+
17
+ function r2Signer(env: Bindings, r2Key: string) {
18
+ const credentials = r2S3Credentials(env);
19
+ if (!credentials) return null;
20
+ return {
21
+ client: new AwsClient({
22
+ accessKeyId: credentials.accessKeyId,
23
+ secretAccessKey: credentials.secretAccessKey,
24
+ service: "s3",
25
+ region: "auto",
26
+ }),
27
+ url: new URL(
28
+ `https://${credentials.accountId}.r2.cloudflarestorage.com/${credentials.bucketName}/${r2Key}`,
29
+ ),
30
+ };
31
+ }
32
+
33
+ export async function signR2PutUrl(input: {
34
+ env: Bindings;
35
+ r2Key: string;
36
+ contentType: string;
37
+ }) {
38
+ const signer = r2Signer(input.env, input.r2Key);
39
+ if (!signer) return null;
40
+ signer.url.searchParams.set("X-Amz-Expires", String(PRESIGN_TTL_SECONDS));
41
+ const signed = await signer.client.sign(signer.url, {
42
+ method: "PUT",
43
+ headers: {
44
+ "content-type": input.contentType,
45
+ },
46
+ aws: {
47
+ allHeaders: true,
48
+ signQuery: true,
49
+ },
50
+ });
51
+ return signed.url;
52
+ }
53
+
54
+ export async function signR2GetUrl(input: {
55
+ env: Bindings;
56
+ r2Key: string;
57
+ }) {
58
+ const signer = r2Signer(input.env, input.r2Key);
59
+ if (!signer) return null;
60
+ signer.url.searchParams.set("X-Amz-Expires", String(PRESIGN_TTL_SECONDS));
61
+ const signed = await signer.client.sign(signer.url, {
62
+ method: "GET",
63
+ aws: { signQuery: true },
64
+ });
65
+ return {
66
+ url: signed.url,
67
+ expiresAt: Date.now() + PRESIGN_TTL_SECONDS * 1_000,
68
+ };
69
+ }
@@ -0,0 +1,59 @@
1
+ import { createDb } from "@open-workspace/core/worker/db/client.ts";
2
+ import { mailboxAddressPredicate } from "@open-workspace/core/worker/db/mailboxes.ts";
3
+ import { domains, mailboxes } from "@open-workspace/core/worker/db/schema.ts";
4
+ import { normalizeMailboxAddress } from "@open-workspace/core/worker/lib/ids.ts";
5
+ import { mailboxStub } from "../mailbox";
6
+ import { inboundDeliveryId } from "./inbound-delivery";
7
+ import { deliverToList } from "./mailing-list";
8
+
9
+ /** Stores incoming mail for a workspace address, or fans a list's mail out. */
10
+ export async function receiveEmail(message: ForwardableEmailMessage, env: Env) {
11
+ const receivedAt = Date.now();
12
+ const recipient = normalizeMailboxAddress(message.to);
13
+ const [mailbox] = await createDb(env.DB)
14
+ .select({
15
+ id: mailboxes.id,
16
+ teamId: mailboxes.teamId,
17
+ delivery: mailboxes.delivery,
18
+ listPosting: mailboxes.listPosting,
19
+ })
20
+ .from(mailboxes)
21
+ .innerJoin(domains, mailboxAddressPredicate(recipient))
22
+ .limit(1);
23
+ if (!mailbox) {
24
+ message.setReject(`Mailbox ${recipient} does not exist`);
25
+ return;
26
+ }
27
+
28
+ const raw = await new Response(message.raw).arrayBuffer();
29
+ if (mailbox.delivery === "list" && mailbox.teamId) {
30
+ await deliverToList({
31
+ env,
32
+ list: { id: mailbox.id, teamId: mailbox.teamId, listPosting: mailbox.listPosting },
33
+ message,
34
+ raw,
35
+ receivedAt,
36
+ });
37
+ return;
38
+ }
39
+ const deliveryId = await inboundDeliveryId({
40
+ mailboxId: mailbox.id,
41
+ envelopeFrom: message.from,
42
+ envelopeTo: message.to,
43
+ raw,
44
+ });
45
+ const objectKey = `mailboxes/${mailbox.id}/raw/${deliveryId}.eml`;
46
+ await mailboxStub(env, mailbox.id).enqueueInbound({
47
+ id: deliveryId,
48
+ mailboxId: mailbox.id,
49
+ rawObjectKey: objectKey,
50
+ envelopeFrom: message.from,
51
+ envelopeTo: message.to,
52
+ receivedAt,
53
+ });
54
+ await env.MAIL_STORAGE.put(
55
+ objectKey,
56
+ raw,
57
+ { httpMetadata: { contentType: "message/rfc822" } },
58
+ );
59
+ }
@@ -0,0 +1,34 @@
1
+ import { normalizeEmail } from "@open-workspace/core/worker/lib/ids.ts";
2
+
3
+ export type RecipientFields = {
4
+ to: string[];
5
+ cc: string[];
6
+ bcc: string[];
7
+ };
8
+
9
+ /**
10
+ * A destination can occupy only one visible/hidden role. Precedence follows
11
+ * what recipients can observe: To, then Cc, then Bcc.
12
+ */
13
+ export function dedupeRecipientFields(input: RecipientFields): RecipientFields {
14
+ const seen = new Set<string>();
15
+ const take = (values: string[]) => {
16
+ const result: string[] = [];
17
+ for (const value of values) {
18
+ const normalized = normalizeEmail(value);
19
+ if (!normalized || seen.has(normalized)) continue;
20
+ seen.add(normalized);
21
+ result.push(normalized);
22
+ }
23
+ return result;
24
+ };
25
+ return {
26
+ to: take(input.to),
27
+ cc: take(input.cc),
28
+ bcc: take(input.bcc),
29
+ };
30
+ }
31
+
32
+ export function recipientCount(input: RecipientFields) {
33
+ return input.to.length + input.cc.length + input.bcc.length;
34
+ }