From e1691cd017f5b330f4d7623346383f217c952aaa Mon Sep 17 00:00:00 2001
From: Henry <48483883+hfellerhoff@users.noreply.github.com>
Date: Tue, 14 Apr 2026 10:29:46 -0400
Subject: [PATCH 01/29] add resend support v0
---
.env.example | 8 ++-
README.md | 7 +-
docker-compose.arm.yaml | 3 +
docker-compose.build-arm.yaml | 3 +
docker-compose.build-cloud.yaml | 3 +
docker-compose.build.yaml | 3 +
docker-compose.cloud.yaml | 3 +
docker-compose.yaml | 3 +
package.json | 1 +
pnpm-lock.yaml | 38 +++++++++++
src/app/_app.index.tsx | 19 ++++++
src/app/_web.releases.$slug.tsx | 15 +++--
src/app/welcome.tsx | 22 +++----
src/components/feed/SubscriptionDialog.tsx | 35 ++++++----
src/components/ui/responsive-dropdown.tsx | 8 ++-
src/emails/reset-password.tsx | 38 ++++++-----
src/emails/verify-email.tsx | 38 ++++++-----
src/env.js | 4 ++
src/server/api/routers/subscriptionRouter.ts | 3 +-
src/server/auth/endpoints.ts | 4 +-
src/server/auth/index.tsx | 69 +++++++++++++-------
src/server/email.ts | 42 ++++++++++++
22 files changed, 274 insertions(+), 95 deletions(-)
create mode 100644 src/server/email.ts
diff --git a/.env.example b/.env.example
index 4ecfe056..edee9ac7 100644
--- a/.env.example
+++ b/.env.example
@@ -18,9 +18,13 @@ BETTER_AUTH_SECRET=
# OAUTH_SCOPES=openid email profile
# OAUTH_PKCE=false
-# Email
-# Not required, but needed for functions like sending password reset emails.
+# Email (optional — needed for password reset and email verification)
+# FROM_EMAIL_ADDRESS is required for email sending to work.
+# If both provider keys are set, Resend takes priority.
+FROM_EMAIL_ADDRESS=
+RESEND_API_KEY=
SENDGRID_API_KEY=
+VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS=
# Integrations
INSTAPAPER_OAUTH_ID=
diff --git a/README.md b/README.md
index cceba9b9..06115f2b 100644
--- a/README.md
+++ b/README.md
@@ -52,9 +52,10 @@ Serial takes a model of progressive enhancement for features. The app can run wi
### Email support (for password reset, etc)
-- Create an account on [Sendgrid](https://sendgrid.com/en-us)
-- Set up a mailing address
-- Add your `SENDGRID_API_KEY` to `.env` or your host's environment variables UI.
+Serial supports [Resend](https://resend.com) and [SendGrid](https://sendgrid.com/en-us) as email providers. Only one is used at a time — if both keys are set, Resend takes priority.
+
+- **Resend**: Create an account, add your `RESEND_API_KEY` to `.env` or your host's environment variables UI.
+- **SendGrid**: Create an account, set up a mailing address, add your `SENDGRID_API_KEY` to `.env` or your host's environment variables UI.
### Instapaper integration
diff --git a/docker-compose.arm.yaml b/docker-compose.arm.yaml
index 9745fc35..97043d4d 100644
--- a/docker-compose.arm.yaml
+++ b/docker-compose.arm.yaml
@@ -21,7 +21,10 @@ services:
NODE_ENV: production
DATABASE_URL: http://libsql:8080
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS}
+ RESEND_API_KEY: ${RESEND_API_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS}
INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID}
INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET}
depends_on:
diff --git a/docker-compose.build-arm.yaml b/docker-compose.build-arm.yaml
index 899ab462..5e2dff77 100644
--- a/docker-compose.build-arm.yaml
+++ b/docker-compose.build-arm.yaml
@@ -25,7 +25,10 @@ services:
NODE_ENV: production
DATABASE_URL: http://libsql:8080
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS}
+ RESEND_API_KEY: ${RESEND_API_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS}
INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID}
INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET}
depends_on:
diff --git a/docker-compose.build-cloud.yaml b/docker-compose.build-cloud.yaml
index 7b4c37d0..32b00127 100644
--- a/docker-compose.build-cloud.yaml
+++ b/docker-compose.build-cloud.yaml
@@ -11,7 +11,10 @@ services:
DATABASE_URL: ${DATABASE_URL}
DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS}
+ RESEND_API_KEY: ${RESEND_API_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS}
INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID}
INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET}
ports:
diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml
index e8186987..3a115db4 100644
--- a/docker-compose.build.yaml
+++ b/docker-compose.build.yaml
@@ -25,7 +25,10 @@ services:
NODE_ENV: production
DATABASE_URL: http://libsql:8080
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS}
+ RESEND_API_KEY: ${RESEND_API_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS}
INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID}
INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET}
depends_on:
diff --git a/docker-compose.cloud.yaml b/docker-compose.cloud.yaml
index 23fcdde6..4a58027f 100644
--- a/docker-compose.cloud.yaml
+++ b/docker-compose.cloud.yaml
@@ -6,7 +6,10 @@ services:
DATABASE_URL: ${DATABASE_URL}
DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS}
+ RESEND_API_KEY: ${RESEND_API_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS}
INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID}
INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET}
ports:
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 413d78b0..850af3f7 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -21,7 +21,10 @@ services:
NODE_ENV: production
DATABASE_URL: http://libsql:8080
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS}
+ RESEND_API_KEY: ${RESEND_API_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
+ VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS}
INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID}
INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET}
depends_on:
diff --git a/package.json b/package.json
index bfb725ee..5201abfd 100644
--- a/package.json
+++ b/package.json
@@ -124,6 +124,7 @@
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
+ "resend": "^6.11.0",
"rss-parser": "^3.13.0",
"sonner": "^2.0.7",
"superjson": "^2.2.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d2a5a49b..fb7f61af 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -249,6 +249,9 @@ importers:
remark-rehype:
specifier: ^11.1.2
version: 11.1.2
+ resend:
+ specifier: ^6.11.0
+ version: 6.11.0(@react-email/render@2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
rss-parser:
specifier: ^3.13.0
version: 3.13.0
@@ -6505,6 +6508,9 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
+ postal-mime@2.7.4:
+ resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
+
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
@@ -6832,6 +6838,15 @@ packages:
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+ resend@6.11.0:
+ resolution: {integrity: sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ '@react-email/render': '*'
+ peerDependenciesMeta:
+ '@react-email/render':
+ optional: true
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -7191,6 +7206,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ svix@1.90.0:
+ resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==}
+
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -7547,6 +7565,10 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ uuid@10.0.0:
+ resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+ hasBin: true
+
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -14195,6 +14217,8 @@ snapshots:
possible-typed-array-names@1.1.0: {}
+ postal-mime@2.7.4: {}
+
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
@@ -14556,6 +14580,13 @@ snapshots:
reselect@5.1.1: {}
+ resend@6.11.0(@react-email/render@2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
+ dependencies:
+ postal-mime: 2.7.4
+ svix: 1.90.0
+ optionalDependencies:
+ '@react-email/render': 2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+
resolve-pkg-maps@1.0.0: {}
resolve@1.22.11:
@@ -14990,6 +15021,11 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ svix@1.90.0:
+ dependencies:
+ standardwebhooks: 1.0.0
+ uuid: 10.0.0
+
tagged-tag@1.0.0: {}
tailwind-merge@3.5.0: {}
@@ -15319,6 +15355,8 @@ snapshots:
util-deprecate@1.0.2: {}
+ uuid@10.0.0: {}
+
v8-compile-cache-lib@3.0.1: {}
vary@1.1.2: {}
diff --git a/src/app/_app.index.tsx b/src/app/_app.index.tsx
index cb9a5dbc..02c24fd0 100644
--- a/src/app/_app.index.tsx
+++ b/src/app/_app.index.tsx
@@ -14,6 +14,10 @@ import { ViewFilterChips } from "~/components/feed/ViewFilterChips";
import { useUpdateViewFilter } from "~/lib/data/views";
import { useShortcut } from "~/lib/hooks/useShortcut";
import { SHORTCUT_KEYS } from "~/lib/constants/shortcuts";
+import { useFeeds } from "~/lib/data/feeds";
+import { useHasInitialData } from "~/lib/data/store";
+import FeedLoading from "~/components/loading";
+import { FeedEmptyState } from "~/components/feed/view-lists/EmptyStates";
export const Route = createFileRoute("/_app/")({
component: Home,
@@ -90,6 +94,21 @@ function Home() {
updateViewFilter(views[nextIndex]!.id);
});
+ const hasInitialData = useHasInitialData();
+ const { feeds, hasFetchedFeeds } = useFeeds();
+
+ if (!hasInitialData) {
+ return ;
+ }
+
+ if (hasFetchedFeeds && !feeds.length) {
+ return (
+
+
+
+ );
+ }
+
return (
diff --git a/src/app/_web.releases.$slug.tsx b/src/app/_web.releases.$slug.tsx
index 66691ce8..13f18395 100644
--- a/src/app/_web.releases.$slug.tsx
+++ b/src/app/_web.releases.$slug.tsx
@@ -14,6 +14,7 @@ export const Route = createFileRoute("/_web/releases/$slug")({
function RouteComponent() {
const { release, isAuthed } = Route.useLoaderData();
+ const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS;
return (
@@ -39,12 +40,14 @@ function RouteComponent() {
{isAuthed && (
<>
- Thanks for checking out the release log! If you have any questions
- or feedback, feel free to send me an email at{" "}
-
- hey@serial.tube
-
- .
+ Thanks for checking out the release log!
+ {supportEmail && (
+ <>
+ {" "}
+ If you have any questions or feedback, feel free to send me an
+ email at {supportEmail} .
+ >
+ )}
Return to the app →
>
diff --git a/src/app/welcome.tsx b/src/app/welcome.tsx
index 9749d25a..86f687d9 100644
--- a/src/app/welcome.tsx
+++ b/src/app/welcome.tsx
@@ -23,6 +23,7 @@ export const Route = createFileRoute("/welcome")({
function RouteComponent() {
const { mostRecentRelease } = Route.useLoaderData();
+ const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS;
return (
@@ -187,17 +188,16 @@ function RouteComponent() {
-
+ {supportEmail && (
+
+ )}
);
}
diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx
index 77750dfb..d8d913e2 100644
--- a/src/components/feed/SubscriptionDialog.tsx
+++ b/src/components/feed/SubscriptionDialog.tsx
@@ -9,6 +9,7 @@ import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown";
+import { Skeleton } from "~/components/ui/skeleton";
import { useSubscription } from "~/lib/data/subscription";
import { orpc } from "~/lib/orpc";
import { authClient, useSession } from "~/lib/auth-client";
@@ -18,18 +19,21 @@ function formatPrice(cents: number): string {
return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`;
}
-function formatRefreshInterval(ms: number | null): string {
- if (ms == null) return "Manual refresh only";
- const minutes = ms / (60 * 1000);
- if (minutes < 60) return `Background refresh every ${minutes} min`;
- const hours = minutes / 60;
- return `Background refresh every ${hours} hr`;
-}
-
function getPlanFeatures(plan: PlanConfig): string[] {
const features: string[] = [];
features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`);
- features.push(formatRefreshInterval(plan.backgroundRefreshIntervalMs));
+
+ if (plan.id === "free") {
+ features.push("Refresh up to once an hour");
+ features.push("Manual refresh only");
+ } else {
+ features.push(
+ plan.id === "pro"
+ ? "Refreshes once every 5 min"
+ : "Refreshes once every 15 min",
+ );
+ features.push("Automatically refresh in background");
+ }
return features;
}
@@ -132,7 +136,7 @@ export function SubscriptionDialog({
const emailVerified = session?.user?.emailVerified ?? false;
- const { data: products } = useQuery({
+ const { data: products, isLoading: isLoadingProducts } = useQuery({
...orpc.subscription.getProducts.queryOptions(),
enabled: open,
staleTime: 5 * 60 * 1000,
@@ -184,10 +188,13 @@ export function SubscriptionDialog({
onOpenChange={onOpenChange}
title="Subscription"
description="Choose a plan that fits your needs."
+ className="lg:max-w-4xl"
>
-
+
{showVerification && !emailVerified && (
-
+
+
+
)}
{PLAN_IDS.map((id) => {
const plan = PLANS[id];
@@ -215,7 +222,9 @@ export function SubscriptionDialog({
)}
- {hasPrice ? (
+ {isPaid && isLoadingProducts ? (
+
+ ) : hasPrice ? (
{monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`}
{monthlyPrice != null && annualPrice != null && " · "}
diff --git a/src/components/ui/responsive-dropdown.tsx b/src/components/ui/responsive-dropdown.tsx
index f16a6645..7625d4ae 100644
--- a/src/components/ui/responsive-dropdown.tsx
+++ b/src/components/ui/responsive-dropdown.tsx
@@ -114,6 +114,7 @@ interface ControlledResponsiveDialogProps {
children: React.ReactNode;
title?: string;
description?: string;
+ className?: string;
onBack?: () => void;
headerRight?: React.ReactNode;
onOpenAutoFocus?: (event: Event) => void;
@@ -126,6 +127,7 @@ export function ControlledResponsiveDialog({
description,
onBack,
headerRight,
+ className,
onOpenAutoFocus,
}: ControlledResponsiveDialogProps) {
const isDesktop = useMediaQuery("(min-width: 640px)");
@@ -133,7 +135,11 @@ export function ControlledResponsiveDialog({
if (isDesktop) {
return (
-
+
{onBack && (
@@ -56,24 +60,26 @@ export default function ResetPasswordEmail({ resetUrl }: ResetPasswordProps) {
height="48"
alt="Serial's Logo"
/>
-
- Having trouble? Reach out to us at{" "}
-
- hey@serial.tube
-
-
+ Having trouble? Reach out to us at{" "}
+
+ {supportEmail}
+
+
+ )}