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) {