Refine workspaces and event publishing; harden uploads and email delivery
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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("<script>");
|
||||
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
@@ -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&family=Geologica:wght@400;600;700&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));
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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)`;
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user