Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
+46
View File
@@ -91,11 +91,35 @@ export const createEventInputSchema = z.object({
inviteCode: z.string().trim().max(80).optional(),
});
export const publishingPolicySchema = z.enum(["automatic", "approved", "never"]);
export type EmailPayload = { to: string; from: string; subject: string; html: string; text: string; referenceId: string; attachments?: { filename: string; content: string; contentId: string }[] };
export const checkEventSlugInputSchema = z.object({ eventId: z.string().uuid(), slug: eventSlugSchema });
export const generateEventSlugInputSchema = z.object({ eventId: z.string().uuid(), title: z.string().trim().min(1).max(120) });
export type PublishingPolicy = z.infer<typeof publishingPolicySchema>;
export const moderateNoteInputSchema = z.object({ eventId: z.string().uuid(), guestId: z.string().uuid(), approved: z.boolean() });
export const updateEventInputSchema = z.object({
publishAt: z.coerce.date().nullable().optional(),
submissionsOpenAt: z.coerce.date().nullable().optional(),
submissionsCloseAt: z.coerce.date().nullable().optional(),
galleryVisibleAt: z.coerce.date().nullable().optional(),
notesVisibleAt: z.coerce.date().nullable().optional(),
notesPolicy: publishingPolicySchema.optional(),
galleryPolicy: publishingPolicySchema.optional(),
showPhotoStats: z.boolean().optional(),
showSubmitterStats: z.boolean().optional(),
showNoteStats: z.boolean().optional(),
eventId: z.string().uuid(),
title: z.string().trim().min(1).max(120).optional(),
slug: eventSlugSchema.optional(),
description: z.string().trim().max(2000).nullable().optional(),
location: z.string().trim().max(300).nullable().optional(),
locationCoordinates: z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}).nullable().optional(),
bannerPhotoId: z.string().uuid().nullable().optional(),
customBannerId: z.string().uuid().nullable().optional(),
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
status: eventStatusSchema.optional(),
@@ -103,6 +127,18 @@ export const updateEventInputSchema = z.object({
uploadEnabled: z.boolean().optional(),
});
export const locationSearchInputSchema = z.object({
eventId: z.string().uuid(),
query: z.string().trim().min(3).max(300),
});
export const locationSuggestionSchema = z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
});
export type LocationSuggestion = z.infer<typeof locationSuggestionSchema>;
export const ensureGuestInputSchema = z.object({
eventSlug: eventSlugSchema,
displayName: contributorNameSchema,
@@ -136,6 +172,16 @@ export const completePhotoInputSchema = z.object({
photoId: z.string().uuid(),
});
export const createBannerInputSchema = z.object({
eventId: z.string().uuid(),
contentType: allowedImageTypeSchema,
byteSize: z.number().int().positive().max(MAX_PHOTO_BYTES),
});
export const bannerInputSchema = z.object({
eventId: z.string().uuid(),
bannerId: z.string().uuid(),
});
export const moderatePhotoInputSchema = z.object({
photoId: z.string().uuid(),
visibility: photoVisibilitySchema,
@@ -0,0 +1,3 @@
ALTER TABLE "events" ADD COLUMN "location" text;
--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "banner_photo_id" uuid;
@@ -0,0 +1,8 @@
ALTER TABLE "events" ADD COLUMN "latitude" double precision;
--> statement-breakpoint
ALTER TABLE "events" ADD COLUMN "longitude" double precision;
--> statement-breakpoint
ALTER TABLE "events" ADD CONSTRAINT "events_coordinates_check" CHECK (
("latitude" IS NULL AND "longitude" IS NULL) OR
("latitude" IS NOT NULL AND "longitude" IS NOT NULL AND "latitude" BETWEEN -90 AND 90 AND "longitude" BETWEEN -180 AND 180)
);
@@ -0,0 +1,17 @@
ALTER TABLE "events" ADD COLUMN "custom_banner_id" uuid;
--> statement-breakpoint
CREATE TABLE "event_banners" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"event_id" uuid NOT NULL REFERENCES "events"("id") ON DELETE CASCADE,
"original_key" text NOT NULL,
"display_key" text,
"content_type" text NOT NULL,
"byte_size" integer NOT NULL,
"status" text NOT NULL DEFAULT 'uploading',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE INDEX "event_banners_event_idx" ON "event_banners" ("event_id");
--> statement-breakpoint
CREATE INDEX "event_banners_status_idx" ON "event_banners" ("status");
@@ -0,0 +1,6 @@
ALTER TABLE events ADD COLUMN notes_policy text NOT NULL DEFAULT 'never' CHECK (notes_policy IN ('automatic', 'approved', 'never'));
ALTER TABLE events ADD COLUMN gallery_policy text NOT NULL DEFAULT 'approved' CHECK (gallery_policy IN ('automatic', 'approved', 'never'));
ALTER TABLE events ADD COLUMN show_photo_stats boolean NOT NULL DEFAULT false;
ALTER TABLE events ADD COLUMN show_submitter_stats boolean NOT NULL DEFAULT false;
ALTER TABLE events ADD COLUMN show_note_stats boolean NOT NULL DEFAULT false;
ALTER TABLE guests ADD COLUMN note_approved boolean NOT NULL DEFAULT false;
@@ -0,0 +1,7 @@
ALTER TABLE events ADD COLUMN publish_at timestamptz;
ALTER TABLE events ADD COLUMN submissions_open_at timestamptz;
ALTER TABLE events ADD COLUMN submissions_close_at timestamptz;
ALTER TABLE events ADD COLUMN gallery_visible_at timestamptz;
ALTER TABLE events ADD COLUMN notes_visible_at timestamptz;
ALTER TABLE events ADD COLUMN completed_at timestamptz;
ALTER TABLE guests ADD COLUMN notification_claimed_at timestamptz;
@@ -0,0 +1,9 @@
CREATE TABLE email_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
recipient text NOT NULL, payload jsonb NOT NULL, provider text NOT NULL,
status text NOT NULL DEFAULT 'pending', attempts integer NOT NULL DEFAULT 0,
first_attempt_at timestamptz, next_attempt_at timestamptz NOT NULL DEFAULT now(),
provider_id text, last_error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX email_deliveries_event_recipient_idx ON email_deliveries(event_id, recipient);
CREATE INDEX email_deliveries_queue_idx ON email_deliveries(status, next_attempt_at);
@@ -0,0 +1,8 @@
CREATE TABLE "email_webhook_events" (
"id" text PRIMARY KEY,
"provider_id" text NOT NULL,
"outcome" text NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"received_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "email_webhook_events_provider_idx" ON "email_webhook_events" ("provider_id");
@@ -15,6 +15,55 @@
"when": 1788819000000,
"tag": "0001_groups_roles_guests",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788905400000,
"tag": "0002_event_information",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1788905500000,
"tag": "0003_event_coordinates",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1788905600000,
"tag": "0004_event_banner_uploads",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1788905700000,
"tag": "0005_publishing_preferences",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1788905800000,
"tag": "0006_event_lifecycle",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788905900000,
"tag": "0007_email_outbox",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788906000000,
"tag": "0008_email_webhooks",
"breakpoints": true
}
]
}
+1
View File
@@ -18,6 +18,7 @@
"seed": "bun src/seed.ts"
},
"dependencies": {
"@album/contracts": "workspace:*",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.7"
-13
View File
@@ -1,21 +1,8 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as appSchema from "./schema";
import * as authSchema from "./auth-schema";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const schema = { ...appSchema, ...authSchema };
type PostgresClient = ReturnType<typeof postgres>;
+59
View File
@@ -1,5 +1,7 @@
import {
boolean,
check,
doublePrecision,
index,
integer,
jsonb,
@@ -138,21 +140,76 @@ export const events = pgTable(
slug: text("slug").notNull(),
title: text("title").notNull(),
description: text("description"),
location: text("location"),
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
bannerPhotoId: uuid("banner_photo_id"),
customBannerId: uuid("custom_banner_id"),
startsAt: timestamp("starts_at", { withTimezone: true }),
endsAt: timestamp("ends_at", { withTimezone: true }),
status: eventStatus("status").notNull().default("draft"),
listed: boolean("listed").notNull().default(false),
uploadEnabled: boolean("upload_enabled").notNull().default(true),
galleryReleasedAt: timestamp("gallery_released_at", { withTimezone: true }),
publishAt: timestamp("publish_at", { withTimezone: true }),
submissionsOpenAt: timestamp("submissions_open_at", { withTimezone: true }),
submissionsCloseAt: timestamp("submissions_close_at", { withTimezone: true }),
galleryVisibleAt: timestamp("gallery_visible_at", { withTimezone: true }),
notesVisibleAt: timestamp("notes_visible_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
notesPolicy: text("notes_policy").$type<"automatic" | "approved" | "never">().notNull().default("never"),
galleryPolicy: text("gallery_policy").$type<"automatic" | "approved" | "never">().notNull().default("approved"),
showPhotoStats: boolean("show_photo_stats").notNull().default(false),
showSubmitterStats: boolean("show_submitter_stats").notNull().default(false),
showNoteStats: boolean("show_note_stats").notNull().default(false),
...timestamps,
},
(table) => [
uniqueIndex("events_slug_idx").on(table.slug),
check("events_notes_policy_check", sql`${table.notesPolicy} IN ('automatic', 'approved', 'never')`),
check("events_gallery_policy_check", sql`${table.galleryPolicy} IN ('automatic', 'approved', 'never')`),
check("events_coordinates_check", sql`(${table.latitude} IS NULL AND ${table.longitude} IS NULL) OR (${table.latitude} IS NOT NULL AND ${table.longitude} IS NOT NULL AND ${table.latitude} BETWEEN -90 AND 90 AND ${table.longitude} BETWEEN -180 AND 180)`),
index("events_group_id_idx").on(table.groupId),
index("events_listed_idx").on(table.listed, table.status),
],
);
export const emailDeliveries = pgTable("email_deliveries", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
recipient: text("recipient").notNull(),
payload: jsonb("payload").$type<import("@album/contracts").EmailPayload>().notNull(),
provider: text("provider").notNull(),
status: text("status").notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
firstAttemptAt: timestamp("first_attempt_at", { withTimezone: true }),
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(),
providerId: text("provider_id"),
lastError: text("last_error"),
...timestamps,
}, (table) => [uniqueIndex("email_deliveries_event_recipient_idx").on(table.eventId, table.recipient), index("email_deliveries_queue_idx").on(table.status, table.nextAttemptAt)]);
// Provider callbacks may precede the worker's providerId write. Keep a minimal
// inbox independently, without storing recipient addresses or raw payloads.
export const emailWebhookEvents = pgTable("email_webhook_events", {
id: text("id").primaryKey(),
providerId: text("provider_id").notNull(),
outcome: text("outcome").notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index("email_webhook_events_provider_idx").on(table.providerId)]);
export const eventBanners = pgTable("event_banners", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
originalKey: text("original_key").notNull(),
displayKey: text("display_key"),
contentType: text("content_type").notNull(),
byteSize: integer("byte_size").notNull(),
status: text("status").notNull().default("uploading"),
...timestamps,
}, (table) => [index("event_banners_event_idx").on(table.eventId), index("event_banners_status_idx").on(table.status)]);
export const eventMemberships = pgTable(
"event_memberships",
{
@@ -272,7 +329,9 @@ export const guests = pgTable(
email: text("email"),
notifyWhenReady: boolean("notify_when_ready").notNull().default(false),
notifiedAt: timestamp("notified_at", { withTimezone: true }),
notificationClaimedAt: timestamp("notification_claimed_at", { withTimezone: true }),
note: text("note"),
noteApproved: boolean("note_approved").notNull().default(false),
tokenHash: text("token_hash").notNull(),
...timestamps,
},
+10 -3
View File
@@ -117,14 +117,15 @@ if (group) {
.values({
groupId: group.id,
slug: "demo",
title: "Demo gathering",
title: "Riverhead Raceway Championship Night",
description:
"A sample event so you can try guest uploads and host moderation.",
"A late-summer night under the lights at Riverhead Raceway — feature winners, victory lane, and the people who make race night happen.",
status: "published",
listed: true,
uploadEnabled: true,
galleryReleasedAt: new Date(),
startsAt: new Date(),
startsAt: new Date("2026-09-06T22:00:00.000Z"),
endsAt: new Date("2026-09-07T02:00:00.000Z"),
})
.returning();
console.info("Seeded published event /e/demo");
@@ -133,8 +134,14 @@ if (group) {
.update(events)
.set({
groupId: group.id,
title: "Riverhead Raceway Championship Night",
description:
"A late-summer night under the lights at Riverhead Raceway — feature winners, victory lane, and the people who make race night happen.",
listed: true,
uploadEnabled: true,
galleryReleasedAt: event.galleryReleasedAt ?? new Date(),
startsAt: new Date("2026-09-06T22:00:00.000Z"),
endsAt: new Date("2026-09-07T02:00:00.000Z"),
status: "published",
updatedAt: new Date(),
})
@@ -0,0 +1,21 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
const url = new URL(process.env.DATABASE_URL ?? "postgres://album:album@localhost:5439/album");
if (!["localhost", "127.0.0.1"].includes(url.hostname)) throw new Error("Clean migration verification is local-only");
const admin = postgres(url.toString(), { max: 1 });
const databaseName = `manyangles_verify_${crypto.randomUUID().replaceAll("-", "")}`;
await admin`CREATE DATABASE ${admin(databaseName)}`;
url.pathname = `/${databaseName}`;
const client = postgres(url.toString(), { max: 1 });
try {
await migrate(drizzle(client), { migrationsFolder: new URL("../drizzle", import.meta.url).pathname });
const rows = await client`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('email_deliveries', 'events', 'guests', 'event_banners')`;
if (rows.length !== 4) throw new Error("Expected lifecycle/outbox tables were not created");
console.info("Clean-database migrations passed");
} finally {
await client.end();
await admin`DROP DATABASE ${admin(databaseName)}`;
await admin.end();
}
+6 -1
View File
@@ -4,7 +4,9 @@
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./queue": "./src/queue.ts",
"./webhooks": "./src/webhooks.ts"
},
"scripts": {
"build": "tsc --noEmit",
@@ -12,6 +14,9 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@album/contracts": "workspace:*",
"@album/database": "workspace:*",
"drizzle-orm": "^0.45.2",
"nodemailer": "^9.0.3",
"resend": "^6.18.0"
},
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { renderAlbumReadyEmail, emailBrowserPreview } from "./index";
test("gallery email escapes content and includes a plain-text alternative", () => {
const message = renderAlbumReadyEmail({ to: "test@example.test", eventTitle: '<script>alert("x")</script>', galleryUrl: "https://manyangles.test/e/demo" });
expect(message.html).not.toContain("<script>");
expect(message.html).toContain("&lt;script&gt;");
expect(message.html).toContain('role="presentation"');
expect(message.html).toContain("Manyangles");
expect(message.text).toContain("https://manyangles.test/e/demo");
expect(message.html).toContain("Arial,Helvetica,sans-serif");
expect(message.html).toContain("cid:manyangles-mark-v1");
expect(message.attachments[0]?.contentId).toBe("manyangles-mark-v1");
expect(Buffer.from(message.attachments[0]!.content, "base64").subarray(1, 4).toString()).toBe("PNG");
expect(emailBrowserPreview(message.html)).toContain("data:image/png;base64,");
});
+40 -15
View File
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto";
import nodemailer from "nodemailer";
import { Resend } from "resend";
import { EMAIL_LOGO_CID, emailLogoAttachment } from "./logo";
export { emailBrowserPreview } from "./logo";
const PRIMARY = "#8b5a4a";
@@ -16,25 +18,33 @@ function referenceHash(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function chrome(bodyHtml: string) {
export function chrome(bodyHtml: string) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@500;600;700&amp;family=Geologica:wght@400;600;700&amp;display=swap" rel="stylesheet">
<style>h1,h2 {font-family:'Funnel Display',Arial,Helvetica,sans-serif;line-height:1.15;letter-spacing:-.035em} @media(max-width:480px){body{padding:12px 6px!important}}</style>
</head>
<body style="margin:0;padding:24px 12px;background:#f6f1ea">
<div style="max-width:560px;margin:0 auto;color:#2c2416;background:#fff;font-family:Georgia,serif">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;color:#292524;background:#fff;font-family:'Geologica',Arial,Helvetica,sans-serif"><tr><td>
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
<strong style="font-size:22px;letter-spacing:.04em">Manyangles</strong>
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
<td style="vertical-align:middle;padding-right:8px"><img src="cid:${EMAIL_LOGO_CID}" width="32" height="32" alt="" style="display:block;border:0;width:32px;height:32px"></td>
<td style="vertical-align:middle"><strong style="font-size:26px;line-height:32px;letter-spacing:-.04em;color:${PRIMARY}">Manyangles</strong></td>
</tr></table>
<p style="margin:8px 0 0;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#78716c">Every angle. One shared album.</p>
</div>
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0">
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0;font-size:16px;line-height:1.65">
${bodyHtml}
</div>
<div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0">
<strong style="font-size:18px;color:${PRIMARY}">Manyangles</strong>
<p style="margin:8px 0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#7a6e5e">Keep the moment.</p>
<p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p>
</div>
</div>
</td></tr></table>
</body>
</html>`;
}
@@ -63,15 +73,21 @@ export function getEmailReadiness() {
};
}
async function sendEmail(input: {
export async function sendEmail(input: {
to: string;
subject: string;
html: string;
text: string;
referenceId: string;
}) {
const provider = process.env.EMAIL_PROVIDER ?? "resend";
const from =
from?: string;
attachments?: { filename: string; content: string; contentId: string }[];
}, options?: { provider?: string; idempotencyKey?: string }) {
const attachments = input.attachments ?? (input.html.includes(`cid:${EMAIL_LOGO_CID}`) ? [emailLogoAttachment] : []);
const provider = options?.provider ?? process.env.EMAIL_PROVIDER ?? "resend";
if (options?.provider && provider !== (process.env.EMAIL_PROVIDER ?? "resend")) throw new Error("Queued email provider differs from configured provider");
if (!["mailpit", "smtp", "resend"].includes(provider)) throw new Error("Unsupported email provider");
if (provider === "mailpit" && process.env.NODE_ENV === "production") throw new Error("Mailpit is development-only");
const from = input.from ??
process.env.EMAIL_FROM ??
process.env.RESEND_FROM ??
"Manyangles <photos@manyangles.test>";
@@ -87,6 +103,8 @@ async function sendEmail(input: {
host: process.env.SMTP_HOST ?? "127.0.0.1",
port: Number(process.env.SMTP_PORT ?? 1025),
secure: process.env.SMTP_SECURE === "true",
connectionTimeout: 15_000,
socketTimeout: 30_000,
});
const result = await transporter.sendMail({
from,
@@ -95,14 +113,14 @@ async function sendEmail(input: {
html: input.html,
text: input.text,
headers: { "X-Entity-Ref-ID": input.referenceId },
attachments: attachments.map((attachment) => ({ filename: attachment.filename, content: Buffer.from(attachment.content, "base64"), cid: attachment.contentId, contentType: "image/png", contentDisposition: "inline" as const })),
});
return { id: result.messageId };
}
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
console.info(`[email:demo] ${input.subject} -> ${input.to}`);
return { id: `demo-${input.referenceId}` };
throw new Error("RESEND_API_KEY is required; use Mailpit for local email");
}
const resend = new Resend(apiKey);
@@ -113,7 +131,8 @@ async function sendEmail(input: {
html: input.html,
text: input.text,
headers: { "X-Entity-Ref-ID": input.referenceId },
});
attachments,
}, { idempotencyKey: options?.idempotencyKey ?? `${input.referenceId}-${referenceHash(input.to.toLowerCase())}` });
if (error) throw new Error(error.message);
return { id: data?.id ?? input.referenceId };
}
@@ -192,12 +211,14 @@ export async function sendStaffInviteEmail(message: {
});
}
export async function sendAlbumReadyEmail(message: {
export function renderAlbumReadyEmail(message: {
to: string;
eventTitle: string;
galleryUrl: string;
}) {
return sendEmail({
return {
from: process.env.EMAIL_FROM ?? process.env.RESEND_FROM ?? "Manyangles <photos@manyangles.test>",
attachments: [emailLogoAttachment],
to: message.to,
subject: `The gallery for ${message.eventTitle} is ready`,
html: chrome(`
@@ -212,5 +233,9 @@ export async function sendAlbumReadyEmail(message: {
`),
text: `The gallery for ${message.eventTitle} is ready: ${message.galleryUrl}`,
referenceId: `album-ready-${referenceHash(message.galleryUrl)}`,
});
};
}
export async function sendAlbumReadyEmail(message: { to: string; eventTitle: string; galleryUrl: string }) {
return sendEmail(renderAlbumReadyEmail(message));
}
+7
View File
@@ -0,0 +1,7 @@
// 3x PNG rendering of BrandMark. Keep v1 immutable for queued email retries.
export const EMAIL_LOGO_CID = "manyangles-mark-v1";
export const EMAIL_LOGO_BASE64 = "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";
export const emailLogoAttachment = { filename: "manyangles-logo.png", content: EMAIL_LOGO_BASE64, contentId: EMAIL_LOGO_CID };
export function emailBrowserPreview(html: string) {
return html.replaceAll(`cid:${EMAIL_LOGO_CID}`, `data:image/png;base64,${EMAIL_LOGO_BASE64}`);
}
+44
View File
@@ -0,0 +1,44 @@
import { and, eq, sql } from "drizzle-orm";
import { auditEvents, emailDeliveries, events, getDb, guests } from "@album/database";
import { sendEmail } from "./index";
export async function processEmailDelivery() {
const db = getDb();
const provider = process.env.EMAIL_PROVIDER ?? "resend";
await db.execute(sql`UPDATE email_deliveries SET status = CASE WHEN provider = 'resend' AND first_attempt_at > now() - interval '23 hours' AND attempts < 5 THEN 'pending' ELSE 'review' END,
last_error = 'Worker interrupted; delivery needs reconciliation', updated_at = now()
WHERE provider = ${provider} AND status = 'sending' AND updated_at < now() - interval '5 minutes'`);
const rows = await db.execute<{ id: string; event_id: string }>(sql`UPDATE email_deliveries SET status = 'sending', attempts = attempts + 1,
first_attempt_at = coalesce(first_attempt_at, now()), updated_at = now()
WHERE id = (SELECT id FROM email_deliveries WHERE provider = ${provider} AND status = 'pending' AND next_attempt_at <= now() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, event_id`);
const job = rows[0];
if (!job) return false;
const scope = and(eq(emailDeliveries.id, job.id), eq(emailDeliveries.eventId, job.event_id));
const [delivery] = await db.select().from(emailDeliveries).where(scope);
if (!delivery) return true;
try {
if (delivery.attempts > 1 && (!delivery.firstAttemptAt || Date.now() - delivery.firstAttemptAt.getTime() >= 23 * 3600000)) {
await db.update(emailDeliveries).set({ status: "review", lastError: "Idempotency window expired", updatedAt: new Date() }).where(scope);
return true;
}
const [event] = await db.select().from(events).where(eq(events.id, job.event_id));
const now = new Date();
const visible = event && (event.status !== "draft" || (event.publishAt && event.publishAt <= now)) && (!event.publishAt || event.publishAt <= now) && event.galleryPolicy !== "never" && (!event.galleryVisibleAt || event.galleryVisibleAt <= now) && (event.galleryPolicy === "automatic" || event.galleryReleasedAt || event.galleryVisibleAt) && delivery.payload.text.endsWith(`/e/${event.slug}`);
const optedIn = await db.select({ id: guests.id }).from(guests).where(and(eq(guests.eventId, job.event_id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${delivery.recipient}`)).limit(1);
if (!visible || !optedIn.length) {
await db.update(emailDeliveries).set({ status: "review", lastError: "Gallery visibility or recipient consent changed", updatedAt: now }).where(scope);
return true;
}
const result = await sendEmail(delivery.payload, { provider: delivery.provider, idempotencyKey: `gallery-ready/${delivery.id}` });
await db.transaction(async (tx) => {
await tx.update(emailDeliveries).set({ status: "sent", providerId: result.id, lastError: null, updatedAt: new Date() }).where(scope);
await tx.update(guests).set({ notifiedAt: new Date(), updatedAt: new Date() }).where(and(eq(guests.eventId, job.event_id), eq(guests.notifyWhenReady, true), sql`lower(${guests.email}) = ${delivery.recipient}`));
await tx.insert(auditEvents).values({ eventId: job.event_id, action: "guest.email.sent", subjectType: "email", subjectId: delivery.id, metadata: { providerId: result.id } });
});
} catch {
const retry = delivery.provider === "resend" && delivery.attempts < 5 && delivery.firstAttemptAt && Date.now() - delivery.firstAttemptAt.getTime() < 23 * 3600000;
await db.update(emailDeliveries).set({ status: retry ? "pending" : "review", nextAttemptAt: new Date(Date.now() + Math.min(3600, 30 * 2 ** delivery.attempts) * 1000), lastError: "Delivery could not be confirmed; no recipient data logged", updatedAt: new Date() }).where(scope);
}
return true;
}
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test";
import { createHmac } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { emailDeliveries, emailWebhookEvents, events, getDb, groups } from "@album/database";
import { emailDeliveryOutcome, recordEmailWebhook, verifyEmailWebhook } from "./webhooks";
const key = Buffer.from("local-test-signing-secret-not-production");
const secret = `whsec_${key.toString("base64")}`;
function signed(type = "email.delivered", timestamp = Math.floor(Date.now() / 1000)) {
const id = `test_${crypto.randomUUID()}`;
const payload = JSON.stringify({ type, created_at: new Date().toISOString(), data: { email_id: crypto.randomUUID() } });
const signature = createHmac("sha256", key).update(`${id}.${timestamp}.${payload}`).digest("base64");
return { payload, headers: new Headers({ "svix-id": id, "svix-timestamp": `${timestamp}`, "svix-signature": `v1,${signature}` }) };
}
test("webhooks require an authentic, fresh, unmodified signature", () => {
const input = signed();
expect(verifyEmailWebhook(input.payload, input.headers, secret)?.outcome).toBe("delivered");
expect(() => verifyEmailWebhook(input.payload + " ", input.headers, secret)).toThrow();
expect(() => verifyEmailWebhook(input.payload, new Headers(), secret)).toThrow();
const stale = signed("email.delivered", 1);
expect(() => verifyEmailWebhook(stale.payload, stale.headers, secret)).toThrow();
const ignored = signed("email.opened");
expect(verifyEmailWebhook(ignored.payload, ignored.headers, secret)).toBeNull();
});
test.skipIf(process.env.WEBHOOK_INTEGRATION !== "1")("early, duplicate and reordered callbacks preserve terminal outcome and event scope", async () => {
if (!["localhost", "127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const db = getDb();
const [group] = await db.select({ id: groups.id }).from(groups).limit(1);
const input = signed("email.bounced");
const callback = verifyEmailWebhook(input.payload, input.headers, secret)!;
const ids = [callback.id, `${callback.id}-delivered`];
const [event] = await db.insert(events).values({ groupId: group!.id, title: "Webhook test", slug: `webhook-${crypto.randomUUID()}` }).returning();
try {
await Promise.all([recordEmailWebhook(callback), recordEmailWebhook(callback)]);
expect(await db.select().from(emailWebhookEvents).where(eq(emailWebhookEvents.id, callback.id))).toHaveLength(1);
const [delivery] = await db.insert(emailDeliveries).values({ eventId: event!.id, recipient: "webhook@manyangles.test", provider: "resend", payload: { to: "webhook@manyangles.test", from: "test@manyangles.test", subject: "Test", html: "", text: "", referenceId: "test" } }).returning();
const scope = and(eq(emailDeliveries.eventId, event!.id), eq(emailDeliveries.id, delivery!.id));
expect((await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(scope))[0]?.outcome).toBeNull();
await db.update(emailDeliveries).set({ providerId: callback.providerId }).where(scope);
await recordEmailWebhook({ ...callback, id: ids[1]!, outcome: "delivered", occurredAt: new Date(Date.now() + 1000) });
expect((await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(scope))[0]?.outcome).toBe("bounced");
expect(await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(eq(emailDeliveries.eventId, crypto.randomUUID()))).toHaveLength(0);
await db.update(emailDeliveries).set({ provider: "mailpit" }).where(scope);
expect((await db.select({ outcome: emailDeliveryOutcome }).from(emailDeliveries).where(scope))[0]?.outcome).toBeNull();
} finally {
await db.delete(events).where(eq(events.id, event!.id));
await db.delete(emailWebhookEvents).where(inArray(emailWebhookEvents.id, ids));
}
});
+31
View File
@@ -0,0 +1,31 @@
import { Resend } from "resend";
import { sql } from "drizzle-orm";
import { emailWebhookEvents, getDb } from "@album/database";
const outcomes = new Set(["sent", "delivery_delayed", "delivered", "failed", "bounced", "complained"]);
export function verifyEmailWebhook(payload: string, headers: Headers, secret: string) {
const event = new Resend("webhook-verification-only").webhooks.verify({
payload,
headers: { id: headers.get("svix-id") ?? "", timestamp: headers.get("svix-timestamp") ?? "", signature: headers.get("svix-signature") ?? "" },
webhookSecret: secret,
});
const outcome = event.type.replace(/^email\./, "");
if (!event.type.startsWith("email.") || !outcomes.has(outcome)) return null;
const occurredAt = new Date(event.created_at);
if (!("email_id" in event.data) || typeof event.data.email_id !== "string" || !event.data.email_id || !Number.isFinite(occurredAt.getTime())) throw new Error("Invalid event");
return { id: headers.get("svix-id")!, providerId: event.data.email_id, outcome, occurredAt };
}
export async function recordEmailWebhook(event: NonNullable<ReturnType<typeof verifyEmailWebhook>>) {
await getDb().insert(emailWebhookEvents).values(event).onConflictDoNothing();
}
// Read through the inbox so early and duplicate callbacks need no reconciliation
// job. Negative terminal outcomes win even if delivered/sent arrives afterward.
// Only evaluate this expression inside an authorized, event-scoped delivery query.
export const emailDeliveryOutcome = sql<string | null>`(SELECT outcome FROM email_webhook_events
WHERE provider_id = "email_deliveries"."provider_id" AND "email_deliveries"."provider" = 'resend'
ORDER BY CASE outcome WHEN 'complained' THEN 6 WHEN 'bounced' THEN 5 WHEN 'failed' THEN 4
WHEN 'delivered' THEN 3 WHEN 'delivery_delayed' THEN 2 ELSE 1 END DESC,
occurred_at DESC, id DESC LIMIT 1)`;
-13
View File
@@ -1,6 +1,3 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import {
CreateBucketCommand,
DeleteObjectsCommand,
@@ -15,16 +12,6 @@ import {
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const PRESIGN_PUT_SECONDS = 10 * 60;
const PRESIGN_GET_SECONDS = 60 * 60;