Skip to content

Commit 485fa9d

Browse files
nams1570N2D4
andauthored
[Refactor][Feat][Fix] Rework Email Section With New Sent Page, Better Drafts Page, and Settings Page (#1221)
### Context We didn't have an easy place for a user to see their domain statistics and track their sent emails, either overall or by draft. Additionally, there was scope creep with the sidebar, where we were supporting more pages. Our emails landing page was also rather confusing, especially toggling/ working with different email server types. So, we decide to add a "sent" page, to track email logs and email statistics, as well as let users temporarily override their sending limits if need be. Additionally, a user may want to see a particular email in more detail: what stage is it in? How did it proceed through time? How can I pause the sending of this email or change the scheduled time or edit the code? We allow for that to happen. ### Summary of Changes #### New Pages 1. **Sent Page:** A Domain Reputation card lets you track how many of your sent emails were bounced or marked as spam as well as how much capacity you have left. We also provide a temporary override, where you can use up to 4 times your capacity for a limited period of time. Additionally, we provide an email log that lets you see the recently sent emails. You can also toggle this view from a "list all emails" to "group by template/draft" which shows stats for each template/draft id (i.e a bar showing how many emails were sent, are pending, were marked as spam, were bounced etc, and the total number of emails sent with that template or draft). Clicking on an email in the list all view takes you to the "email-viewer" endpoint for that email (see below). Clicking on a template/draft in the group by view takes you to a page where you can see the statistics for that template/draft in more detail (the "send" stage view for that template/draft, as referenced below). 2. **Settings Page:** This is a new page we created because the old "emails" landing page wasn't doing its job. This page is to track all the email settings. Currently, we put in 2 sections. A "theme settings" card where users can see their active theme and click on a button to be navigated to the themes page. This is necessary as we remove themes from the sidebar. The other section is a card for email server and domain configuration - you can change your server type and adjust the settings or send a test email. It's cleaner and less noisy. 3. **Drafts Page**: There are a lot of changes here. On the landing page, we actually separate out the drafts into "active drafts" and "draft history" because drafts are meant to be fire-and-forget, not reusable. We also add the functionality to create a draft from a template. This was tricky to manage because templates rely on template variables which sent to the backend along with the code and injected during render time. We deal with this by having AI rewrite the template source code to remove any references to template variables and to make the draft standalone. The drafts page has been separated into a stepper-controlled multi stage process: draft->recipients->schedule->sent. Sent is a read only view that shows you the statistics of the emails sent using that draft, as mentioned earlier. You can also see the sent view of a historical draft. You can also bulk pause/cancel any unsent emails from the sent view of the drafts. 4. **Sidebar Updates**: The email sidebar now doesn't show "themes" or "emails" (the old landing page), but it does show "settings" and "sent", and the default landing page for emails is "sent". 5. **Email Viewer**: When you click on an individual email, you get navigated here. This has a timeline showing the progress of the email on the right, and some optional info for the user that's toggleable on the right bottom, while having either a preview of the email if it's sent or a way to edit it. You can also change the scheduledAt date of an email if it hasn't already been sent. #### Bug Fixes 1. **Search in `TeamMemberSearchTable`**: This was broken. Every time you tried to enter or remove a character, it would trigger skeleton loading that overlapped the search bar too, preventing you from adding/removing more. This was caused because the `useUser` hook eventually ended up calling a `use` hook, which throws a promise that triggers a suspense. This, coupled with the fact that the implementation of `TeamMemberSearchTable` involved a prop-drilling/ dependency inversion approach to passing down its toolbar to a base table component, meant the suspense would cover the toolbar too and couldn't be scoped to just the table. A refactor has gotten rid of the need for those base components while fixing tables in `payments/customers`, `teams/team_id`, and `payments/transactions` on top of the existing use in email drafts recipients stage. We also dedupped some code. 2. **Stale draft fetches on draft landing page**: `useEmailDrafts` uses an asyncCache to cache the fetched drafts. It is used on the drafts landing page to render the drafts. When a draft is sent, its `sentAt` is marked versus when it is still active, it is marked as null. The cache was stale and so navigating to the landing page after firing off a draft would errorneously represent that draft as still active and indeed, even allow you to edit it and fire it again. This violated the principle of drafts being fire and forget. This has been dealt with by adding functionality to refresh the draft cache upon firing off a draft. #### Other Changes 1. We bumped up the base time for the exponential send attempt retry backoff in `email-queue-step` to 20 seconds. The previous base was two seconds, and this effectively just made it wait until the next iteration of the `email-queue-step` cron job or at most an iteration that wasn't too far away. When an outage with our provider happens, it may take a while for it to be resolved, so a longer backoff is justified 2. We transitioned the themes page and the templates page to using the new components, though deeper UI refactors for them were out of scope for this ticket. 3. We implement a "temporarily increase capacity" button, that bumps up the throughput/ capacity limit fourfold for a user for a given period of time. It works like this: > Clicking the button sets a boost expiredat time. > When this time is set and still valid, the capacity rate is multiplied by 4. > When the button is clicked, trigger a loading spinner until the route finishes processing. > When the timer runs out, we reset the button back to its original state. > We dont need to wrap the onclick with runAsyncWithAlert because the component does that already. 4. We add a new default theme: a colorful theme with a lavender base. This was mainly done so we could have three times in a theme showcase in the settings page. ### UI Demos **Sent Page Demo:** https://github.com/user-attachments/assets/19294a90-bb65-4f00-9a97-111f6c08287f **Drafts Page Demo** https://github.com/user-attachments/assets/847609ef-d699-470c-a699-297bb9e17f04 **Settings Page Demo** https://github.com/user-attachments/assets/190a3829-036a-4f57-89c0-a873bef5a7ce **Email Viewer Page Demo** https://github.com/user-attachments/assets/3bc50159-4acb-4865-a4dd-830c84ee4235 --------- Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
1 parent 66adb4e commit 485fa9d

64 files changed

Lines changed: 5610 additions & 798 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "Tenancy" ADD COLUMN "emailCapacityBoostExpiresAt" TIMESTAMP(3);
3+

apps/backend/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ model Tenancy {
7575
sessionReplayChunks SessionReplayChunk[]
7676
managedEmailDomains ManagedEmailDomain[]
7777
78+
// Email capacity boost - when set and in the future, email capacity is multiplied by 4
79+
emailCapacityBoostExpiresAt DateTime?
80+
7881
@@unique([projectId, branchId, organizationId])
7982
@@unique([projectId, branchId, hasNoOrganization])
8083
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { globalPrismaClient } from "@/prisma-client";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
4+
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
6+
const BOOST_DURATION_HOURS = 4;
7+
8+
export const POST = createSmartRouteHandler({
9+
metadata: {
10+
summary: "Activate email capacity boost",
11+
description: "Temporarily increases email capacity by 4x for 4 hours.",
12+
tags: ["Emails"],
13+
},
14+
request: yupObject({
15+
auth: yupObject({
16+
type: serverOrHigherAuthTypeSchema,
17+
tenancy: adaptSchema.defined(),
18+
}).defined(),
19+
method: yupString().oneOf(["POST"]).defined(),
20+
}),
21+
response: yupObject({
22+
statusCode: yupNumber().oneOf([200]).defined(),
23+
bodyType: yupString().oneOf(["json"]).defined(),
24+
body: yupObject({
25+
expires_at: yupString().defined(),
26+
}).defined(),
27+
}),
28+
handler: async ({ auth }) => {
29+
const tenancy = await globalPrismaClient.tenancy.findUniqueOrThrow({
30+
where: { id: auth.tenancy.id },
31+
select: { emailCapacityBoostExpiresAt: true },
32+
});
33+
34+
if (tenancy.emailCapacityBoostExpiresAt && tenancy.emailCapacityBoostExpiresAt > new Date()) {
35+
throw new KnownErrors.EmailCapacityBoostAlreadyActive(tenancy.emailCapacityBoostExpiresAt.toISOString());
36+
}
37+
38+
const expiresAt = new Date(Date.now() + BOOST_DURATION_HOURS * 60 * 60 * 1000);
39+
40+
await globalPrismaClient.tenancy.update({
41+
where: { id: auth.tenancy.id },
42+
data: { emailCapacityBoostExpiresAt: expiresAt },
43+
});
44+
45+
return {
46+
statusCode: 200,
47+
bodyType: "json",
48+
body: {
49+
expires_at: expiresAt.toISOString(),
50+
},
51+
};
52+
},
53+
});

apps/backend/src/app/api/latest/emails/delivery-info/route.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { calculateCapacityRate, getEmailDeliveryStatsForTenancy } from "@/lib/email-delivery-stats";
1+
import { calculateCapacityRate, getEmailCapacityBoostExpiresAt, getEmailDeliveryStatsForTenancy } from "@/lib/email-delivery-stats";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3-
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { adaptSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
44

55
const windows = [
66
{ key: "hour" as const },
@@ -34,13 +34,19 @@ export const GET = createSmartRouteHandler({
3434
}).defined(),
3535
capacity: yupObject({
3636
rate_per_second: yupNumber().defined(),
37+
boost_multiplier: yupNumber().defined(),
3738
penalty_factor: yupNumber().defined(),
39+
is_boost_active: yupBoolean().defined(),
40+
boost_expires_at: yupString().nullable().defined(),
3841
}).defined(),
3942
}).defined(),
4043
}),
4144
handler: async ({ auth }) => {
42-
const stats = await getEmailDeliveryStatsForTenancy(auth.tenancy.id);
43-
const capacity = calculateCapacityRate(stats);
45+
const [stats, boostExpiresAt] = await Promise.all([
46+
getEmailDeliveryStatsForTenancy(auth.tenancy.id),
47+
getEmailCapacityBoostExpiresAt(auth.tenancy.id),
48+
]);
49+
const capacity = calculateCapacityRate(stats, boostExpiresAt);
4450

4551
return {
4652
statusCode: 200,
@@ -57,7 +63,10 @@ export const GET = createSmartRouteHandler({
5763
}, {} as Record<typeof windows[number]["key"], { sent: number, bounced: number, marked_as_spam: number }>),
5864
capacity: {
5965
rate_per_second: capacity.ratePerSecond,
66+
boost_multiplier: capacity.boostMultiplier,
6067
penalty_factor: capacity.penaltyFactor,
68+
is_boost_active: capacity.isBoostActive,
69+
boost_expires_at: boostExpiresAt?.toISOString() ?? null,
6170
},
6271
},
6372
};

apps/backend/src/app/api/latest/emails/outbox/crud.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ function prismaModelToCrud(prismaModel: EmailOutbox): EmailOutboxCrud["Server"][
8888
variables: (prismaModel.extraRenderVariables ?? {}) as Record<string, any>,
8989
skip_deliverability_check: prismaModel.shouldSkipDeliverabilityCheck,
9090
scheduled_at_millis: prismaModel.scheduledAt.getTime(),
91+
// Source tracking for grouping emails by template/draft
92+
created_with: (prismaModel.createdWith === "DRAFT" ? "draft" : "programmatic-call") as "draft" | "programmatic-call",
93+
email_draft_id: prismaModel.emailDraftId,
94+
email_programmatic_call_template_id: prismaModel.emailProgrammaticCallTemplateId,
9195
send_retries: prismaModel.sendRetries,
9296
next_send_retry_at_millis: prismaModel.nextSendRetryAt?.getTime() ?? null,
9397
send_attempt_errors: sendAttemptErrors,

apps/backend/src/app/api/latest/emails/send-email/route.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ import { adaptSchema, jsonSchema, serverOrHigherAuthTypeSchema, templateThemeIdS
99
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
1010
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1111

12-
type UserResult = {
13-
user_id: string,
14-
user_email?: string,
15-
};
16-
1712
const bodyBase = yupObject({
1813
user_ids: yupArray(yupString().defined()).optional(),
1914
all_users: yupBoolean().oneOf([true]).optional(),
@@ -25,6 +20,9 @@ const bodyBase = yupObject({
2520
is_high_priority: yupBoolean().optional().meta({
2621
openapiField: { description: "Marks the email as high priority so it jumps the queue." }
2722
}),
23+
scheduled_at_millis: yupNumber().optional().meta({
24+
openapiField: { description: "When to send the email. If not specified, the email will be sent immediately." }
25+
}),
2826
});
2927

3028
export const POST = createSmartRouteHandler({
@@ -83,7 +81,7 @@ export const POST = createSmartRouteHandler({
8381

8482
const prisma = await getPrismaClientForTenancy(auth.tenancy);
8583

86-
const variables = "variables" in body ? body.variables ?? {} : {};
84+
let variables: Record<string, any> = "variables" in body ? body.variables ?? {} : {};
8785

8886
let overrideSubject: string | undefined = undefined;
8987
if (body.subject) {
@@ -159,6 +157,8 @@ export const POST = createSmartRouteHandler({
159157
}
160158
}
161159

160+
const scheduledAt = body.scheduled_at_millis ? new Date(body.scheduled_at_millis) : new Date();
161+
162162
await sendEmailToMany({
163163
createdWith: createdWith,
164164
tenancy: auth.tenancy,
@@ -168,7 +168,7 @@ export const POST = createSmartRouteHandler({
168168
themeId: selectedThemeId === null ? null : (selectedThemeId === undefined ? auth.tenancy.config.emails.selectedThemeId : selectedThemeId),
169169
isHighPriority: isHighPriority,
170170
shouldSkipDeliverabilityCheck: false,
171-
scheduledAt: new Date(),
171+
scheduledAt: scheduledAt,
172172
overrideSubject: overrideSubject,
173173
overrideNotificationCategoryId: overrideNotificationCategoryId,
174174
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { rewriteTemplateSourceWithAI } from "@/lib/email-template-rewrite";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { KnownErrors } from "@stackframe/stack-shared";
4+
import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
6+
export const POST = createSmartRouteHandler({
7+
metadata: {
8+
summary: "Rewrite email template source for email draft creation",
9+
description: "Rewrites email template TSX into standalone draft TSX using AI and runtime validation.",
10+
hidden: true,
11+
tags: ["Internal", "AI"],
12+
},
13+
request: yupObject({
14+
auth: yupObject({
15+
type: yupString().oneOf(["admin"]).defined(),
16+
tenancy: adaptSchema.defined(),
17+
}).defined(),
18+
body: yupObject({
19+
template_tsx_source: yupString().defined(),
20+
}).defined(),
21+
}),
22+
response: yupObject({
23+
statusCode: yupNumber().oneOf([200]).defined(),
24+
bodyType: yupString().oneOf(["json"]).defined(),
25+
body: yupObject({
26+
tsx_source: yupString().defined(),
27+
}).defined(),
28+
}),
29+
handler: async ({ body }) => {
30+
const rewriteResult = await rewriteTemplateSourceWithAI(body.template_tsx_source);
31+
if (rewriteResult.status === "error") {
32+
throw new KnownErrors.TemplateSourceRewriteError(rewriteResult.error);
33+
}
34+
35+
return {
36+
statusCode: 200,
37+
bodyType: "json",
38+
body: {
39+
tsx_source: rewriteResult.data,
40+
},
41+
};
42+
},
43+
});

apps/backend/src/lib/email-delivery-stats.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,22 @@ if (!Number.isFinite(defaultEmailCapacityPerHour)) {
3030
throw new StackAssertionError(`Invalid STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR environment variable: ${getEnvVariable("STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR", "<not set>")}`);
3131
}
3232

33-
export function calculateCapacityRate(stats: EmailDeliveryStats) {
33+
const BOOST_MULTIPLIER = 4;
34+
35+
export function calculateCapacityRate(stats: EmailDeliveryStats, boostExpiresAt?: Date | null) {
3436
const penaltyFactor = Math.min(
3537
calculatePenaltyFactor(stats.week.sent, stats.week.bounced, stats.week.markedAsSpam),
3638
calculatePenaltyFactor(stats.day.sent, stats.day.bounced, stats.day.markedAsSpam),
3739
calculatePenaltyFactor(stats.hour.sent, stats.hour.bounced, stats.hour.markedAsSpam)
3840
);
3941
const hourlyBaseline = defaultEmailCapacityPerHour + (4 * stats.month.sent / 30 / 24); // default capacity + 4x the average throughput during the last month
4042
const ratePerHour = Math.max(hourlyBaseline * penaltyFactor, defaultEmailCapacityPerHour / 4); // multiply by penalty factor, at least 1/4th of the default capacity
41-
const ratePerSecond = ratePerHour / 60 / 60;
42-
return { ratePerSecond, penaltyFactor };
43+
44+
const isBoostActive = boostExpiresAt != null && boostExpiresAt > new Date();
45+
const boostMultiplier = isBoostActive ? BOOST_MULTIPLIER : 1;
46+
47+
const ratePerSecond = (ratePerHour / 60 / 60) * boostMultiplier;
48+
return { ratePerSecond, boostMultiplier, penaltyFactor, isBoostActive };
4349
}
4450

4551
const deliveryStatsQuery = (tenancyId: string): RawQuery<EmailDeliveryStats> => ({
@@ -107,3 +113,12 @@ export async function getEmailDeliveryStatsForTenancy(tenancyId: string, tx?: Pr
107113
const client = tx ?? globalPrismaClient;
108114
return await rawQuery(client, deliveryStatsQuery(tenancyId));
109115
}
116+
117+
export async function getEmailCapacityBoostExpiresAt(tenancyId: string, tx?: PrismaClientTransaction): Promise<Date | null> {
118+
const client = tx ?? globalPrismaClient;
119+
const tenancy = await client.tenancy.findUniqueOrThrow({
120+
where: { id: tenancyId },
121+
select: { emailCapacityBoostExpiresAt: true },
122+
});
123+
return tenancy.emailCapacityBoostExpiresAt;
124+
}

apps/backend/src/lib/email-queue-step.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EmailOutbox, EmailOutboxSkippedReason, Prisma } from "@/generated/prisma/client";
2-
import { calculateCapacityRate, getEmailDeliveryStatsForTenancy } from "@/lib/email-delivery-stats";
2+
import { calculateCapacityRate, getEmailCapacityBoostExpiresAt, getEmailDeliveryStatsForTenancy } from "@/lib/email-delivery-stats";
33
import { getEmailThemeForThemeId, renderEmailsForTenancyBatched } from "@/lib/email-rendering";
44
import { EmailOutboxRecipient, getEmailConfig, } from "@/lib/emails";
55
import { generateUnsubscribeLink, getNotificationCategoryById, hasNotificationEnabled, listNotificationCategories } from "@/lib/notification-categories";
@@ -21,7 +21,7 @@ const MAX_RENDER_BATCH = 50;
2121

2222
const MAX_SEND_ATTEMPTS = 5;
2323

24-
const SEND_RETRY_BACKOFF_BASE_MS = 2000;
24+
const SEND_RETRY_BACKOFF_BASE_MS = 20000;
2525

2626
const calculateRetryBackoffMs = (attemptCount: number): number => {
2727
return (Math.random() + 0.5) * SEND_RETRY_BACKOFF_BASE_MS * Math.pow(2, attemptCount);
@@ -555,13 +555,21 @@ async function prepareSendPlan(deltaSeconds: number): Promise<TenancySendBatch[]
555555

556556
const plan: TenancySendBatch[] = [];
557557
for (const entry of tenancyIds) {
558-
const stats = await getEmailDeliveryStatsForTenancy(entry.tenancyId);
559-
const capacity = calculateCapacityRate(stats);
560-
const quota = stochasticQuota(capacity.ratePerSecond * deltaSeconds);
561-
if (quota <= 0) continue;
562-
const rows = await claimEmailsForSending(globalPrismaClient, entry.tenancyId, quota);
563-
if (rows.length === 0) continue;
564-
plan.push({ tenancyId: entry.tenancyId, rows, capacityRatePerSecond: capacity.ratePerSecond });
558+
try {
559+
const [stats, boostExpiresAt] = await Promise.all([
560+
getEmailDeliveryStatsForTenancy(entry.tenancyId),
561+
getEmailCapacityBoostExpiresAt(entry.tenancyId),
562+
]);
563+
const capacity = calculateCapacityRate(stats, boostExpiresAt);
564+
const quota = stochasticQuota(capacity.ratePerSecond * deltaSeconds);
565+
if (quota <= 0) continue;
566+
const rows = await claimEmailsForSending(globalPrismaClient, entry.tenancyId, quota);
567+
if (rows.length === 0) continue;
568+
plan.push({ tenancyId: entry.tenancyId, rows, capacityRatePerSecond: capacity.ratePerSecond });
569+
} catch (error) {
570+
captureError("email-queue-step-prepare-send-plan-for-tenancy-error", error);
571+
continue;
572+
}
565573
}
566574
return plan;
567575
}

apps/backend/src/lib/email-rendering.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,3 +685,4 @@ describe('renderEmailWithTemplate', () => {
685685
`);
686686
});
687687
});
688+

0 commit comments

Comments
 (0)