Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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 \
Expand Down
87 changes: 87 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
@@ -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).*)",
],
};
9 changes: 5 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -125,6 +127,5 @@
},
"ct3aMetadata": {
"initVersion": "7.30.0"
},
"packageManager": "pnpm@8.9.2"
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Campaign" ADD COLUMN "recipientEmails" TEXT[];
59 changes: 46 additions & 13 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -383,6 +415,7 @@ model Campaign {
html String?
content String?
contactBookId String?
recipientEmails String[]
scheduledAt DateTime?
total Int @default(0)
sent Int @default(0)
Expand Down
Binary file modified apps/web/public/hero-dark.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/hero-light.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading