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
.array(z.enum(WAITLIST_EMAIL_TYPES))
.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
.string({ required_error: "Provide a short description" })
.trim()

View File

@@ -40,6 +40,7 @@ export function WaitListForm({ userEmail }: WaitListFormProps) {
defaultValues: {
domain: "",
emailTypes: [],
emailVolume: "",
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
control={form.control}
name="description"

View File

@@ -5,6 +5,8 @@ import { createTRPCRouter, adminProcedure } from "~/server/api/trpc";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { getAccount } from "~/server/aws/ses";
import { db } from "~/server/db";
import { sendMail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
const waitlistUserSelection = {
id: true,
@@ -14,6 +16,28 @@ const waitlistUserSelection = {
createdAt: true,
} 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 = {
id: true,
name: true,
@@ -54,7 +78,7 @@ export const adminRouter = createTRPCRouter({
.input(
z.object({
region: z.string(),
}),
})
)
.query(async ({ input }) => {
const acc = await getAccount(input.region);
@@ -68,7 +92,7 @@ export const adminRouter = createTRPCRouter({
usesendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number(),
}),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
@@ -85,7 +109,7 @@ export const adminRouter = createTRPCRouter({
settingsId: z.string(),
sendRate: z.number(),
transactionalQuota: z.number(),
}),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.updateSesSetting({
@@ -99,11 +123,11 @@ export const adminRouter = createTRPCRouter({
.input(
z.object({
region: z.string().optional().nullable(),
}),
})
)
.query(async ({ input }) => {
return SesSettingsService.getSetting(
input.region ?? env.AWS_DEFAULT_REGION,
input.region ?? env.AWS_DEFAULT_REGION
);
}),
@@ -114,7 +138,7 @@ export const adminRouter = createTRPCRouter({
.string()
.email()
.transform((value) => value.toLowerCase()),
}),
})
)
.mutation(async ({ input }) => {
const user = await db.user.findUnique({
@@ -130,15 +154,62 @@ export const adminRouter = createTRPCRouter({
z.object({
userId: z.number(),
isWaitlisted: z.boolean(),
}),
})
)
.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({
where: { id: input.userId },
data: { isWaitlisted: input.isWaitlisted },
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;
}),
@@ -149,7 +220,7 @@ export const adminRouter = createTRPCRouter({
.string({ required_error: "Search query is required" })
.trim()
.min(1, "Search query is required"),
}),
})
)
.mutation(async ({ input }) => {
const query = input.query.trim();
@@ -205,7 +276,7 @@ export const adminRouter = createTRPCRouter({
dailyEmailLimit: z.number().int().min(0).max(10_000_000),
isBlocked: z.boolean(),
plan: z.enum(["FREE", "BASIC"]),
}),
})
)
.mutation(async ({ input }) => {
const { teamId, ...data } = input;

View File

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

View File

@@ -74,7 +74,8 @@ export async function sendMail(
subject: string,
text: string,
html: string,
replyTo?: string
replyTo?: string,
fromOverride?: string
) {
if (isSelfHosted()) {
logger.info("Sending email using self hosted");
@@ -96,24 +97,33 @@ export async function sendMail(
return;
}
const fromEmailDomain = env.FROM_EMAIL?.split("@")[1];
const availableDomains = domains.map((d) => d.name);
const domain = domains[0];
const domain =
domains.find((d) => d.name === fromEmailDomain) ?? domains[0];
const candidateFroms = [fromOverride, env.FROM_EMAIL, `hello@${domain.name}`].filter(
(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({
teamId: team.id,
to: email,
from: `hello@${domain.name}`,
from: selectedFrom,
subject,
text,
html,
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({
to: email,
from: env.FROM_EMAIL,
from: fromAddress,
subject,
text,
html,