diff --git a/apps/web/src/components/settings/AddSesSettings.tsx b/apps/web/src/components/settings/AddSesSettings.tsx index f90cdfd3..7e199d58 100644 --- a/apps/web/src/components/settings/AddSesSettings.tsx +++ b/apps/web/src/components/settings/AddSesSettings.tsx @@ -18,9 +18,10 @@ import { Button } from "@usesend/ui/src/button"; import Spinner from "@usesend/ui/src/spinner"; import { toast } from "@usesend/ui/src/toaster"; import { isLocalhost } from "~/utils/client"; +import { sesRegionSchema } from "~/lib/zod/ses-setting-schema"; const FormSchema = z.object({ - region: z.string(), + region: sesRegionSchema, usesendUrl: z.string().url(), sendRate: z.coerce.number(), transactionalQuota: z.coerce.number().min(0).max(100), @@ -48,14 +49,51 @@ export const AddSesSettings: React.FC = ({ onSuccess }) => { export const AddSesSettingsForm: React.FC = ({ onSuccess, }) => { - const addSesSettings = api.admin.addSesSettings.useMutation(); + const defaultRegion = api.admin.getDefaultSesRegion.useQuery(); + + if (defaultRegion.isLoading) { + return ( +
+ +
+ ); + } + + if (!defaultRegion.data) { + return ( +
+

+ Failed to load the default AWS region. +

+ +
+ ); + } + return ( + + ); +}; + +const SesSettingsForm: React.FC< + SesSettingsProps & { defaultRegion: string } +> = ({ defaultRegion, onSuccess }) => { + const addSesSettings = api.admin.addSesSettings.useMutation(); const utils = api.useUtils(); const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { - region: "", + region: defaultRegion, usesendUrl: "", sendRate: 1, transactionalQuota: 50, @@ -101,11 +139,22 @@ export const AddSesSettingsForm: React.FC = ({ } const onRegionInputOutOfFocus = async () => { - const region = form.getValues("region"); + const region = sesRegionSchema.safeParse(form.getValues("region")); + + if (region.success) { + form.clearErrors("region"); - if (region) { - const quota = await utils.admin.getQuotaForRegion.fetch({ region }); - form.setValue("sendRate", quota ?? 1); + try { + const quota = await utils.admin.getQuotaForRegion.fetch({ + region: region.data, + }); + form.setValue("sendRate", quota ?? 1); + } catch { + form.setValue("sendRate", 1); + form.setError("region", { + message: "Unable to load the SES quota for this region", + }); + } } }; @@ -113,7 +162,7 @@ export const AddSesSettingsForm: React.FC = ({
region.trim()).filter(Boolean))]; +} diff --git a/apps/web/src/lib/zod/ses-setting-schema.unit.test.ts b/apps/web/src/lib/zod/ses-setting-schema.unit.test.ts new file mode 100644 index 00000000..8bcef77d --- /dev/null +++ b/apps/web/src/lib/zod/ses-setting-schema.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + getValidSesRegions, + sesRegionSchema, +} from "~/lib/zod/ses-setting-schema"; + +describe("sesRegionSchema", () => { + it("rejects empty regions", () => { + expect(sesRegionSchema.safeParse("").success).toBe(false); + expect(sesRegionSchema.safeParse(" ").success).toBe(false); + }); + + it("normalizes valid regions", () => { + expect(sesRegionSchema.parse(" us-east-1 ")).toBe("us-east-1"); + }); +}); + +describe("getValidSesRegions", () => { + it("filters legacy empty regions and removes duplicates", () => { + expect( + getValidSesRegions(["", " ", "us-east-1", " us-east-1 ", "eu-west-1"]), + ).toEqual(["us-east-1", "eu-west-1"]); + }); +}); diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts index 4ca82b2a..33b3ab08 100644 --- a/apps/web/src/server/api/routers/admin.ts +++ b/apps/web/src/server/api/routers/admin.ts @@ -11,6 +11,7 @@ import { logger } from "~/server/logger/log"; import { UseSend } from "usesend-js"; import { isCloud } from "~/utils/common"; import { toPlainHtml } from "~/server/utils/email-content"; +import { sesRegionSchema } from "~/lib/zod/ses-setting-schema"; const waitlistUserSelection = { id: true, @@ -67,10 +68,12 @@ export const adminRouter = createTRPCRouter({ return SesSettingsService.getAllSettings(); }), + getDefaultSesRegion: adminProcedure.query(() => env.AWS_DEFAULT_REGION), + getQuotaForRegion: adminProcedure .input( z.object({ - region: z.string(), + region: sesRegionSchema, }), ) .query(async ({ input }) => { @@ -81,7 +84,7 @@ export const adminRouter = createTRPCRouter({ addSesSettings: adminProcedure .input( z.object({ - region: z.string(), + region: sesRegionSchema, usesendUrl: z.string().url(), sendRate: z.number(), transactionalQuota: z.number(), diff --git a/apps/web/src/server/api/routers/domain.ts b/apps/web/src/server/api/routers/domain.ts index 848d24aa..33c8fb9b 100644 --- a/apps/web/src/server/api/routers/domain.ts +++ b/apps/web/src/server/api/routers/domain.ts @@ -16,15 +16,19 @@ import { } from "~/server/service/domain-service"; import { sendEmail } from "~/server/service/email-service"; import { SesSettingsService } from "~/server/service/ses-settings-service"; +import { + getValidSesRegions, + sesRegionSchema, +} from "~/lib/zod/ses-setting-schema"; export const domainRouter = createTRPCRouter({ getAvailableRegions: protectedProcedure.query(async () => { const settings = await SesSettingsService.getAllSettings(); - return settings.map((setting) => setting.region); + return getValidSesRegions(settings.map((setting) => setting.region)); }), createDomain: teamProcedure - .input(z.object({ name: z.string(), region: z.string() })) + .input(z.object({ name: z.string(), region: sesRegionSchema })) .mutation(async ({ ctx, input }) => { return createDomain( ctx.team.id, diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index 7402d713..ba90a7a6 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -7,6 +7,7 @@ import { EventType } from "@aws-sdk/client-sesv2"; import { EmailQueueService } from "./email-queue-service"; import { smallNanoid } from "../nanoid"; import { logger } from "../logger/log"; +import { sesRegionSchema } from "~/lib/zod/ses-setting-schema"; const GENERAL_EVENTS: EventType[] = [ "BOUNCE", @@ -27,10 +28,12 @@ export class SesSettingsService { public static async getSetting( region = env.AWS_DEFAULT_REGION ): Promise { + const normalizedRegion = sesRegionSchema.parse(region); + await this.checkInitialized(); - if (this.cache[region]) { - return this.cache[region] as SesSetting; + if (this.cache[normalizedRegion]) { + return this.cache[normalizedRegion] as SesSetting; } return null; } @@ -62,6 +65,8 @@ export class SesSettingsService { sendingRateLimit: number; transactionalQuota: number; }) { + region = sesRegionSchema.parse(region); + await this.checkInitialized(); if (this.cache[region]) { throw new Error(`SesSetting for region ${region} already exists`);