Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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
WHEN "renderErrorExternalMessage" IS NOT NULL OR "sendServerErrorExternalMessage" IS NOT NULL OR "bouncedAt" IS NOT NULL THEN 'ERROR'::"EmailOutboxSimpleStatus"
-- SKIPPED is OK regardless of when it happens
WHEN "skippedReason" IS NOT NULL THEN 'OK'::"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 @@ -691,6 +691,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 @@ -730,30 +732,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 @@ -804,8 +809,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