add waitlist confirmation (#239)

This commit is contained in:
KM Koushik
2025-09-19 07:26:38 +10:00
committed by GitHub
parent 87c772dcc4
commit 62a15ef811
5 changed files with 127 additions and 17 deletions

View File

@@ -14,6 +14,11 @@ export const waitlistSubmissionSchema = z.object({
emailTypes: z emailTypes: z
.array(z.enum(WAITLIST_EMAIL_TYPES)) .array(z.enum(WAITLIST_EMAIL_TYPES))
.min(1, "Select at least one email type"), .min(1, "Select at least one email type"),
emailVolume: z
.string({ required_error: "Share your expected volume" })
.trim()
.min(1, "Tell us how many emails you expect to send")
.max(500, "Keep the volume details under 500 characters"),
description: z description: z
.string({ required_error: "Provide a short description" }) .string({ required_error: "Provide a short description" })
.trim() .trim()

View File

@@ -40,6 +40,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
defaultValues: { defaultValues: {
domain: "", domain: "",
emailTypes: [], emailTypes: [],
emailVolume: "",
description: "", description: "",
}, },
}); });
@@ -146,6 +147,25 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
}} }}
/> />
<FormField
control={form.control}
name="emailVolume"
render={({ field }) => (
<FormItem>
<FormLabel>How many emails will you send?</FormLabel>
<FormControl>
<Textarea
rows={3}
placeholder="e.g., Around 400 transactional emails per day and 5,000 marketing emails per month"
{...field}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"

View File

@@ -5,6 +5,8 @@ import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service"; import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses"; import { getAccount } from "~/server/aws/ses";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { sendMail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
const waitlistUserSelection = { const waitlistUserSelection = {
id: true, id: true,
@@ -14,6 +16,28 @@ const waitlistUserSelection = {
createdAt: true, createdAt: true,
} as const; } as const;
function toPlainHtml(text: string) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return `<pre style="font-family: inherit; white-space: pre-wrap; margin: 0;">${escaped}</pre>`;
}
function formatDisplayNameFromEmail(email: string) {
const localPart = email.split("@")[0] ?? email;
const pieces = localPart.split(/[._-]+/).filter(Boolean);
if (pieces.length === 0) {
return localPart;
}
return pieces
.map((piece) => piece.charAt(0).toUpperCase() + piece.slice(1))
.join(" ");
}
const teamAdminSelection = { const teamAdminSelection = {
id: true, id: true,
name: true, name: true,
@@ -54,7 +78,7 @@ export const adminRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
region: z.string(), region: z.string(),
}), })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const acc = await getAccount(input.region); const acc = await getAccount(input.region);
@@ -68,7 +92,7 @@ export const adminRouter = createTRPCRouter({
usesendUrl: z.string().url(), usesendUrl: z.string().url(),
sendRate: z.number(), sendRate: z.number(),
transactionalQuota: z.number(), transactionalQuota: z.number(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({ return SesSettingsService.createSesSetting({
@@ -85,7 +109,7 @@ export const adminRouter = createTRPCRouter({
settingsId: z.string(), settingsId: z.string(),
sendRate: z.number(), sendRate: z.number(),
transactionalQuota: z.number(), transactionalQuota: z.number(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return SesSettingsService.updateSesSetting({ return SesSettingsService.updateSesSetting({
@@ -99,11 +123,11 @@ export const adminRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
region: z.string().optional().nullable(), region: z.string().optional().nullable(),
}), })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
return SesSettingsService.getSetting( return SesSettingsService.getSetting(
input.region ?? env.AWS_DEFAULT_REGION, input.region ?? env.AWS_DEFAULT_REGION
); );
}), }),
@@ -114,7 +138,7 @@ export const adminRouter = createTRPCRouter({
.string() .string()
.email() .email()
.transform((value) => value.toLowerCase()), .transform((value) => value.toLowerCase()),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const user = await db.user.findUnique({ const user = await db.user.findUnique({
@@ -130,15 +154,62 @@ export const adminRouter = createTRPCRouter({
z.object({ z.object({
userId: z.number(), userId: z.number(),
isWaitlisted: z.boolean(), isWaitlisted: z.boolean(),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const existingUser = await db.user.findUnique({
where: { id: input.userId },
select: waitlistUserSelection,
});
if (!existingUser) {
throw new Error("User not found");
}
const updatedUser = await db.user.update({ const updatedUser = await db.user.update({
where: { id: input.userId }, where: { id: input.userId },
data: { isWaitlisted: input.isWaitlisted }, data: { isWaitlisted: input.isWaitlisted },
select: waitlistUserSelection, select: waitlistUserSelection,
}); });
const founderEmail = env.FOUNDER_EMAIL ?? undefined;
const fallbackFrom = env.FROM_EMAIL ?? env.ADMIN_EMAIL ?? undefined;
const shouldSendAcceptanceEmail =
existingUser.isWaitlisted &&
!input.isWaitlisted &&
Boolean(updatedUser.email) &&
(founderEmail || fallbackFrom);
if (shouldSendAcceptanceEmail) {
const recipient = updatedUser.email as string;
const replyTo = founderEmail ?? fallbackFrom;
const fromOverride = founderEmail ?? undefined;
const founderName = replyTo
? formatDisplayNameFromEmail(replyTo)
: "Founder";
const userFirstName =
updatedUser.name?.split(" ")[0] ?? updatedUser.name ?? recipient;
const text = `Hey ${userFirstName},\n\nThanks for hanging in while we reviewed your waitlist request. I've just moved your account off the waitlist, so you now have full access to useSend.\n\nGo ahead and log back in to start sending: ${env.NEXTAUTH_URL}\n\nIf anything feels unclear or you want help getting set up, reply to this email and it comes straight to me.\n\nCheers,\n${founderName}\n${replyTo}`;
try {
await sendMail(
recipient,
"useSend: You're off the waitlist",
text,
toPlainHtml(text),
replyTo,
fromOverride
);
} catch (error) {
logger.error(
{ userId: updatedUser.id, error },
"Failed to send waitlist acceptance email"
);
}
}
return updatedUser; return updatedUser;
}), }),
@@ -149,7 +220,7 @@ export const adminRouter = createTRPCRouter({
.string({ required_error: "Search query is required" }) .string({ required_error: "Search query is required" })
.trim() .trim()
.min(1, "Search query is required"), .min(1, "Search query is required"),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const query = input.query.trim(); const query = input.query.trim();
@@ -205,7 +276,7 @@ export const adminRouter = createTRPCRouter({
dailyEmailLimit: z.number().int().min(0).max(10_000_000), dailyEmailLimit: z.number().int().min(0).max(10_000_000),
isBlocked: z.boolean(), isBlocked: z.boolean(),
plan: z.enum(["FREE", "BASIC"]), plan: z.enum(["FREE", "BASIC"]),
}), })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { teamId, ...data } = input; const { teamId, ...data } = input;

View File

@@ -75,11 +75,14 @@ export const waitlistRouter = createTRPCRouter({
const escapedDescription = escapeHtml(input.description); const escapedDescription = escapeHtml(input.description);
const escapedDomain = escapeHtml(input.domain); const escapedDomain = escapeHtml(input.domain);
const escapedEmailVolume = escapeHtml(input.emailVolume);
const subject = `Waitlist request from ${user.email ?? "unknown user"}`; const subject = `Waitlist request from ${user.email ?? "unknown user"}`;
const textBody = `A waitlisted user submitted a request:\n\nEmail: ${ const textBody = `A waitlisted user submitted a request:\n\nEmail: ${
user.email ?? "Unknown" user.email ?? "Unknown"
}\nDomain: ${input.domain}\nInterested emails: ${typesLabel}\n\nDescription:\n${input.description}`; }\nDomain: ${input.domain}\nInterested emails: ${typesLabel}\nExpected sending volume: ${
input.emailVolume
}\n\nDescription:\n${input.description}`;
const htmlBody = ` const htmlBody = `
<p>A waitlisted user submitted a request.</p> <p>A waitlisted user submitted a request.</p>
@@ -87,6 +90,7 @@ export const waitlistRouter = createTRPCRouter({
<li><strong>Email:</strong> ${escapeHtml(user.email ?? "Unknown")}</li> <li><strong>Email:</strong> ${escapeHtml(user.email ?? "Unknown")}</li>
<li><strong>Domain:</strong> ${escapedDomain}</li> <li><strong>Domain:</strong> ${escapedDomain}</li>
<li><strong>Interested emails:</strong> ${escapeHtml(typesLabel)}</li> <li><strong>Interested emails:</strong> ${escapeHtml(typesLabel)}</li>
<li><strong>Expected sending volume:</strong> ${escapedEmailVolume}</li>
</ul> </ul>
<p><strong>Description</strong></p> <p><strong>Description</strong></p>
<p style="white-space: pre-wrap;">${escapedDescription}</p> <p style="white-space: pre-wrap;">${escapedDescription}</p>

View File

@@ -74,7 +74,8 @@ export async function sendMail(
subject: string, subject: string,
text: string, text: string,
html: string, html: string,
replyTo?: string replyTo?: string,
fromOverride?: string
) { ) {
if (isSelfHosted()) { if (isSelfHosted()) {
logger.info("Sending email using self hosted"); logger.info("Sending email using self hosted");
@@ -96,24 +97,33 @@ export async function sendMail(
return; return;
} }
const fromEmailDomain = env.FROM_EMAIL?.split("@")[1]; const availableDomains = domains.map((d) => d.name);
const domain = domains[0];
const domain = const candidateFroms = [fromOverride, env.FROM_EMAIL, `hello@${domain.name}`].filter(
domains.find((d) => d.name === fromEmailDomain) ?? domains[0]; (value): value is string => Boolean(value)
);
const selectedFrom =
candidateFroms.find((address) => {
const domainPart = address.split("@")[1];
return domainPart ? availableDomains.includes(domainPart) : false;
}) ?? `hello@${domain.name}`;
await sendEmail({ await sendEmail({
teamId: team.id, teamId: team.id,
to: email, to: email,
from: `hello@${domain.name}`, from: selectedFrom,
subject, subject,
text, text,
html, html,
replyTo, replyTo,
}); });
} else if (env.UNSEND_API_KEY && env.FROM_EMAIL) { } else if (env.UNSEND_API_KEY && (env.FROM_EMAIL || fromOverride)) {
const fromAddress = fromOverride ?? env.FROM_EMAIL!;
const resp = await getClient().emails.send({ const resp = await getClient().emails.send({
to: email, to: email,
from: env.FROM_EMAIL, from: fromAddress,
subject, subject,
text, text,
html, html,