Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions apps/web/src/app/(dashboard)/admin/ses/callback-diagnostics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,18 +125,16 @@ export default function CallbackDiagnostics() {
</ul>
)}

{region.subscription.state !== "confirmed" || region.callbackUrl !== region.expectedCallbackUrl ? (
{!healthy && (
<Button
size="sm"
className="mt-4 w-full"
onClick={() => resubscribe.mutate({ settingsId: region.settingId })}
disabled={resubscribe.isPending}
onClick={() => repair.mutate({ settingsId: region.settingId })}
disabled={repair.isPending}
>
{resubscribe.isPending && resubscribe.variables?.settingsId === region.settingId
? "Resubscribing…"
: `Resubscribe ${region.expectedCallbackUrl}`}
{repair.isPending && repair.variables?.settingsId === region.settingId ? "Repairing…" : "Repair"}
</Button>
) : null}
)}
</div>
);
})}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/settings/AddSesSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
name="bytesendUrl"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Callback URL</FormLabel>
<FormLabel>Site URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
placeholder="https://bytesend.cloud"
className="w-full"
{...field}
/>
Expand All @@ -159,8 +159,8 @@ export const AddSesSettingsForm: React.FC<SesSettingsProps> = ({
<FormMessage />
) : (
<FormDescription>
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.
</FormDescription>
)}
</FormItem>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/constants/admin-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/server/api/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/server/aws/ses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand,
GetConfigurationSetEventDestinationsCommand,
UpdateConfigurationSetEventDestinationCommand,
EventType,
GetAccountCommand,
CreateTenantResourceAssociationCommand,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 39 additions & 15 deletions apps/web/src/server/service/ses-callback-diagnostics-service.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<RegionCallbackDiagnosis["subscription"]> {
Expand Down Expand Up @@ -77,7 +84,7 @@ async function diagnoseSubscription(setting: SesSetting): Promise<RegionCallback
}
}

async function diagnoseConfigSet(setting: SesSetting, name: string): Promise<ConfigSetDiagnosis> {
async function diagnoseConfigSet(setting: SesSetting, { name, eventTypes }: ConfigSet): Promise<ConfigSetDiagnosis> {
try {
const destinations = await getConfigurationSetEventDestinations(name, setting.region);
const destination = destinations.find((candidate) => candidate.topicArn === setting.topicArn);
Expand All @@ -87,8 +94,9 @@ async function diagnoseConfigSet(setting: SesSetting, name: string): Promise<Con
if (!destination.enabled) {
return { name, ok: false, detail: "The event destination is disabled" };
}
if (!destination.eventTypes.includes("DELIVERY")) {
return { name, ok: false, detail: "The event destination doesn't publish DELIVERY events" };
const missing = eventTypes.filter((eventType) => !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) {
Expand All @@ -100,7 +108,7 @@ async function diagnoseRegion(setting: SesSetting): Promise<RegionCallbackDiagno
const expected = expectedCallbackUrl();
const [subscription, configSets, lastReceivedAt] = await Promise.all([
diagnoseSubscription(setting),
Promise.all(configSetNames(setting).map((name) => diagnoseConfigSet(setting, name))),
Promise.all(configSetsFor(setting).map((configSet) => diagnoseConfigSet(setting, configSet))),
setting.topicArn ? SesCallbackTracker.lastReceived(setting.topicArn) : Promise.resolve(null),
]);

Expand Down Expand Up @@ -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 };
},
};
7 changes: 3 additions & 4 deletions apps/web/src/server/service/ses-settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
Loading