Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Domain" ADD COLUMN "mailFromLabel" TEXT;
2 changes: 2 additions & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ model Domain {
dmarcAdded Boolean @default(false)
errorMessage String?
subdomain String?
/// Optional first label for custom MAIL FROM (e.g. "bounce"); full host is `{label}.{name}`. Null means use `region` as the label (e.g. us-east-1.example.com).
mailFromLabel String?
sesTenantId String?
isVerifying Boolean @default(false)
createdAt DateTime @default(now())
Expand Down
14 changes: 9 additions & 5 deletions apps/web/src/app/(dashboard)/admin/teams/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
SelectValue,
} from "@usesend/ui/src/select";
import { formatDistanceToNow } from "date-fns";
import { aggregateDomainStatus } from "~/lib/domain-aggregate-status";

import { api } from "~/trpc/react";
import type { AppRouter } from "~/server/api/root";
Expand Down Expand Up @@ -223,19 +224,22 @@ export default function AdminTeamsPage() {
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
{team.domains.length ? (
team.domains.map((domain) => (
team.domains.map((domain) => {
const agg = aggregateDomainStatus(domain);
return (
<div
key={domain.id}
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
>
<span>{domain.name}</span>
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
{domain.status === "SUCCESS"
<Badge variant={agg === "SUCCESS" ? "outline" : "secondary"}>
{agg === "SUCCESS"
? "Verified"
: domain.status.toLowerCase()}
: agg.toLowerCase()}
</Badge>
Comment on lines +227 to 239
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aggregateDomainStatus(domain) here is computed from team.domains, but the admin API selection for domains (see teamAdminSelection in apps/web/src/server/api/routers/admin.ts) only includes status/isVerifying and does not include dkimStatus/spfDetails. With missing fields, aggregateDomainStatus treats them as NOT_STARTED, so even a SUCCESS domain will display as not_started. Fix by including dkimStatus and spfDetails (and any other required fields) in the admin query selection, or by using domain.status directly when those fields aren’t present.

Copilot uses AI. Check for mistakes.
</div>
))
);
})
) : (
<p className="text-xs text-muted-foreground">No domains connected.</p>
)}
Expand Down
112 changes: 110 additions & 2 deletions apps/web/src/app/(dashboard)/domains/[domainId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Switch } from "@usesend/ui/src/switch";
import DeleteDomain from "./delete-domain";
import SendTestMail from "./send-test-mail";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import Link from "next/link";
import { toast } from "@usesend/ui/src/toaster";
import type { inferRouterOutputs } from "@trpc/server";
Expand Down Expand Up @@ -94,7 +95,11 @@ export default function DomainItemPage({

<div className="">
<DomainStatusBadge
status={domainQuery.data?.status || DomainStatus.NOT_STARTED}
status={
domainQuery.data?.aggregateStatus ??
domainQuery.data?.status ??
DomainStatus.NOT_STARTED
}
/>
</div>
</div>
Expand All @@ -103,7 +108,8 @@ export default function DomainItemPage({
<Button variant="outline" onClick={handleVerify}>
{domainQuery.data?.isVerifying
? "Verifying..."
: domainQuery.data?.status === DomainStatus.SUCCESS
: (domainQuery.data?.aggregateStatus ??
domainQuery.data?.status) === DomainStatus.SUCCESS
? "Verify again"
: "Verify domain"}
</Button>
Expand Down Expand Up @@ -175,12 +181,23 @@ export default function DomainItemPage({

const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
const updateDomain = api.domain.updateDomain.useMutation();
const setMailFromLabelMutation = api.domain.setMailFromLabel.useMutation();
const utils = api.useUtils();

const [clickTracking, setClickTracking] = React.useState(
domain.clickTracking,
);
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
const [mailFromDraft, setMailFromDraft] = React.useState(
domain.mailFromLabel ?? "",
);

const effectiveMailFromLabel =
domain.mailFromLabel?.trim() || domain.region;

React.useEffect(() => {
setMailFromDraft(domain.mailFromLabel ?? "");
}, [domain.mailFromLabel]);

function handleClickTrackingChange() {
setClickTracking(!clickTracking);
Expand Down Expand Up @@ -210,6 +227,97 @@ const DomainSettings: React.FC<{ domain: DomainResponse }> = ({ domain }) => {
return (
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
<p className="font-semibold text-xl">Settings</p>

<div className="flex flex-col gap-3 border-b border-border pb-6">
<div className="font-semibold">MAIL FROM label</div>
<p className="text-muted-foreground text-sm">
The MX and SPF rows in the DNS table use this hostname label. By
default it matches your SES region (
<span className="font-mono text-xs">{domain.region}</span>). You can
set a custom label (for example{" "}
<span className="font-mono text-xs">bounce</span>) — a single DNS
label, letters, digits, and hyphens only. Saving updates Amazon SES;
then update DNS and run Verify.
</p>
<p className="text-muted-foreground text-sm">
Tip: add the new MX and SPF records at your DNS provider{" "}
<strong>before</strong> you save a new label here. That way SES can
verify as soon as you save, and you avoid a temporary “not verified”
state.
</p>
<p className="text-sm">
<span className="text-muted-foreground">Effective label:</span>{" "}
<span className="font-mono text-xs">{effectiveMailFromLabel}</span>
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end sm:gap-3">
<div className="flex flex-col gap-1 flex-1 min-w-[200px]">
<span className="text-xs text-muted-foreground">
Custom label (optional)
</span>
<Input
placeholder={domain.region}
value={mailFromDraft}
onChange={(e) => setMailFromDraft(e.target.value)}
disabled={setMailFromLabelMutation.isPending}
autoComplete="off"
/>
</div>
<Button
type="button"
variant="secondary"
disabled={setMailFromLabelMutation.isPending}
onClick={() => {
const trimmed = mailFromDraft.trim();
setMailFromLabelMutation.mutate(
{
id: domain.id,
mailFromLabel:
trimmed === "" ? null : trimmed.toLowerCase(),
},
{
onSuccess: () => {
utils.domain.invalidate();
toast.success(
trimmed === ""
? "MAIL FROM reset to region default"
: "MAIL FROM label updated — update DNS and verify",
);
},
onError: (err) => {
toast.error(err.message);
},
},
);
}}
>
Save
</Button>
{domain.mailFromLabel ? (
<Button
type="button"
variant="outline"
disabled={setMailFromLabelMutation.isPending}
onClick={() => {
setMailFromLabelMutation.mutate(
{ id: domain.id, mailFromLabel: null },
{
onSuccess: () => {
utils.domain.invalidate();
toast.success("MAIL FROM reset to region default");
},
onError: (err) => {
toast.error(err.message);
},
},
);
}}
>
Reset to default
</Button>
) : null}
</div>
</div>

<div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div>
<p className=" text-muted-foreground text-sm">
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/app/(dashboard)/domains/domain-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Domain } from "@prisma/client";
import type { DomainWithDnsRecords } from "~/types/domain";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { Switch } from "@usesend/ui/src/switch";
Expand Down Expand Up @@ -35,7 +35,7 @@ export default function DomainsList() {
);
}

const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
const DomainItem: React.FC<{ domain: DomainWithDnsRecords }> = ({ domain }) => {
const updateDomain = api.domain.updateDomain.useMutation();
const utils = api.useUtils();

Expand Down Expand Up @@ -71,7 +71,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
return (
<div key={domain.id}>
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
<StatusIndicator status={domain.status} />
<StatusIndicator
status={domain.aggregateStatus ?? domain.status}
/>
<div className="flex justify-between w-full pl-8 py-4">
<div className="flex flex-col gap-4 w-1/5">
<Link
Expand All @@ -80,7 +82,9 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
>
{domain.name}
</Link>
<DomainStatusBadge status={domain.status} />
<DomainStatusBadge
status={domain.aggregateStatus ?? domain.status}
/>
</div>

<div className="flex flex-col gap-4">
Expand Down
46 changes: 38 additions & 8 deletions apps/web/src/app/api/ses_callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { env } from "~/env";
import { db } from "~/server/db";
import { logger } from "~/server/logger/log";
import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser";
import { SesHookParser } from "~/server/service/ses-hook-parser";
import { SesSettingsService } from "~/server/service/ses-settings-service";
import { SnsNotificationMessage } from "~/types/aws-types";
import { SesEvent, SnsNotificationMessage } from "~/types/aws-types";

export const dynamic = "force-dynamic";

Expand All @@ -14,11 +14,11 @@ export async function GET() {
export async function POST(req: Request) {
const data = await req.json();

console.log(data, data.Message);
logger.info({ type: data?.Type, messageId: data?.MessageId }, "Received SNS callback");

const isEventValid = await checkEventValidity(data);

console.log("Is event valid: ", isEventValid);
logger.info({ isEventValid, topicArn: data?.TopicArn }, "SNS callback validation result");

if (!isEventValid) {
return Response.json({ data: "Event is not valid" });
Expand All @@ -28,10 +28,32 @@ export async function POST(req: Request) {
return handleSubscription(data);
}

let message = null;

try {
message = JSON.parse(data.Message || "{}");
const rawMessage = data?.Message;
if (typeof rawMessage !== "string" || rawMessage.trim() === "") {
logger.warn({ messageId: data?.MessageId }, "SNS callback without message payload");
return Response.json({ data: "Ignored non-SES callback message" });
}

let message: unknown;
try {
message = JSON.parse(rawMessage);
} catch {
logger.info(
{ messageId: data?.MessageId, rawMessage },
"Ignoring SNS notification with non-JSON payload",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
return Response.json({ data: "Ignored non-SES callback message" });
}

if (!isSesEventPayload(message)) {
logger.info(
{ messageId: data?.MessageId },
"Ignoring SNS notification that is not an SES event payload",
);
return Response.json({ data: "Ignored non-SES callback message" });
}

const status = await SesHookParser.queue({
event: message,
messageId: data.MessageId,
Expand All @@ -42,11 +64,19 @@ export async function POST(req: Request) {

return Response.json({ data: "Success" });
} catch (e) {
console.error(e);
logger.error({ err: e, messageId: data?.MessageId }, "Failed to process SES callback");
return Response.json({ data: "Error is parsing hook" });
}
}

function isSesEventPayload(value: unknown): value is SesEvent {
if (!value || typeof value !== "object") {
return false;
}
const event = value as Partial<SesEvent>;
return typeof event.eventType === "string" && !!event.mail;
Comment on lines +72 to +77
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden SES event type guard before queueing

The current guard accepts any truthy mail value (!!event.mail), so malformed payloads can pass and be queued as SesEvent, increasing parse failures/retries downstream. Validate mail as an object and minimally assert required fields.

Proposed fix
 function isSesEventPayload(value: unknown): value is SesEvent {
   if (!value || typeof value !== "object") {
     return false;
   }
-  const event = value as Partial<SesEvent>;
-  return typeof event.eventType === "string" && !!event.mail;
+  const event = value as Partial<SesEvent> & { mail?: unknown };
+  if (typeof event.eventType !== "string") {
+    return false;
+  }
+  if (!event.mail || typeof event.mail !== "object") {
+    return false;
+  }
+  const mail = event.mail as { messageId?: unknown; timestamp?: unknown; source?: unknown };
+  return (
+    typeof mail.messageId === "string" &&
+    typeof mail.timestamp === "string" &&
+    typeof mail.source === "string"
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/api/ses_callback/route.ts` around lines 72 - 77, The
isSesEventPayload type guard is too permissive because it only checks
!!event.mail; update isSesEventPayload to ensure event.mail is an object (typeof
event.mail === "object" && event.mail !== null) and assert at least the minimal
required mail fields exist and have expected types (e.g., messageId is a string
and source is a string or destination is an array of strings) while keeping the
existing eventType string check; modify the function (isSesEventPayload /
SesEvent) to perform these stronger checks before returning true so malformed
payloads are rejected before queueing.

}

/**
* Handles the subscription confirmation event. called only once for a webhook
*/
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/lib/domain-aggregate-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { DomainStatus } from "@prisma/client";

/**
* Severity order: worst first. Used to combine identity, DKIM, and MAIL FROM (SPF) checks.
*/
const STATUS_WORST_FIRST: DomainStatus[] = [
DomainStatus.FAILED,
DomainStatus.TEMPORARY_FAILURE,
DomainStatus.PENDING,
DomainStatus.NOT_STARTED,
DomainStatus.SUCCESS,
];

function parseLooseStatus(value?: string | null): DomainStatus {
if (!value) {
return DomainStatus.NOT_STARTED;
}
const normalized = value.toUpperCase();
if ((Object.values(DomainStatus) as string[]).includes(normalized)) {
return normalized as DomainStatus;
}
return DomainStatus.NOT_STARTED;
}

/**
* Single status for UX: all of SES identity verification, DKIM, and MAIL FROM (SPF) must be SUCCESS
* for the aggregate to be SUCCESS.
*/
export function aggregateDomainStatus(domain: {
status: DomainStatus;
dkimStatus?: string | null;
spfDetails?: string | null;
}): DomainStatus {
const parts: DomainStatus[] = [
domain.status,
parseLooseStatus(domain.dkimStatus),
parseLooseStatus(domain.spfDetails),
];

let minIdx = STATUS_WORST_FIRST.length - 1;
for (const p of parts) {
const idx = STATUS_WORST_FIRST.indexOf(p);
if (idx !== -1 && idx < minIdx) {
minIdx = idx;
}
}
return STATUS_WORST_FIRST[minIdx]!;
}
Loading