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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This file provides guidance to coding agents when working with code in this repo
These commands are usually already called by the user, but you can remind them to run it for you if they forgot to.
- **Build packages**: `pnpm build:packages`
- **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user)
- **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc.
- **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc. Do NOT call build:packages, dev, codegen, or anything like that yourself, as the dev is already running it.
- **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems)

### Testing
Expand Down Expand Up @@ -83,6 +83,8 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error) (or similar). In most cases you don't actually need to be asynchronous, especially when UI is involved (instead, use a loading indicator! eg. our <Button> component already takes an async callback for onClick and sets its loading state accordingly — if whatever component doesn't do that, update the component instead). If you really do need things to be asynchronous, use `runAsynchronously` or `runAsynchronouslyWithAlert` instead as it deals with error logging.
- WHENEVER you create hover transitions, avoid hover-enter transitions, and just use hover-exit transitions. For example, `transition-colors hover:transition-none`.
- Any environment variables you create should be prefixed with `STACK_` (or NEXT_PUBLIC_STACK_ if they are public). This ensures that their changes are picked up by Turborepo (and helps readability).
- Code defensively. Prefer `?? throwErr(...)` over non-null assertions, with good error messages explicitly stating the assumption that must've been violated for the error to be thrown.
- Try to avoid the `any` type. Whenever you need to use `any`, leave a comment explaining why you're using it (optimally it explains why the type system fails here, and how you can be certain that any errors in that code path would still be flagged at compile-, test-, or runtime).

### Code-related
- Use ES6 maps instead of records wherever you can.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- AlterEnum: Add new skipped reasons
ALTER TYPE "EmailOutboxSkippedReason" ADD VALUE 'LIKELY_NOT_DELIVERABLE';

-- AlterTable: Add skippedDetails column
ALTER TABLE "EmailOutbox" ADD COLUMN "skippedDetails" JSONB;

-- Backfill: Set skippedDetails to empty object for existing skipped emails
UPDATE "EmailOutbox" SET "skippedDetails" = '{}'::jsonb WHERE "skippedReason" IS NOT NULL AND "skippedDetails" IS NULL;
Comment thread
N2D4 marked this conversation as resolved.

-- DropConstraint: Remove old send_payload_when_not_finished_check
ALTER TABLE "EmailOutbox" DROP CONSTRAINT "EmailOutbox_send_payload_when_not_finished_check";

-- AddConstraint: Re-create with skippedDetails included
ALTER TABLE "EmailOutbox" ADD CONSTRAINT "EmailOutbox_send_payload_when_not_finished_check"
CHECK (
"finishedSendingAt" IS NOT NULL OR (
"sendServerErrorExternalMessage" IS NULL
AND "sendServerErrorExternalDetails" IS NULL
AND "sendServerErrorInternalMessage" IS NULL
AND "sendServerErrorInternalDetails" IS NULL
AND "skippedReason" IS NULL
AND "skippedDetails" IS NULL
AND "canHaveDeliveryInfo" IS NULL
AND "deliveredAt" IS NULL
AND "deliveryDelayedAt" IS NULL
AND "bouncedAt" IS NULL
AND "openedAt" IS NULL
AND "clickedAt" IS NULL
AND "unsubscribedAt" IS NULL
AND "markedAsSpamAt" IS NULL
)
);

-- AddConstraint: Ensure skippedDetails is set iff skippedReason is set
ALTER TABLE "EmailOutbox" ADD CONSTRAINT "EmailOutbox_skipped_details_consistency_check"
CHECK (
("skippedReason" IS NULL AND "skippedDetails" IS NULL)
OR ("skippedReason" IS NOT NULL AND "skippedDetails" IS NOT NULL)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterEnum: Add MANUALLY_CANCELLED skipped reason
ALTER TYPE "EmailOutboxSkippedReason" ADD VALUE 'MANUALLY_CANCELLED';

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- This migration allows SKIPPED status to occur at any point in the email lifecycle,
-- not just after sending has finished. This is similar to PAUSED.

-- Step 1: Drop the old generated column "status"
ALTER TABLE "EmailOutbox" DROP COLUMN "status";

-- Step 2: Recreate "status" with SKIPPED check moved earlier (right after PAUSED)
ALTER TABLE "EmailOutbox" ADD COLUMN "status" "EmailOutboxStatus" NOT NULL GENERATED ALWAYS AS (
CASE
-- paused (can happen at any time)
WHEN "isPaused" THEN 'PAUSED'::"EmailOutboxStatus"

-- skipped (can now happen at any time, like paused)
WHEN "skippedReason" IS NOT NULL THEN 'SKIPPED'::"EmailOutboxStatus"

-- starting, not rendering yet
WHEN "startedRenderingAt" IS NULL THEN 'PREPARING'::"EmailOutboxStatus"

-- rendering
WHEN "finishedRenderingAt" IS NULL THEN 'RENDERING'::"EmailOutboxStatus"

-- rendering error
WHEN "renderErrorExternalMessage" IS NOT NULL THEN 'RENDER_ERROR'::"EmailOutboxStatus"

-- queued or scheduled
WHEN "startedSendingAt" IS NULL AND "isQueued" IS FALSE THEN 'SCHEDULED'::"EmailOutboxStatus"
WHEN "startedSendingAt" IS NULL THEN 'QUEUED'::"EmailOutboxStatus"

-- sending
WHEN "finishedSendingAt" IS NULL THEN 'SENDING'::"EmailOutboxStatus"
WHEN "canHaveDeliveryInfo" IS TRUE AND "deliveredAt" IS NULL THEN 'SENDING'::"EmailOutboxStatus"

-- failed to send
WHEN "sendServerErrorExternalMessage" IS NOT NULL THEN 'SERVER_ERROR'::"EmailOutboxStatus"

-- delivered successfully
WHEN "canHaveDeliveryInfo" IS FALSE THEN 'SENT'::"EmailOutboxStatus"
WHEN "markedAsSpamAt" IS NOT NULL THEN 'MARKED_AS_SPAM'::"EmailOutboxStatus"
WHEN "clickedAt" IS NOT NULL THEN 'CLICKED'::"EmailOutboxStatus"
WHEN "openedAt" IS NOT NULL THEN 'OPENED'::"EmailOutboxStatus"
WHEN "bouncedAt" IS NOT NULL THEN 'BOUNCED'::"EmailOutboxStatus"
WHEN "deliveryDelayedAt" IS NOT NULL THEN 'DELIVERY_DELAYED'::"EmailOutboxStatus"
ELSE 'SENT'::"EmailOutboxStatus"
END
) STORED;
Comment thread
N2D4 marked this conversation as resolved.

-- Step 3: Drop the old generated column "simpleStatus"
ALTER TABLE "EmailOutbox" DROP COLUMN "simpleStatus";

-- Step 4: Recreate "simpleStatus" accounting for SKIPPED at any time
ALTER TABLE "EmailOutbox" ADD COLUMN "simpleStatus" "EmailOutboxSimpleStatus" NOT NULL GENERATED ALWAYS AS (
CASE
-- SKIPPED is OK regardless of when it happens
WHEN "skippedReason" IS NOT NULL THEN 'OK'::"EmailOutboxSimpleStatus"
WHEN "renderErrorExternalMessage" IS NOT NULL OR "sendServerErrorExternalMessage" IS NOT NULL OR "bouncedAt" IS NOT NULL THEN 'ERROR'::"EmailOutboxSimpleStatus"
WHEN "finishedSendingAt" IS NOT NULL AND ("canHaveDeliveryInfo" IS FALSE OR "deliveredAt" IS NOT NULL) THEN 'OK'::"EmailOutboxSimpleStatus"
WHEN "finishedSendingAt" IS NULL OR ("canHaveDeliveryInfo" IS TRUE AND "deliveredAt" IS NULL) THEN 'IN_PROGRESS'::"EmailOutboxSimpleStatus"
ELSE 'OK'::"EmailOutboxSimpleStatus"
END
) STORED;
Comment thread
N2D4 marked this conversation as resolved.

-- Step 5: Drop the old constraint that required finishedSendingAt for skipped fields
ALTER TABLE "EmailOutbox" DROP CONSTRAINT "EmailOutbox_send_payload_when_not_finished_check";

-- Step 6: Recreate the constraint WITHOUT skippedReason and skippedDetails
-- (since skipping can now happen before sending finishes)
ALTER TABLE "EmailOutbox" ADD CONSTRAINT "EmailOutbox_send_payload_when_not_finished_check"
CHECK (
"finishedSendingAt" IS NOT NULL OR (
"sendServerErrorExternalMessage" IS NULL
AND "sendServerErrorExternalDetails" IS NULL
AND "sendServerErrorInternalMessage" IS NULL
AND "sendServerErrorInternalDetails" IS NULL
AND "canHaveDeliveryInfo" IS NULL
AND "deliveredAt" IS NULL
AND "deliveryDelayedAt" IS NULL
AND "bouncedAt" IS NULL
AND "openedAt" IS NULL
AND "clickedAt" IS NULL
AND "unsubscribedAt" IS NULL
AND "markedAsSpamAt" IS NULL
)
);

-- Step 7: Recreate the indexes that reference status and simpleStatus
DROP INDEX IF EXISTS "EmailOutbox_status_tenancy_idx";
DROP INDEX IF EXISTS "EmailOutbox_simple_status_tenancy_idx";

CREATE INDEX "EmailOutbox_status_tenancy_idx" ON "EmailOutbox" ("tenancyId", "status");
CREATE INDEX "EmailOutbox_simple_status_tenancy_idx" ON "EmailOutbox" ("tenancyId", "simpleStatus");




44 changes: 25 additions & 19 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,8 @@ enum EmailOutboxSkippedReason {
USER_ACCOUNT_DELETED
USER_HAS_NO_PRIMARY_EMAIL
NO_EMAIL_PROVIDED
LIKELY_NOT_DELIVERABLE
MANUALLY_CANCELLED
}

enum EmailOutboxCreatedWith {
Expand Down Expand Up @@ -731,30 +733,33 @@ model EmailOutbox {

// Computed from EmailOutbox can be the `status` of the email:
//
// - ⚪ `paused` : isPaused
// - ⚪ `preparing` : !isPaused && !startedRenderingAt
// - ⚪ `rendering` : !isPaused && !finishedRenderingAt
// - 🔴 `render-error` : !isPaused && finishedRenderingAt && renderError
// - ⚪ `scheduled` : !isPaused && finishedRenderingAt && !renderError && !isQueued
// - ⚪ `queued` : !isPaused && finishedRenderingAt && !renderError && isQueued && !startedSendingAt
// - ⚪ `sending` : !isPaused && startedSendingAt && !deliveredAt
// - 🔴 `server-error` : !isPaused && finishedSendingAt && sendServerErrorMessage
// - ⚫ `skipped` : !isPaused && finishedSendingAt && !sendServerErrorMessage && skippedReason
// - 🟢 `sent` : !isPaused && finishedSendingAt && !openedAt && !markedAsSpamAt && !sendServerErrorMessage && !skippedReason && (canHaveDeliveryInfo ? deliveredAt : finishedSendingAt)
// - ⚪ `delivery-delayed` : !isPaused && canHaveDeliveryInfo && deliveryDelayedAt
// - 🔴 `bounced` : !isPaused && canHaveDeliveryInfo && bouncedAt
// - 🔵 `opened` : !isPaused && openedAt && !clickedAt && !markedAsSpamAt
// - 🟣 `clicked` : !isPaused && clickedAt && !markedAsSpamAt
// - 🟡 `marked-as-spam` : !isPaused && markedAsSpamAt
// - ⚪ `paused` : isPaused (can happen at any time)
// - ⚫ `skipped` : !isPaused && skippedReason (can happen at any time, like paused)
// - ⚪ `preparing` : !isPaused && !skippedReason && !startedRenderingAt
// - ⚪ `rendering` : !isPaused && !skippedReason && !finishedRenderingAt
// - 🔴 `render-error` : !isPaused && !skippedReason && finishedRenderingAt && renderError
// - ⚪ `scheduled` : !isPaused && !skippedReason && finishedRenderingAt && !renderError && !isQueued
// - ⚪ `queued` : !isPaused && !skippedReason && finishedRenderingAt && !renderError && isQueued && !startedSendingAt
// - ⚪ `sending` : !isPaused && !skippedReason && startedSendingAt && (canHaveDeliveryInfo ? !deliveredAt : !finishedSendingAt)
// - 🔴 `server-error` : !isPaused && !skippedReason && finishedSendingAt && sendServerErrorMessage
// - 🟢 `sent` : !isPaused && !skippedReason && finishedSendingAt && !openedAt && !markedAsSpamAt && !sendServerErrorMessage && (canHaveDeliveryInfo ? deliveredAt : finishedSendingAt)
// - ⚪ `delivery-delayed` : !isPaused && !skippedReason && canHaveDeliveryInfo && deliveryDelayedAt
// - 🔴 `bounced` : !isPaused && !skippedReason && canHaveDeliveryInfo && bouncedAt
// - 🔵 `opened` : !isPaused && !skippedReason && openedAt && !clickedAt && !markedAsSpamAt
// - 🟣 `clicked` : !isPaused && !skippedReason && clickedAt && !markedAsSpamAt
// - 🟡 `marked-as-spam` : !isPaused && !skippedReason && markedAsSpamAt
//
// NOTE: The `sending` status is NOT the same as `finishedSendingAt`! If the outbox can have delivery info, then
// `sending` is about deliveredAt. A better name would be `delivering`, but that name is less catchy than `sending`.
//
// This column is auto-generated as defined in the SQL migration. It can not be set manually. Note: Setting the dbgenerated value is NOT sufficient to create a generated column in Postgres! (Prisma dbgenerated only generates a value for the *default*, and won't reflect any updates.) You must create one manually in the migration file instead, and then update the value here to match.
status EmailOutboxStatus @default(dbgenerated("\nCASE\n WHEN \"isPaused\" THEN 'PAUSED'::\"EmailOutboxStatus\"\n WHEN (\"startedRenderingAt\" IS NULL) THEN 'PREPARING'::\"EmailOutboxStatus\"\n WHEN (\"finishedRenderingAt\" IS NULL) THEN 'RENDERING'::\"EmailOutboxStatus\"\n WHEN (\"renderErrorExternalMessage\" IS NOT NULL) THEN 'RENDER_ERROR'::\"EmailOutboxStatus\"\n WHEN ((\"startedSendingAt\" IS NULL) AND (\"isQueued\" IS FALSE)) THEN 'SCHEDULED'::\"EmailOutboxStatus\"\n WHEN (\"startedSendingAt\" IS NULL) THEN 'QUEUED'::\"EmailOutboxStatus\"\n WHEN (\"finishedSendingAt\" IS NULL) THEN 'SENDING'::\"EmailOutboxStatus\"\n WHEN ((\"canHaveDeliveryInfo\" IS TRUE) AND (\"deliveredAt\" IS NULL)) THEN 'SENDING'::\"EmailOutboxStatus\"\n WHEN (\"sendServerErrorExternalMessage\" IS NOT NULL) THEN 'SERVER_ERROR'::\"EmailOutboxStatus\"\n WHEN (\"skippedReason\" IS NOT NULL) THEN 'SKIPPED'::\"EmailOutboxStatus\"\n WHEN (\"canHaveDeliveryInfo\" IS FALSE) THEN 'SENT'::\"EmailOutboxStatus\"\n WHEN (\"markedAsSpamAt\" IS NOT NULL) THEN 'MARKED_AS_SPAM'::\"EmailOutboxStatus\"\n WHEN (\"clickedAt\" IS NOT NULL) THEN 'CLICKED'::\"EmailOutboxStatus\"\n WHEN (\"openedAt\" IS NOT NULL) THEN 'OPENED'::\"EmailOutboxStatus\"\n WHEN (\"bouncedAt\" IS NOT NULL) THEN 'BOUNCED'::\"EmailOutboxStatus\"\n WHEN (\"deliveryDelayedAt\" IS NOT NULL) THEN 'DELIVERY_DELAYED'::\"EmailOutboxStatus\"\n ELSE 'SENT'::\"EmailOutboxStatus\"\nEND"))
status EmailOutboxStatus @default(dbgenerated("\nCASE\n WHEN \"isPaused\" THEN 'PAUSED'::\"EmailOutboxStatus\"\n WHEN (\"skippedReason\" IS NOT NULL) THEN 'SKIPPED'::\"EmailOutboxStatus\"\n WHEN (\"startedRenderingAt\" IS NULL) THEN 'PREPARING'::\"EmailOutboxStatus\"\n WHEN (\"finishedRenderingAt\" IS NULL) THEN 'RENDERING'::\"EmailOutboxStatus\"\n WHEN (\"renderErrorExternalMessage\" IS NOT NULL) THEN 'RENDER_ERROR'::\"EmailOutboxStatus\"\n WHEN ((\"startedSendingAt\" IS NULL) AND (\"isQueued\" IS FALSE)) THEN 'SCHEDULED'::\"EmailOutboxStatus\"\n WHEN (\"startedSendingAt\" IS NULL) THEN 'QUEUED'::\"EmailOutboxStatus\"\n WHEN (\"finishedSendingAt\" IS NULL) THEN 'SENDING'::\"EmailOutboxStatus\"\n WHEN ((\"canHaveDeliveryInfo\" IS TRUE) AND (\"deliveredAt\" IS NULL)) THEN 'SENDING'::\"EmailOutboxStatus\"\n WHEN (\"sendServerErrorExternalMessage\" IS NOT NULL) THEN 'SERVER_ERROR'::\"EmailOutboxStatus\"\n WHEN (\"canHaveDeliveryInfo\" IS FALSE) THEN 'SENT'::\"EmailOutboxStatus\"\n WHEN (\"markedAsSpamAt\" IS NOT NULL) THEN 'MARKED_AS_SPAM'::\"EmailOutboxStatus\"\n WHEN (\"clickedAt\" IS NOT NULL) THEN 'CLICKED'::\"EmailOutboxStatus\"\n WHEN (\"openedAt\" IS NOT NULL) THEN 'OPENED'::\"EmailOutboxStatus\"\n WHEN (\"bouncedAt\" IS NOT NULL) THEN 'BOUNCED'::\"EmailOutboxStatus\"\n WHEN (\"deliveryDelayedAt\" IS NOT NULL) THEN 'DELIVERY_DELAYED'::\"EmailOutboxStatus\"\n ELSE 'SENT'::\"EmailOutboxStatus\"\nEND"))

// A simplified version of the status property.
// In terms of the color mapping of `status`, white statuses have a `simpleStatus` of `in-progress`, red statuses have a `simpleStatus` of `error`, and everything else has a `simpleStatus` of `ok`.
//
// This column is auto-generated as defined in the SQL migration. It can not be set manually. See the note above on EmailOutboxStatus.status for more details on dbgenerated values.
simpleStatus EmailOutboxSimpleStatus @default(dbgenerated("\nCASE\n WHEN ((\"renderErrorExternalMessage\" IS NOT NULL) OR (\"sendServerErrorExternalMessage\" IS NOT NULL) OR (\"bouncedAt\" IS NOT NULL)) THEN 'ERROR'::\"EmailOutboxSimpleStatus\"\n WHEN ((\"finishedSendingAt\" IS NOT NULL) AND ((\"skippedReason\" IS NOT NULL) OR (\"canHaveDeliveryInfo\" IS FALSE) OR (\"deliveredAt\" IS NOT NULL))) THEN 'OK'::\"EmailOutboxSimpleStatus\"\n WHEN ((\"finishedSendingAt\" IS NULL) OR ((\"canHaveDeliveryInfo\" IS TRUE) AND (\"deliveredAt\" IS NULL))) THEN 'IN_PROGRESS'::\"EmailOutboxSimpleStatus\"\n ELSE 'OK'::\"EmailOutboxSimpleStatus\"\nEND"))
simpleStatus EmailOutboxSimpleStatus @default(dbgenerated("\nCASE\n WHEN ((\"renderErrorExternalMessage\" IS NOT NULL) OR (\"sendServerErrorExternalMessage\" IS NOT NULL) OR (\"bouncedAt\" IS NOT NULL)) THEN 'ERROR'::\"EmailOutboxSimpleStatus\"\n WHEN (\"skippedReason\" IS NOT NULL) THEN 'OK'::\"EmailOutboxSimpleStatus\"\n WHEN ((\"finishedSendingAt\" IS NOT NULL) AND ((\"canHaveDeliveryInfo\" IS FALSE) OR (\"deliveredAt\" IS NOT NULL))) THEN 'OK'::\"EmailOutboxSimpleStatus\"\n WHEN ((\"finishedSendingAt\" IS NULL) OR ((\"canHaveDeliveryInfo\" IS TRUE) AND (\"deliveredAt\" IS NULL))) THEN 'IN_PROGRESS'::\"EmailOutboxSimpleStatus\"\n ELSE 'OK'::\"EmailOutboxSimpleStatus\"\nEND"))

// priority is the sending priority of the email among all emails that are not yet sent but already past their scheduled time. Higher priority means it should be sent sooner.
//
Expand Down Expand Up @@ -805,8 +810,9 @@ model EmailOutbox {
sendServerErrorInternalMessage String?
sendServerErrorInternalDetails Json?

// The reason why the email was skipped. If finishedSendingAt is not set, then this is also not set. Usually one of:
skippedReason EmailOutboxSkippedReason?
// The reason why the email was skipped. Unlike most status-related fields, this can be set at any point in the email lifecycle (like isPaused). skippedDetails must be set if and only if skippedReason is set. (Enforced by EmailOutbox_skipped_details_consistency_check constraint.)
skippedReason EmailOutboxSkippedReason?
skippedDetails Json?

// Whether this email was sent through a server that provides delivery info. This is set if and only if finishedSendingAt is set (it is only determined once the email has been sent). If canHaveDeliveryInfo is false, then [deliveredAt, deliveryDelayedAt, bouncedAt] are all not set. This flag is usually set to true if the email provider is Resend.
canHaveDeliveryInfo Boolean?
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/app/api/latest/emails/outbox/[id]/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { emailOutboxCrudHandlers } from "../crud";

export const GET = emailOutboxCrudHandlers.readHandler;
export const PATCH = emailOutboxCrudHandlers.updateHandler;

Loading
Loading