diff --git a/CHANGELOG.md b/CHANGELOG.md index fb25f1c..9ef627a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +--- + +## [0.4.4] - 2026-10-19 + ### Added -- **Delivery callback diagnostics** on Admin → SES regions: per region, the SNS subscription state and endpoint, each configuration set's event destination, when the last callback arrived, and any callbacks rejected for an unknown topic, with one-click resubscribe to this site's callback URL +- **Delivery callback diagnostics** on Admin → SES regions: per region, the SNS subscription state and endpoint, each configuration set's event destination, when the last callback arrived, and any callbacks rejected for an unknown topic, with a one-click **Repair** that resubscribes this site's callback URL and re-enables or re-points broken event destinations ### Fixed +- Adding an SES region with the full callback URL (as the old "Callback URL" label suggested) no longer produces `/api/ses_callback/api/ses_callback`; the field is now labelled "Site URL" and either form is accepted - Sent counts in analytics no longer depend on SES callbacks: an email is counted when ByteSend hands it to SES, so totals stay accurate even if SNS notifications stop arriving ### Changed @@ -911,7 +916,8 @@ Initial public beta release of ByteSend: an all-in-one email infrastructure plat --- -[Unreleased]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.3...HEAD +[Unreleased]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.4...HEAD +[0.4.4]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.3...v0.4.4 [0.4.3]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/ByteSendApp/ByteSend/compare/v0.4.0...v0.4.1 diff --git a/apps/web/src/app/(dashboard)/admin/ses/callback-diagnostics.tsx b/apps/web/src/app/(dashboard)/admin/ses/callback-diagnostics.tsx index 3aed308..2de8233 100644 --- a/apps/web/src/app/(dashboard)/admin/ses/callback-diagnostics.tsx +++ b/apps/web/src/app/(dashboard)/admin/ses/callback-diagnostics.tsx @@ -20,14 +20,16 @@ export default function CallbackDiagnostics() { const diagnosticsQuery = api.admin.getSesCallbackDiagnostics.useQuery(undefined, { refetchOnWindowFocus: false, }); - const resubscribe = api.admin.resubscribeSesCallback.useMutation({ + const repair = api.admin.repairSesCallbacks.useMutation({ onSuccess: async (result) => { - toast.success("Resubscribed", { - description: `AWS will send a confirmation to ${result.callbackUrl}. Re-check in a few seconds.`, + toast.success(result.actions.length ? "Repaired" : "Nothing to repair", { + description: result.actions.length + ? `${result.actions.join(". ")}. Re-check in a few seconds.` + : "Everything already looks correct in AWS.", }); await Promise.all([utils.admin.getSesCallbackDiagnostics.invalidate(), utils.admin.getSesSettings.invalidate()]); }, - onError: (error) => toast.error("Resubscribe failed", { description: error.message }), + onError: (error) => toast.error("Repair failed", { description: error.message }), }); const data = diagnosticsQuery.data; @@ -123,18 +125,16 @@ export default function CallbackDiagnostics() { )} - {region.subscription.state !== "confirmed" || region.callbackUrl !== region.expectedCallbackUrl ? ( + {!healthy && ( - ) : null} + )} ); })} diff --git a/apps/web/src/components/settings/AddSesSettings.tsx b/apps/web/src/components/settings/AddSesSettings.tsx index c6d1723..27ea46e 100644 --- a/apps/web/src/components/settings/AddSesSettings.tsx +++ b/apps/web/src/components/settings/AddSesSettings.tsx @@ -147,10 +147,10 @@ export const AddSesSettingsForm: React.FC = ({ name="bytesendUrl" render={({ field, formState }) => ( - Callback URL + Site URL @@ -159,8 +159,8 @@ export const AddSesSettingsForm: React.FC = ({ ) : ( - This url should be accessible from the internet. Will be - called from SES + The public URL of this ByteSend site. SES events are sent to + /api/ses_callback on it. )} diff --git a/apps/web/src/lib/constants/admin-audit.ts b/apps/web/src/lib/constants/admin-audit.ts index 3d522cd..b0b9f2c 100644 --- a/apps/web/src/lib/constants/admin-audit.ts +++ b/apps/web/src/lib/constants/admin-audit.ts @@ -3,7 +3,7 @@ export const ADMIN_AUDIT_ACTIONS = { "ses.update": "Updated SES region", "ses.delete": "Deleted SES region", "ses.toggle": "Toggled SES region", - "ses.resubscribe": "Resubscribed SES callback", + "ses.repair": "Repaired SES callbacks", "user.beta.update": "Changed beta access", "user.admin.update": "Changed admin role", "user.ban": "Banned user", diff --git a/apps/web/src/server/api/routers/admin.ts b/apps/web/src/server/api/routers/admin.ts index b3800d9..2dfee2f 100644 --- a/apps/web/src/server/api/routers/admin.ts +++ b/apps/web/src/server/api/routers/admin.ts @@ -461,16 +461,16 @@ export const adminRouter = createTRPCRouter({ getSesCallbackDiagnostics: adminProcedure.query(async () => SesCallbackDiagnosticsService.diagnose()), - resubscribeSesCallback: adminProcedure + repairSesCallbacks: adminProcedure .input(z.object({ settingsId: z.string() })) .mutation(async ({ ctx, input }) => { - const result = await SesCallbackDiagnosticsService.resubscribe(input.settingsId); + const result = await SesCallbackDiagnosticsService.repair(input.settingsId); await AdminAuditService.record({ actor: actorFrom(ctx), - action: "ses.resubscribe", + action: "ses.repair", targetType: "ses", targetId: input.settingsId, - metadata: { from: result.previousCallbackUrl, to: result.callbackUrl }, + metadata: { actions: result.actions }, }); return result; }), diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 51f7148..dfebd89 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -9,6 +9,7 @@ import { CreateConfigurationSetEventDestinationCommand, CreateConfigurationSetCommand, GetConfigurationSetEventDestinationsCommand, + UpdateConfigurationSetEventDestinationCommand, EventType, GetAccountCommand, CreateTenantResourceAssociationCommand, @@ -366,6 +367,50 @@ export async function getConfigurationSetEventDestinations(configName: string, r })); } +/** + * Makes the ByteSend event destination on a configuration set enabled, pointed at `topicArn` and + * publishing `eventTypes`, creating the destination (or the whole configuration set) if it's gone. + */ +export async function repairConfigurationSetEventDestination( + configName: string, + topicArn: string, + eventTypes: EventType[], + region: string, +) { + const sesClient = getSesClient(region); + const eventDestination = { + Enabled: true, + MatchingEventTypes: eventTypes, + SnsDestination: { TopicArn: topicArn }, + }; + + try { + await sesClient.send( + new UpdateConfigurationSetEventDestinationCommand({ + ConfigurationSetName: configName, + EventDestinationName: "bytesend_destination", + EventDestination: eventDestination, + }), + ); + return; + } catch (error) { + if ((error as { name?: string }).name !== "NotFoundException") throw error; + } + + try { + await sesClient.send( + new CreateConfigurationSetEventDestinationCommand({ + ConfigurationSetName: configName, + EventDestinationName: "bytesend_destination", + EventDestination: eventDestination, + }), + ); + } catch (error) { + if ((error as { name?: string }).name !== "NotFoundException") throw error; + await addWebhookConfiguration(configName, topicArn, eventTypes, region); + } +} + export async function deleteFromSesSuppressionList( email: string, region: string diff --git a/apps/web/src/server/service/ses-callback-diagnostics-service.ts b/apps/web/src/server/service/ses-callback-diagnostics-service.ts index 5ba9bec..dfee765 100644 --- a/apps/web/src/server/service/ses-callback-diagnostics-service.ts +++ b/apps/web/src/server/service/ses-callback-diagnostics-service.ts @@ -1,11 +1,12 @@ import type { SesSetting } from "@prisma/client"; import { env } from "~/env"; import { db } from "../db"; -import { getConfigurationSetEventDestinations } from "../aws/ses"; +import type { EventType } from "@aws-sdk/client-sesv2"; +import { getConfigurationSetEventDestinations, repairConfigurationSetEventDestination } from "../aws/ses"; import { listTopicSubscriptions, subscribeEndpoint } from "../aws/sns"; import { logger } from "../logger/log"; import { SesCallbackTracker, type RejectedCallback } from "./ses-callback-tracker"; -import { SesSettingsService } from "./ses-settings-service"; +import { GENERAL_EVENTS, SesSettingsService } from "./ses-settings-service"; export type SubscriptionState = "confirmed" | "pending" | "missing" | "error"; @@ -36,10 +37,16 @@ function expectedCallbackUrl() { return `${env.NEXTAUTH_URL.replace(/\/$/, "")}/api/ses_callback`; } -function configSetNames(setting: SesSetting) { - return [setting.configGeneral, setting.configClick, setting.configOpen, setting.configFull].filter( - (name): name is string => Boolean(name), - ); +type ConfigSet = { name: string; eventTypes: EventType[] }; + +// Mirrors the event types each configuration set is created with in SesSettingsService. +function configSetsFor(setting: SesSetting): ConfigSet[] { + return [ + { name: setting.configGeneral, eventTypes: GENERAL_EVENTS }, + { name: setting.configClick, eventTypes: [...GENERAL_EVENTS, "CLICK"] }, + { name: setting.configOpen, eventTypes: [...GENERAL_EVENTS, "OPEN"] }, + { name: setting.configFull, eventTypes: [...GENERAL_EVENTS, "CLICK", "OPEN"] }, + ].filter((configSet): configSet is ConfigSet => Boolean(configSet.name)); } async function diagnoseSubscription(setting: SesSetting): Promise { @@ -77,7 +84,7 @@ async function diagnoseSubscription(setting: SesSetting): Promise { +async function diagnoseConfigSet(setting: SesSetting, { name, eventTypes }: ConfigSet): Promise { try { const destinations = await getConfigurationSetEventDestinations(name, setting.region); const destination = destinations.find((candidate) => candidate.topicArn === setting.topicArn); @@ -87,8 +94,9 @@ async function diagnoseConfigSet(setting: SesSetting, name: string): Promise !destination.eventTypes.includes(eventType)); + if (missing.length > 0) { + return { name, ok: false, detail: `The event destination doesn't publish ${missing.join(", ")}` }; } return { name, ok: true, detail: destination.eventTypes.join(", ") }; } catch (error) { @@ -100,7 +108,7 @@ async function diagnoseRegion(setting: SesSetting): Promise diagnoseConfigSet(setting, name))), + Promise.all(configSetsFor(setting).map((configSet) => diagnoseConfigSet(setting, configSet))), setting.topicArn ? SesCallbackTracker.lastReceived(setting.topicArn) : Promise.resolve(null), ]); @@ -138,23 +146,39 @@ export const SesCallbackDiagnosticsService = { }, /** - * Points the region's callback at this site's URL and (re)subscribes it to the SNS topic. - * SNS then sends a SubscriptionConfirmation, which /api/ses_callback confirms automatically. + * Fixes whatever diagnose() flags for a region: points the callback at this site and resubscribes + * it (SNS then sends a SubscriptionConfirmation that /api/ses_callback confirms), and re-enables or + * re-points any configuration set event destination that isn't publishing to the region's topic. */ - async resubscribe(settingId: string) { + async repair(settingId: string) { const setting = await db.sesSetting.findUnique({ where: { id: settingId } }); if (!setting?.topicArn) throw new Error("SES setting or SNS topic not found"); const callbackUrl = expectedCallbackUrl(); + const actions: string[] = []; + if (setting.callbackUrl !== callbackUrl) { await db.sesSetting.update({ where: { id: setting.id }, data: { callbackUrl, callbackSuccess: false }, }); await SesSettingsService.invalidateCache(); + actions.push(`Callback URL changed from ${setting.callbackUrl} to ${callbackUrl}`); + } + + const subscription = await diagnoseSubscription({ ...setting, callbackUrl }); + if (subscription.state !== "confirmed") { + await subscribeEndpoint(setting.topicArn, callbackUrl, setting.region); + actions.push(`Subscribed ${callbackUrl} to the SNS topic`); + } + + for (const configSet of configSetsFor(setting)) { + const diagnosis = await diagnoseConfigSet(setting, configSet); + if (diagnosis.ok) continue; + await repairConfigurationSetEventDestination(configSet.name, setting.topicArn, configSet.eventTypes, setting.region); + actions.push(`Repaired ${configSet.name} (${diagnosis.detail})`); } - const subscriptionArn = await subscribeEndpoint(setting.topicArn, callbackUrl, setting.region); - return { previousCallbackUrl: setting.callbackUrl, callbackUrl, subscriptionArn: subscriptionArn ?? null }; + return { actions }; }, }; diff --git a/apps/web/src/server/service/ses-settings-service.ts b/apps/web/src/server/service/ses-settings-service.ts index 51b5eec..38197fd 100644 --- a/apps/web/src/server/service/ses-settings-service.ts +++ b/apps/web/src/server/service/ses-settings-service.ts @@ -8,7 +8,7 @@ import { EmailQueueService } from "./email-queue-service"; import { smallNanoid } from "../nanoid"; import { logger } from "../logger/log"; -const GENERAL_EVENTS: EventType[] = [ +export const GENERAL_EVENTS: EventType[] = [ "BOUNCE", "COMPLAINT", "DELIVERY", @@ -61,9 +61,8 @@ export class SesSettingsService { throw new Error(`SesSetting for region ${region} already exists`); } - const parsedUrl = bytesendUrl.endsWith("/") - ? bytesendUrl.substring(0, bytesendUrl.length - 1) - : bytesendUrl; + // Accept either the site URL or the full callback URL; the callback path is appended below. + const parsedUrl = bytesendUrl.replace(/\/+$/, "").replace(/\/api\/ses_callback$/, ""); if (!env.NEXT_PUBLIC_IS_CLOUD) { const bytesendUrlValidation = await isValidBytesendUrl(parsedUrl); if (!bytesendUrlValidation.isValid) {