diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7edc787..282ef51 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -181,6 +181,10 @@ jobs: for APP_NAME in bytesend smtp-proxy; do for TAG in $TAGS; do + # Wait for both platform images to be available before creating manifest + wait_for_remote_image "bytesend/$APP_NAME-amd64:$TAG" || exit 1 + wait_for_remote_image "bytesend/$APP_NAME-arm64:$TAG" || exit 1 + # buildx imagetools create is idempotent and updates the target tag in-place. docker buildx imagetools create \ --tag bytesend/$APP_NAME:$TAG \ @@ -229,6 +233,10 @@ jobs: for APP_NAME in bytesend smtp-proxy; do for TAG in $TAGS; do + # Wait for both platform images to be available before creating manifest + wait_for_remote_image "ghcr.io/bytesend/$APP_NAME-amd64:$TAG" || exit 1 + wait_for_remote_image "ghcr.io/bytesend/$APP_NAME-arm64:$TAG" || exit 1 + # buildx imagetools create is idempotent and updates the target tag in-place. docker buildx imagetools create \ --tag ghcr.io/bytesend/$APP_NAME:$TAG \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e307b7..3d38ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.3.0] - 2026-06-03 + +### Added + +#### Account Security & Management +- **Email address change with verification** — users can now change their account email via a re-verification flow: request new email → verification code sent → confirm with 6-character code (15-min expiry) → email updated and verified +- **Account-level two-factor authentication (2FA)** — TOTP-based 2FA using authenticator apps; includes: + - Setup wizard with QR code and manual secret fallback + - Recovery codes (10 × 10-character hex codes per setup) for account recovery if authenticator is lost + - Timing-safe verification with SHA-256 hashing for recovery codes + - Single-use recovery code tracking (mark used, count remaining) + - Regenerate recovery codes option (requires current TOTP verification) +- **Server-enforced 2FA** — 2FA verification enforced at the middleware level via HMAC-signed httpOnly cookies (12-hour expiry); users with 2FA enabled are redirected to `/auth/2fa-verify` if cookie is missing/invalid +- **OAuth account linking safety** — users with OAuth-linked accounts (GitHub, Google, Discord) cannot change email to prevent account takeover via dangerous email account linking + +#### Broadcasts +- **Direct broadcast recipient support** — campaigns with `intent: "BROADCAST"` can now accept direct recipient email lists via `recipientEmails` field; recipients need not be in a contact book +- **Broadcast batch processing** — `CampaignBatchService.worker` now handles direct broadcasts by: + - Iterating recipient email list + - Checking suppression/bounce status + - Creating Email and CampaignEmail records + - Queuing via EmailQueueService (no contact book required) + - Deduplicating via CampaignEmail lookup +- **Dedicated broadcast compose UI** — new `/broadcasts/[broadcastId]/compose` page with: + - Toggle between contact book and direct recipient modes + - Direct recipient textarea with email parsing and validation + - Simplified UX (no campaign automation, direct send or schedule) + - Auto-save on field/content changes + - Send now vs. schedule workflows + +#### Settings Reorganization +- **Account section** — new `/settings/account` page consolidating: + - Email address change (with OAuth provider notice for linked accounts) + - Two-factor authentication setup/disable + - Recovery codes management (display and regenerate) +- **Team section** — renamed from "General"; `/settings/team` now contains: + - Team image upload/management + - Team name editing + - Danger zone (delete team) + +#### Infrastructure & Build +- **Docker manifest publishing robustness** — `create_and_publish_manifest` job now waits for platform-specific images (amd64, arm64) to be available before creating multi-platform manifests; prevents "image not found" errors on manifest creation +- **Edge Runtime compatible 2FA utilities** — new `edge-2fa-utils.ts` using Web Crypto API (no Node.js crypto) for middleware HMAC validation in Edge Runtime +- **Dynamic rendering for search params** — `/auth/2fa-verify` uses `Suspense` boundary pattern for safe `useSearchParams()` usage in Next.js 15 + +### Changed + +#### User Router +- **Profile query enrichment** — `user.getProfile` now includes linked OAuth accounts (`type`, `provider`) to allow UI checks for password/email change eligibility +- **Email change mutations** — `requestEmailChange` validates OAuth account presence and blocks email changes for OAuth-linked users + +#### Campaign Router +- **Campaign update accepts recipient emails** — `updateCampaign` mutation now accepts optional `recipientEmails: string[]` input for direct broadcast editing + +#### Broadcast UI Navigation +- **Campaign card routing logic** — `CampaignCard` now differentiates broadcast vs. campaign routing: + - Broadcasts: `/broadcasts/[id]/compose` (DRAFT/SCHEDULED) or `/broadcasts/[id]` (SENT/RUNNING) + - Campaigns: `/campaigns/[id]/edit` (DRAFT/SCHEDULED) or `/campaigns/[id]` (SENT/RUNNING) +- **Broadcasts landing page** — `/broadcasts` now uses `CreateBroadcast` dialog (separate from `CreateCampaign`) + +#### Settings Navigation +- **Settings layout tabs** — updated nav buttons: + - Changed "General" → "Team" + - Added "Account" tab + - Root `/settings` redirects to `/settings/team` + +### Fixed + +#### Build & Runtime +- **Next.js 15 prerender error for dynamic routes** — `/auth/2fa-verify` now uses server component + Suspense boundary to avoid prerender failure when using `useSearchParams()` +- **Edge Runtime crypto import errors** — middleware no longer imports Node.js `crypto` module; uses Web Crypto API for HMAC validation instead + +#### Docker CI/CD +- **Manifest creation timing issue** — workflow no longer fails immediately when platform images haven't finished pushing; `wait_for_remote_image()` now called for both amd64 and arm64 images before manifest creation (18 retry attempts, 10-second intervals = 180-second timeout) + +### Database + +- **Migration: Email change re-verification** (`20260603032501_email_change_reverification_and_account_2fa`) + - Added `emailChangeToken`, `emailChangeTokenExpires`, `twoFactorEnabled`, `twoFactorSecret`, `twoFactorTempSecret` to `User` model +- **Migration: 2FA recovery codes** (`20260603072201_2factor_recovery_codes`) + - Added `TwoFactorRecoveryCode` model with userId FK, codeHash, used, usedAt, createdAt + - Added index on `userId, used` for efficient unused code lookup +- **Migration: Direct broadcast recipients** (`20260603072459_add_recipient_emails_to_campaign`) + - Added `recipientEmails String[]` to `Campaign` model for direct broadcast recipient storage + +--- + ## [0.2.6] - 2026-05-10 ### Added diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000..0ce1fe1 --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { validateTwoFactorCookieEdge, TWO_FACTOR_COOKIE } from "~/lib/edge-2fa-utils"; + +export async function middleware(request: NextRequest) { + const pathname = request.nextUrl.pathname; + + // Skip middleware for: + // - Public routes + // - API routes + // - Auth routes + // - Marketing routes + // - Static assets + const publicPaths = [ + "/login", + "/signup", + "/api", + "/auth", + "/", + "/privacy", + "/terms", + "/cookie-policy", + "/dmca", + "/dpa", + "/acceptable-use", + "/legal", + "/changelog", + "/_next", + "/favicon.ico", + ]; + + if (publicPaths.some((path) => pathname.startsWith(path))) { + return NextResponse.next(); + } + + // For dashboard routes, check 2FA + if (pathname.startsWith("/dashboard") || pathname.startsWith("/broadcasts") || pathname.startsWith("/campaigns")) { + const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET }); + + if (!token) { + // Not authenticated, redirect to login + return NextResponse.redirect(new URL("/login", request.url)); + } + + // User is authenticated. Check if they have 2FA enabled. + // We can't query the DB from middleware easily, so we rely on: + // 1. The 2FA cookie being set after verification + // 2. If no cookie, we let the client-side check handle it (DashboardProvider) + // But for maximum security, we could also check via an API call + + // For now, just verify the cookie if it exists + const cookieValue = request.cookies.get(TWO_FACTOR_COOKIE)?.value; + const userId = token.sub ? parseInt(token.sub, 10) : null; + + if (cookieValue && userId) { + const isValid = await validateTwoFactorCookieEdge( + cookieValue, + userId, + process.env.NEXTAUTH_SECRET ?? "dev-secret" + ); + if (!isValid) { + // Invalid/expired cookie, redirect to 2FA verification + const url = new URL("/auth/2fa-verify", request.url); + const res = NextResponse.redirect(url); + res.cookies.delete(TWO_FACTOR_COOKIE); + return res; + } + // Valid cookie, allow access + return NextResponse.next(); + } + + // No cookie — the DashboardProvider will handle the 2FA gate client-side + // But optionally we could require 2FA verification here + // For now, allow the request to proceed and let the dashboard provider gate it + return NextResponse.next(); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + // Match all paths except static files and api/auth/callback + "/((?!api/auth/callback|_next/static|_next/image|favicon.ico).*)", + ], +}; diff --git a/apps/web/package.json b/apps/web/package.json index 16db6f1..9ac2e1f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -70,17 +70,19 @@ "mime-types": "^3.0.1", "nanoid": "^5.1.5", "next": "15.5.9", - "next-auth": "^4.24.11", + "next-auth": "^4.24.14", "nodemailer": "^7.0.3", + "otplib": "^13.4.1", "pino": "^9.7.0", "pino-pretty": "^13.0.0", "pnpm": "^10.9.0", "prisma": "^6.6.0", + "qrcode.react": "^4.2.0", "query-string": "^9.1.1", "react": "19.1.0", "react-dom": "19.1.0", - "react-icons": "5.6.0", "react-hook-form": "^7.56.1", + "react-icons": "5.6.0", "recharts": "^2.15.3", "server-only": "^0.0.1", "shiki": "^3.3.0", @@ -125,6 +127,5 @@ }, "ct3aMetadata": { "initVersion": "7.30.0" - }, - "packageManager": "pnpm@8.9.2" + } } \ No newline at end of file diff --git a/apps/web/prisma/migrations/20260603032501_email_change_reverification_and_account_2fa/migration.sql b/apps/web/prisma/migrations/20260603032501_email_change_reverification_and_account_2fa/migration.sql new file mode 100644 index 0000000..34bdaf7 --- /dev/null +++ b/apps/web/prisma/migrations/20260603032501_email_change_reverification_and_account_2fa/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "twoFactorSecret" TEXT, +ADD COLUMN "twoFactorTempSecret" TEXT; + +-- CreateTable +CREATE TABLE "PendingEmailChange" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "newEmail" TEXT NOT NULL, + "code" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PendingEmailChange_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PendingEmailChange_expiresAt_idx" ON "PendingEmailChange"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingEmailChange_userId_newEmail_key" ON "PendingEmailChange"("userId", "newEmail"); + +-- AddForeignKey +ALTER TABLE "PendingEmailChange" ADD CONSTRAINT "PendingEmailChange_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20260603072201_2factor_recovery_codes/migration.sql b/apps/web/prisma/migrations/20260603072201_2factor_recovery_codes/migration.sql new file mode 100644 index 0000000..8db88ec --- /dev/null +++ b/apps/web/prisma/migrations/20260603072201_2factor_recovery_codes/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "TwoFactorRecoveryCode" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "codeHash" TEXT NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TwoFactorRecoveryCode_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TwoFactorRecoveryCode_userId_used_idx" ON "TwoFactorRecoveryCode"("userId", "used"); + +-- AddForeignKey +ALTER TABLE "TwoFactorRecoveryCode" ADD CONSTRAINT "TwoFactorRecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20260603072459_add_recipient_emails_to_campaign/migration.sql b/apps/web/prisma/migrations/20260603072459_add_recipient_emails_to_campaign/migration.sql new file mode 100644 index 0000000..a38dd66 --- /dev/null +++ b/apps/web/prisma/migrations/20260603072459_add_recipient_emails_to_campaign/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Campaign" ADD COLUMN "recipientEmails" TEXT[]; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index f62dd73..a0f55cf 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -80,19 +80,51 @@ model VerificationToken { } model User { - id Int @id @default(autoincrement()) - name String? - email String? @unique - emailVerified DateTime? - image String? - isBetaUser Boolean @default(false) - isAdmin Boolean @default(false) - isBanned Boolean @default(false) - createdAt DateTime @default(now()) - accounts Account[] - sessions Session[] - teamUsers TeamUser[] - webhookEndpoints Webhook[] + id Int @id @default(autoincrement()) + name String? + email String? @unique + emailVerified DateTime? + image String? + isBetaUser Boolean @default(false) + isAdmin Boolean @default(false) + isBanned Boolean @default(false) + twoFactorEnabled Boolean @default(false) + twoFactorSecret String? + twoFactorTempSecret String? + createdAt DateTime @default(now()) + accounts Account[] + sessions Session[] + teamUsers TeamUser[] + webhookEndpoints Webhook[] + pendingEmailChanges PendingEmailChange[] + twoFactorRecoveryCodes TwoFactorRecoveryCode[] +} + +model PendingEmailChange { + id String @id @default(cuid()) + userId Int + newEmail String + code String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, newEmail]) + @@index([expiresAt]) +} + +model TwoFactorRecoveryCode { + id String @id @default(cuid()) + userId Int + codeHash String + used Boolean @default(false) + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, used]) } enum Plan { @@ -383,6 +415,7 @@ model Campaign { html String? content String? contactBookId String? + recipientEmails String[] scheduledAt DateTime? total Int @default(0) sent Int @default(0) diff --git a/apps/web/public/hero-dark.webp b/apps/web/public/hero-dark.webp index f8ac1b7..b4c9d8f 100644 Binary files a/apps/web/public/hero-dark.webp and b/apps/web/public/hero-dark.webp differ diff --git a/apps/web/public/hero-light.webp b/apps/web/public/hero-light.webp index 4d73709..ee44a30 100644 Binary files a/apps/web/public/hero-light.webp and b/apps/web/public/hero-light.webp differ diff --git a/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/compose/page.tsx b/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/compose/page.tsx new file mode 100644 index 0000000..c2a2b5a --- /dev/null +++ b/apps/web/src/app/(dashboard)/broadcasts/[broadcastId]/compose/page.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { api } from "~/trpc/react"; +import { Spinner } from "@bytesend/ui/src/spinner"; +import { Button } from "@bytesend/ui/src/button"; +import { Input } from "@bytesend/ui/src/input"; +import { Editor } from "@bytesend/email-editor"; +import { use, useMemo, useState } from "react"; +import { Campaign } from "@prisma/client"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@bytesend/ui/src/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@bytesend/ui/src/dialog"; +import { toast } from "@bytesend/ui/src/toaster"; +import { useDebouncedCallback } from "use-debounce"; +import { formatDistanceToNow } from "date-fns"; +import ScheduleCampaign from "../../../campaigns/schedule-campaign"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Send } from "lucide-react"; +import Link from "next/link"; + +const IMAGE_SIZE_LIMIT = 10 * 1024 * 1024; + +function parseDirectRecipients(raw: string): string[] { + return raw + .split(/[\n,]+/) + .map((e) => e.trim()) + .filter((e) => e.length > 0 && e.includes("@")); +} + +export default function BroadcastComposePage({ + params, +}: { + params: Promise<{ broadcastId: string }>; +}) { + const { broadcastId } = use(params); + + const { + data: broadcast, + isLoading, + error, + } = api.campaign.getCampaign.useQuery( + { campaignId: broadcastId }, + { enabled: !!broadcastId }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Failed to load broadcast

+
+ ); + } + + if (!broadcast) { + return
Broadcast not found
; + } + + return ; +} + +function BroadcastComposer({ + broadcast, +}: { + broadcast: Campaign & { contactBook: { id: string; name: string; emoji: string; _count?: { contacts: number } } | null; imageUploadSupported: boolean }; +}) { + const router = useRouter(); + const contactBooksQuery = api.contacts.getContactBooks.useQuery({}); + const utils = api.useUtils(); + + const [json, setJson] = useState | undefined>( + broadcast.content ? JSON.parse(broadcast.content) : undefined, + ); + const [isSaving, setIsSaving] = useState(false); + const [name, setName] = useState(broadcast.name); + const [subject, setSubject] = useState(broadcast.subject); + const [from, setFrom] = useState(broadcast.from); + const [replyTo, setReplyTo] = useState( + broadcast.replyTo[0], + ); + + const [recipientMode, setRecipientMode] = useState<"contactBook" | "direct">( + broadcast.contactBookId ? "contactBook" : "direct", + ); + const [contactBookId, setContactBookId] = useState( + broadcast.contactBookId, + ); + const [directRecipients, setDirectRecipients] = useState( + broadcast.recipientEmails?.join("\n") ?? "", + ); + + const [sendNowOpen, setSendNowOpen] = useState(false); + + const updateCampaignMutation = api.campaign.updateCampaign.useMutation({ + onSuccess: () => { + utils.campaign.getCampaign.invalidate(); + setIsSaving(false); + }, + }); + + const scheduleMutation = api.campaign.scheduleCampaign.useMutation(); + + function updateEditorContent() { + updateCampaignMutation.mutate({ + campaignId: broadcast.id, + content: JSON.stringify(json), + }); + } + + const debouncedUpdateCampaign = useDebouncedCallback(updateEditorContent, 1000); + + const handleFileChange = async (file: File) => { + if (file.size > IMAGE_SIZE_LIMIT) { + throw new Error( + `File should be less than ${IMAGE_SIZE_LIMIT / 1024 / 1024}MB`, + ); + } + + const fd = new FormData(); + fd.append("file", file); + fd.append("teamId", String(broadcast.teamId)); + fd.append("type", "asset"); + + const response = await fetch("/api/upload", { method: "POST", body: fd }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error((err as any)?.error ?? "Failed to upload file"); + } + const { publicUrl } = (await response.json()) as { publicUrl: string }; + return publicUrl; + }; + + const contactBook = contactBooksQuery.data?.find( + (book) => book.id === contactBookId, + ); + + const editorVariables = useMemo(() => { + if (recipientMode === "direct") return []; + const baseVariables = ["email", "firstName", "lastName"]; + const registryVariables = contactBook?.variables ?? []; + return Array.from(new Set([...baseVariables, ...registryVariables])); + }, [contactBook, recipientMode]); + + const parsedDirectEmails = useMemo( + () => parseDirectRecipients(directRecipients), + [directRecipients], + ); + + const recipientCount = + recipientMode === "direct" + ? parsedDirectEmails.length + : contactBook?._count?.contacts ?? 0; + + async function saveAll() { + if (recipientMode === "direct") { + await new Promise((resolve, reject) => { + updateCampaignMutation.mutate( + { + campaignId: broadcast.id, + recipientEmails: parsedDirectEmails, + subject, + from, + replyTo: replyTo ? [replyTo] : [], + }, + { onSuccess: () => resolve(), onError: reject }, + ); + }); + } else { + await new Promise((resolve, reject) => { + updateCampaignMutation.mutate( + { + campaignId: broadcast.id, + contactBookId: contactBookId ?? undefined, + subject, + from, + replyTo: replyTo ? [replyTo] : [], + }, + { onSuccess: () => resolve(), onError: reject }, + ); + }); + } + } + + async function handleSendNow() { + try { + await saveAll(); + } catch { + toast.error("Failed to save broadcast before sending"); + return; + } + scheduleMutation.mutate( + { campaignId: broadcast.id }, + { + onSuccess: () => { + toast.success("Broadcast sent!"); + setSendNowOpen(false); + router.push(`/broadcasts/${broadcast.id}`); + }, + onError: (e) => toast.error(e.message), + }, + ); + } + + return ( +
+ {/* Sticky top bar */} +
+
+ + + + setName(e.target.value)} + className="border-0 focus:ring-0 focus:outline-none bg-transparent h-auto p-0 font-medium text-sm min-w-0" + onBlur={() => { + if (!name || name === broadcast.name) return; + updateCampaignMutation.mutate( + { campaignId: broadcast.id, name }, + { + onError: (e) => { + toast.error(`${e.message}. Reverting changes.`); + setName(broadcast.name); + }, + }, + ); + }} + /> +
+
+
+ {isSaving ? ( +
+ ) : ( +
+ )} + + {formatDistanceToNow(broadcast.updatedAt) === "less than a minute" + ? "just now" + : `${formatDistanceToNow(broadcast.updatedAt)} ago`} + +
+ + router.push(`/broadcasts/${broadcast.id}`)} + /> +
+
+ + {/* Body: sidebar + editor */} +
+ {/* Left sidebar */} +