Skip to content

[Refactor][Feat][Fix] Rework Email Section With New Sent Page, Better Drafts Page, and Settings Page#1221

Merged
N2D4 merged 50 commits intodevfrom
email-section-rework
Mar 11, 2026
Merged

[Refactor][Feat][Fix] Rework Email Section With New Sent Page, Better Drafts Page, and Settings Page#1221
N2D4 merged 50 commits intodevfrom
email-section-rework

Conversation

@nams1570
Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 commented Feb 25, 2026

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.

  1. 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:

Sent.Page.Demo.mov

Drafts Page Demo

Drafts.Page.Demo.mov

Settings Page Demo

Settings.Page.Demo.mov

Email Viewer Page Demo

Email.Viewer.Page.Demo.mov

Summary by CodeRabbit

  • New Features
    • Scheduled email sending and per-draft template variables (editor UI + extraction)
    • Temporary email capacity boost (4× for 4 hours) with activation and countdown
    • Multi-step draft workflow with progress bar and template-driven creation
    • Sent emails area: grouped/list views, delivery timeline, domain reputation panel, and Email Settings
    • Outbox now shows source-tracking metadata (draft/template/programmatic)
    • New default "Colorful" email theme and UI refinements

They take you to a page where you can edit them.
Currently, we can only edit the scheduled at, whether its paused, and whether to cancel the email.
This is because the EmailOutBoxUpdateOptions only supports these for now.
We may expand this in a future commit.
Note that sent emails cant be edited.
Note: in the stats bar, we mark green- sent,opened,clicked,delivery-delayed,skipped.
Mark red-bounced
Mark yellow-marked as spam
Mark Orange-errors (server-error, render-error).
Mark gray- the rest.
These correlations are between the colors in the stats bar and the statuses we pull from the outbox api.
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.
This is mainly about text size and badge colors.
Mostly UI Improvements
Can step backwards through stepper.
Archive used drafts since drafts are meant to be fire and forget.
draft stage now persists on refresh.
Settings stage split into recipients and scheduling.
Clicking on archived draft takes you to its sent page.
What is comes down to is that the search bar and the DataPaginatedTable are both children of the suspense with skeleton fallback, and upon the user typing in a new character, global filters updates which causes rerender but also causes useUser to be called again, inside which you will find a use() which is what triggers the suspense to use the fallback UI.

the fix is non trivial. Removing the suspense makes it so that the page reloads on every change. The suspense cant just cover the DataPaginationTable with some sort of decoupled search bar because the user stuff has to be in the suspense boundary. We cant just completely decouple search bar from DataPaginationTable because DataPaginationTable -> DataTableBase-> DataTableView-> DataTableToolbar which renders the bar, and datatabletoolbar is what gives the searchbar component access to the table to set filters from. Dependency inversion here.

We mimic the users table flow, and get rid of the redundant components.
When creating a draft from a template that uses variables (e.g.
passwordResetLink), the variable values are now stored on the draft
record in the database. Previously I tried putting them in an in-memory
Map + sessionStorage on the client, which caused render errors when
navigating between drafts or reloading the page, or closing the tab and trying to open up the project again.

- Add templateVariables JSON column to EmailDraft table
- Update draft CRUD routes to read/write template_variables
- Send-email route now reads variables from the draft record directly
- Remove all client-side variable storage (in-memory Map, sessionStorage)
- Simplify DraftFlowContext to only hold UI state (recipients, schedule)
useEmailDrafts gets the email drafts, but uses a cache.
Because of this, when you navigate to the drafts page without a reload (caused by next.js optimizations), it uses stale cache.
This becomes a problem because if you send a draft and then navigate back, the cached version of the draft has sentAt marked as null, so you can reuse the draft.
Drafts are meant to be fire-and-forget.
We add functionality to refresh drafts cache to protect against stale cache issues.
We refresh on sending an email.
An outage will generally take more than a few seconds to resolve.
2 second base backoff means more often than not we're just waiting for the next queue step iteration. waiting longer might be a good idea
Themes can now only be navigated to from the settings
stepper now makes it evident which ones you can click by hover and visual hints.
A gradient at the bottom of the central theme, with the text overlaid in bold.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx (2)

230-230: Type-widening cast on setStage may accept invalid stage values.

The cast setStage as (stepId: string) => void bypasses the DraftStage type constraint. If DraftProgressBar passes a step ID that isn't a valid DraftStage, setStageInternal would set invalid state without compile-time detection.

Consider adding runtime validation or updating the types to ensure type safety:

🛡️ Suggested defensive wrapper
-                onStepClick={setStage as (stepId: string) => void}
+                onStepClick={(stepId) => {
+                  if (isValidStage(stepId)) {
+                    setStage(stepId);
+                  }
+                }}

Also applies to: 305, 312, 373, 529

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
at line 230, The prop cast on DraftProgressBar (onStepClick={setStage as
(stepId: string) => void}) weakens the DraftStage type and can allow invalid
stages; replace the cast by providing a small wrapper function that validates
the incoming stepId against the DraftStage union (or a known list of allowed
stages) before calling setStageInternal/setStage, or narrow the DraftProgressBar
prop type to accept DraftStage; implement the wrapper where DraftProgressBar is
used (referencing setStage, setStageInternal, DraftProgressBar, DraftStage, and
onStepClick) so only validated DraftStage values are passed to state setters and
invalid values are rejected or logged.

498-506: Type cast bypasses discriminated union type checking.

Line 506 uses as Parameters<typeof stackAdminApp.sendEmail>[0] to work around TypeScript's limitation with discriminated unions in ternaries. While this is a known pattern, it could mask type errors if the sendEmail API signature changes.

Per coding guidelines: "Do NOT use as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it."

Consider restructuring to avoid the cast:

♻️ Suggested refactor to avoid type cast
-      await stackAdminApp.sendEmail(
-        (scope === "users"
-          ? { draftId, userIds: selectedUserIds, scheduledAt }
-          : { draftId, allUsers: true, scheduledAt }
-        ) as Parameters<typeof stackAdminApp.sendEmail>[0]
-      );
+      if (scope === "users") {
+        await stackAdminApp.sendEmail({ draftId, userIds: selectedUserIds, scheduledAt });
+      } else {
+        await stackAdminApp.sendEmail({ draftId, allUsers: true, scheduledAt });
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx
around lines 498 - 506, The code bypasses TypeScript's discriminated-union
checks by casting the ternary result to Parameters<typeof
stackAdminApp.sendEmail>[0]; instead, construct the payload in a type-safe way:
create a local variable (e.g., payload) typed as the actual sendEmail parameter
type or as a discriminated union, set scheduledAt from
scheduleMode/scheduledDate/scheduledTime, then assign either { draftId, userIds:
selectedUserIds, scheduledAt } when scope === "users" or { draftId, allUsers:
true, scheduledAt } when scope !== "users", and pass that payload to
stackAdminApp.sendEmail without using as/any; reference stackAdminApp.sendEmail,
scheduledAt, scheduleMode, selectedUserIds, draftId, and scope to locate and
update the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx:
- Line 230: The prop cast on DraftProgressBar (onStepClick={setStage as (stepId:
string) => void}) weakens the DraftStage type and can allow invalid stages;
replace the cast by providing a small wrapper function that validates the
incoming stepId against the DraftStage union (or a known list of allowed stages)
before calling setStageInternal/setStage, or narrow the DraftProgressBar prop
type to accept DraftStage; implement the wrapper where DraftProgressBar is used
(referencing setStage, setStageInternal, DraftProgressBar, DraftStage, and
onStepClick) so only validated DraftStage values are passed to state setters and
invalid values are rejected or logged.
- Around line 498-506: The code bypasses TypeScript's discriminated-union checks
by casting the ternary result to Parameters<typeof stackAdminApp.sendEmail>[0];
instead, construct the payload in a type-safe way: create a local variable
(e.g., payload) typed as the actual sendEmail parameter type or as a
discriminated union, set scheduledAt from
scheduleMode/scheduledDate/scheduledTime, then assign either { draftId, userIds:
selectedUserIds, scheduledAt } when scope === "users" or { draftId, allUsers:
true, scheduledAt } when scope !== "users", and pass that payload to
stackAdminApp.sendEmail without using as/any; reference stackAdminApp.sendEmail,
scheduledAt, scheduleMode, selectedUserIds, draftId, and scope to locate and
update the logic.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0a1a3b2 and 224962c.

📒 Files selected for processing (1)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx

Comment thread apps/backend/prisma/schema.prisma Outdated
Comment thread apps/backend/src/app/api/latest/emails/send-email/route.tsx Outdated
Comment thread packages/template/src/lib/stack-app/apps/interfaces/server-app.ts Outdated
When you send an email, the state of the draft/template that created it is changed.
A cache refresh here is good to ensure the changed state is picked up.
Remove the template-variable extraction flow and switch draft creation to a
single AI rewrite step that produces standalone draft TSX.

- add internal rewrite endpoint for template TSX with runtime render validation
- remove extraction route and extraction API surface from backend/SDK interfaces
- update dashboard create-from-template flow to skip variable modal and use rewrite
- add rewrite e2e coverage (regular schema, renamed schema, no-schema pass-through)
- align AI invocation with internal AI routes, including mock-mode behavior
Matches how we do it for other endpoints, avoids codegen issues.
@N2D4 N2D4 merged commit 485fa9d into dev Mar 11, 2026
30 of 32 checks passed
@N2D4 N2D4 deleted the email-section-rework branch March 11, 2026 19:01
@coderabbitai coderabbitai bot mentioned this pull request Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants