Skip to content
Merged

fix #46

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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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

### Fixed
- 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
- **Node.js SDK 0.1.4**: package metadata points at the new `ByteSendApp/ByteSend` repository, and releases publish from it with provenance

---

## [0.4.3] - 2026-10-19
Expand Down
145 changes: 145 additions & 0 deletions apps/web/src/app/(dashboard)/admin/ses/callback-diagnostics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"use client";

import { Button } from "@bytesend/ui/src/button";
import Spinner from "@bytesend/ui/src/spinner";
import { toast } from "@bytesend/ui/src/toaster";
import { formatDistanceToNow } from "date-fns";
import { RefreshCw } from "lucide-react";
import { api } from "~/trpc/react";
import { StatusPill, type StatusTone } from "../_components/status-pill";

const SUBSCRIPTION_TONE: Record<string, { tone: StatusTone; label: string }> = {
confirmed: { tone: "success", label: "Subscribed" },
pending: { tone: "warning", label: "Pending confirmation" },
missing: { tone: "destructive", label: "Not subscribed" },
error: { tone: "destructive", label: "Lookup failed" },
};

export default function CallbackDiagnostics() {
const utils = api.useUtils();
const diagnosticsQuery = api.admin.getSesCallbackDiagnostics.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const resubscribe = api.admin.resubscribeSesCallback.useMutation({
onSuccess: async (result) => {
toast.success("Resubscribed", {
description: `AWS will send a confirmation to ${result.callbackUrl}. Re-check in a few seconds.`,
});
await Promise.all([utils.admin.getSesCallbackDiagnostics.invalidate(), utils.admin.getSesSettings.invalidate()]);
},
onError: (error) => toast.error("Resubscribe failed", { description: error.message }),
});

const data = diagnosticsQuery.data;

return (
<section className="mt-8 space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-base font-semibold">Delivery callbacks</h2>
<p className="text-sm text-muted-foreground">
Delivered, bounced, opened and clicked statuses only arrive when SES can reach this site through SNS.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => diagnosticsQuery.refetch()}
disabled={diagnosticsQuery.isFetching}
>
<RefreshCw className={`mr-2 h-4 w-4 ${diagnosticsQuery.isFetching ? "animate-spin" : ""}`} />
Re-check
</Button>
</div>

{data?.lastRejected && (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-destructive">Callbacks are being rejected</p>
<p className="mt-1 text-muted-foreground">
SNS posted from <code className="font-mono text-xs">{data.lastRejected.topicArn}</code>{" "}
{formatDistanceToNow(new Date(data.lastRejected.at), { addSuffix: true })}, which doesn&apos;t match any
region below. Those events were dropped.
</p>
</div>
)}

{diagnosticsQuery.isLoading ? (
<div className="flex h-32 items-center justify-center rounded-xl border">
<Spinner className="h-6 w-6" innerSvgClass="stroke-primary" />
</div>
) : diagnosticsQuery.error ? (
<p className="rounded-xl border p-4 text-sm text-destructive">{diagnosticsQuery.error.message}</p>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{data?.regions.map((region) => {
const subscription = SUBSCRIPTION_TONE[region.subscription.state]!;
const healthy = region.problems.length === 0;
return (
<div key={region.settingId} className="rounded-xl border border-border/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-sm font-medium">{region.region}</span>
<StatusPill tone={healthy ? "success" : "destructive"}>
{healthy ? "Healthy" : `${region.problems.length} problem${region.problems.length === 1 ? "" : "s"}`}
</StatusPill>
</div>

<dl className="mt-4 space-y-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">SNS subscription</dt>
<dd>
<StatusPill tone={subscription.tone}>{subscription.label}</StatusPill>
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Event destinations</dt>
<dd className="tabular-nums">
{region.configSets.filter((configSet) => configSet.ok).length} / {region.configSets.length} OK
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Last callback</dt>
<dd>
{region.lastReceivedAt
? formatDistanceToNow(new Date(region.lastReceivedAt), { addSuffix: true })
: "None recorded"}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="shrink-0 text-muted-foreground">Callback URL</dt>
<dd className="truncate font-mono text-xs" title={region.callbackUrl}>
{region.callbackUrl}
</dd>
</div>
</dl>

{!healthy && (
<ul className="mt-4 space-y-1.5 border-t border-border/60 pt-3 text-xs text-muted-foreground">
{region.problems.map((problem) => (
<li key={problem} className="flex gap-2">
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-destructive" />
<span className="break-all">{problem}</span>
</li>
))}
</ul>
)}

{region.subscription.state !== "confirmed" || region.callbackUrl !== region.expectedCallbackUrl ? (
<Button
size="sm"
className="mt-4 w-full"
onClick={() => resubscribe.mutate({ settingsId: region.settingId })}
disabled={resubscribe.isPending}
>
{resubscribe.isPending && resubscribe.variables?.settingsId === region.settingId
? "Resubscribing…"
: `Resubscribe ${region.expectedCallbackUrl}`}
</Button>
) : null}
</div>
);
})}
</div>
)}
</section>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/app/(dashboard)/admin/ses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { AdminPageHeader } from "../_components/admin-page-header";
import AddSesConfiguration from "./add-ses-configuration";
import CallbackDiagnostics from "./callback-diagnostics";
import SesConfigurations from "./ses-configurations";

export default function AdminSesPage() {
Expand All @@ -13,6 +14,7 @@ export default function AdminSesPage() {
actions={<AddSesConfiguration />}
/>
<SesConfigurations />
<CallbackDiagnostics />
</>
);
}
6 changes: 6 additions & 0 deletions apps/web/src/app/api/ses_callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { db } from "~/server/db";
import { logger } from "~/server/logger/log";
import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser";
import { SesCallbackTracker } from "~/server/service/ses-callback-tracker";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { SnsNotificationMessage } from "~/types/aws-types";

Expand Down Expand Up @@ -29,9 +30,14 @@ export async function POST(req: Request) {
logger.debug({ isEventValid }, "SES callback topic validation result");

if (!isEventValid) {
if (typeof data?.TopicArn === "string") {
await SesCallbackTracker.recordRejected(data.TopicArn);
}
return Response.json({ data: "Event is not valid" });
}

await SesCallbackTracker.recordReceived(trustedTopicArn);

if (data.Type === "SubscriptionConfirmation") {
return handleSubscription(data, trustedTopicArn);
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/constants/admin-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +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",
"user.beta.update": "Changed beta access",
"user.admin.update": "Changed admin role",
"user.ban": "Banned user",
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/server/api/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { createCheckoutSessionForTeam, type CheckoutPlan } from "~/server/billin
import { TeamService } from "~/server/service/team-service";
import { AdminOverviewService } from "~/server/service/admin-overview-service";
import { AdminAuditService, diffFields } from "~/server/service/admin-audit-service";
import { SesCallbackDiagnosticsService } from "~/server/service/ses-callback-diagnostics-service";
import { AdminDeliverabilityService } from "~/server/service/admin-deliverability-service";
import { AdminDetailService } from "~/server/service/admin-detail-service";
import { BlacklistService } from "~/server/service/blacklist-service";
Expand Down Expand Up @@ -458,6 +459,22 @@ export const adminRouter = createTRPCRouter({
return result;
}),

getSesCallbackDiagnostics: adminProcedure.query(async () => SesCallbackDiagnosticsService.diagnose()),

resubscribeSesCallback: adminProcedure
.input(z.object({ settingsId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await SesCallbackDiagnosticsService.resubscribe(input.settingsId);
await AdminAuditService.record({
actor: actorFrom(ctx),
action: "ses.resubscribe",
targetType: "ses",
targetId: input.settingsId,
metadata: { from: result.previousCallbackUrl, to: result.callbackUrl },
});
return result;
}),

getSetting: adminProcedure
.input(
z.object({
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/server/aws/ses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SendEmailCommand,
CreateConfigurationSetEventDestinationCommand,
CreateConfigurationSetCommand,
GetConfigurationSetEventDestinationsCommand,
EventType,
GetAccountCommand,
CreateTenantResourceAssociationCommand,
Expand Down Expand Up @@ -352,6 +353,19 @@ export async function addWebhookConfiguration(
return response.$metadata.httpStatusCode === 200;
}

export async function getConfigurationSetEventDestinations(configName: string, region: string) {
const sesClient = getSesClient(region);
const response = await sesClient.send(
new GetConfigurationSetEventDestinationsCommand({ ConfigurationSetName: configName }),
);
return (response.EventDestinations ?? []).map((destination) => ({
name: destination.Name ?? null,
enabled: destination.Enabled ?? false,
topicArn: destination.SnsDestination?.TopicArn ?? null,
eventTypes: destination.MatchingEventTypes ?? [],
}));
}

export async function deleteFromSesSuppressionList(
email: string,
region: string
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/server/aws/sns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CreateTopicCommand,
SubscribeCommand,
DeleteTopicCommand,
ListSubscriptionsByTopicCommand,
} from "@aws-sdk/client-sns";
import { env } from "~/env";

Expand Down Expand Up @@ -48,3 +49,14 @@ export async function subscribeEndpoint(
const data = await client.send(subscribeCommand);
return data.SubscriptionArn;
}

export async function listTopicSubscriptions(topicArn: string, region: string) {
const client = getSnsClient(region);
const data = await client.send(new ListSubscriptionsByTopicCommand({ TopicArn: topicArn }));
return (data.Subscriptions ?? []).map((subscription) => ({
endpoint: subscription.Endpoint ?? null,
protocol: subscription.Protocol ?? null,
// SNS reports "PendingConfirmation" here until the endpoint confirms the subscription.
subscriptionArn: subscription.SubscriptionArn ?? null,
}));
}
18 changes: 17 additions & 1 deletion apps/web/src/server/service/email-queue-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,11 +476,27 @@ async function executeEmail(job: QueueEmailJob) {
data: { sesEmailId: messageId, text, attachments: null, headers: null },
});

await db.email.updateMany({
const { count: markedSent } = await db.email.updateMany({
where: { id: email.id, latestStatus: { in: ["QUEUED", "SCHEDULED"] } },
data: { latestStatus: "SENT" },
});

// Counted here rather than from SES's Send event so sending totals don't depend on SNS
// callbacks arriving; markedSent guards against counting a retried job twice.
if (markedSent > 0) {
const usageKey = {
teamId: email.teamId,
domainId: email.domainId ?? 0,
date: new Date().toISOString().slice(0, 10),
type: email.campaignId ? ("MARKETING" as const) : ("TRANSACTIONAL" as const),
};
await db.dailyEmailUsage.upsert({
where: { teamId_domainId_date_type: usageKey },
create: { ...usageKey, sent: 1 },
update: { sent: { increment: 1 } },
});
}

} catch (error: any) {
if (isSesTransientError(error)) {
logger.warn(
Expand Down
Loading
Loading