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 @@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [Unreleased]
## [0.9.4] - 2026-10-23

### Fixed
- The receiving health check's test email is sent every 10 minutes again. Before, it only went out once after each restart, so the check reported receiving as degraded when it was working
- Emails from free teams no longer fail with "createdAt.getTime is not a function". The cached team came back with its dates as text, which broke the new-account sending limit check
- Domains that are themselves a subdomain (like `sub.example.com`) no longer show false "DNS records changed" warnings. The DNS check looked for the subdomain twice, for example `mail.sub1.sub2.example.com`
- Receiving DNS records for a sending domain that is a subdomain are now named from the root domain, the same as the sending records, so they can be copied straight into your DNS provider
- The DMARC page accepts the hosted report address (`you+dmarc@in.bytesend.cloud`) as well as the one on your receiving domain, instead of saying the address wasn't added
- The DMARC report panel no longer runs off the side of the screen on mobile. Each source is now a card with a clear pass or fail result

---

Expand Down Expand Up @@ -1089,7 +1094,8 @@ Initial public beta release of ByteSend: an all-in-one email infrastructure plat

---

[Unreleased]: https://github.com/ByteSendApp/ByteSend/compare/v0.9.3...HEAD
[Unreleased]: https://github.com/ByteSendApp/ByteSend/compare/v0.9.4...HEAD
[0.9.4]: https://github.com/ByteSendApp/ByteSend/compare/v0.9.3...v0.9.4
[0.9.3]: https://github.com/ByteSendApp/ByteSend/compare/v0.9.2...v0.9.3
[0.9.2]: https://github.com/ByteSendApp/ByteSend/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/ByteSendApp/ByteSend/compare/v0.9.0...v0.9.1
Expand Down
136 changes: 86 additions & 50 deletions apps/web/src/app/(dashboard)/dmarc/dmarc-reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function DmarcReports({ days, domain }: { days: DmarcRange; domain: strin
</Panel>

<Sheet open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="overflow-y-auto no-scrollbar sm:max-w-2xl">
<SheetContent className="w-full max-w-full overflow-x-hidden overflow-y-auto no-scrollbar sm:max-w-2xl">
<SheetTitle className="sr-only">DMARC report</SheetTitle>
<SheetDescription className="sr-only">Every source and result in one DMARC report.</SheetDescription>
{selected ? <ReportDetails id={selected} /> : null}
Expand All @@ -80,21 +80,83 @@ export function DmarcReports({ days, domain }: { days: DmarcRange; domain: strin

type AuthResult = { domain: string; selector?: string | null; scope?: string | null; result: string };

function AuthResults({ results }: { results: unknown }) {
function AuthResults({ label, results }: { label: string; results: unknown }) {
const list = (results as AuthResult[]) ?? [];
if (list.length === 0) return <span className="text-muted-foreground">none</span>;
return (
<span className="flex flex-col gap-0.5">
{list.map((r, i) => (
<span key={i} className="flex items-center gap-1.5">
<Badge variant={r.result === "pass" ? "success" : r.result === "none" ? "neutral" : "danger"}>{r.result || "?"}</Badge>
<span className="truncate font-mono text-xs">
{r.domain}
{r.selector ? ` (${r.selector})` : ""}
</span>
</span>
))}
</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
{list.length === 0 ? (
<p className="text-[13px] text-muted-foreground">None</p>
) : (
<ul className="mt-1 space-y-1">
{list.map((r, i) => (
<li key={i} className="flex min-w-0 items-start gap-1.5">
<Badge variant={r.result === "pass" ? "success" : r.result === "none" ? "neutral" : "danger"}>{r.result || "?"}</Badge>
<span className="min-w-0 font-mono text-xs leading-5 [overflow-wrap:anywhere]">
{r.domain}
{r.selector ? <span className="text-muted-foreground"> · {r.selector}</span> : null}
</span>
</li>
))}
</ul>
)}
</div>
);
}

type SourceRecord = {
id: string;
sourceIp: string;
sourceHost: string | null;
count: number;
disposition: string;
dkim: string;
spf: string;
headerFrom: string;
envelopeFrom: string | null;
dkimResults: unknown;
spfResults: unknown;
viaByteSend: boolean;
};

function SourceCard({ record }: { record: SourceRecord }) {
const passed = record.dkim === "pass" || record.spf === "pass";
return (
<section className="min-w-0 space-y-3 rounded-lg border border-border bg-card p-4">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={passed ? "success" : "danger"}>{passed ? "Passed DMARC" : "Failed DMARC"}</Badge>
{record.viaByteSend ? <Badge variant="brand">ByteSend</Badge> : null}
{record.disposition !== "none" ? <Badge variant="warning">{record.disposition}</Badge> : null}
</div>
<p className="mt-2 font-mono text-[13px] text-foreground [overflow-wrap:anywhere]">{record.sourceHost ?? record.sourceIp}</p>
{record.sourceHost ? <p className="font-mono text-xs text-muted-foreground">{record.sourceIp}</p> : null}
</div>
<div className="shrink-0 text-right">
<p className="text-base font-semibold tabular-nums text-foreground">{record.count.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{record.count === 1 ? "message" : "messages"}</p>
</div>
</div>

<dl className="grid grid-cols-1 gap-x-4 gap-y-2 border-t border-border/70 pt-3 text-[13px] sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-xs text-muted-foreground">From</dt>
<dd className="[overflow-wrap:anywhere]">{record.headerFrom}</dd>
</div>
{record.envelopeFrom ? (
<div className="min-w-0">
<dt className="text-xs text-muted-foreground">Envelope from</dt>
<dd className="[overflow-wrap:anywhere]">{record.envelopeFrom}</dd>
</div>
) : null}
</dl>

<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<AuthResults label={`DKIM: ${record.dkim}`} results={record.dkimResults} />
<AuthResults label={`SPF: ${record.spf}`} results={record.spfResults} />
</div>
</section>
);
}

Expand All @@ -105,10 +167,10 @@ function ReportDetails({ id }: { id: string }) {
const policy = report.policy as { p?: string | null; sp?: string | null; pct?: string | null; adkim?: string | null; aspf?: string | null };

return (
<div className="space-y-5 p-1">
<div>
<h2 className="text-base font-semibold text-foreground">{report.orgName}</h2>
<p className="text-[13px] text-muted-foreground">
<div className="min-w-0 space-y-5 p-1">
<div className="min-w-0 pr-8">
<h2 className="text-base font-semibold text-foreground [overflow-wrap:anywhere]">{report.orgName}</h2>
<p className="text-[13px] text-muted-foreground [overflow-wrap:anywhere]">
{report.domain} · {period(report.beginAt, report.endAt)}
</p>
</div>
Expand All @@ -121,7 +183,7 @@ function ReportDetails({ id }: { id: string }) {
{
label: "Published policy",
value: (
<span className="font-mono text-xs font-normal">
<span className="font-mono text-xs font-normal [overflow-wrap:anywhere]">
p={policy.p ?? "?"}
{policy.sp ? ` sp=${policy.sp}` : ""} pct={policy.pct ?? "100"} adkim={policy.adkim ?? "r"} aspf={policy.aspf ?? "r"}
</span>
Expand All @@ -134,38 +196,12 @@ function ReportDetails({ id }: { id: string }) {
</Panel>

<div className="space-y-2">
<h3 className="text-sm font-medium text-foreground">Sources</h3>
<div>
<h3 className="text-sm font-medium text-foreground">Sources</h3>
<p className="text-xs text-muted-foreground">A source passes DMARC when DKIM or SPF passes for your domain.</p>
</div>
{report.records.map((record) => (
<Panel key={record.id} flush>
<KeyValueList
items={[
{
label: "Source",
value: (
<span className="flex items-center justify-end gap-1.5">
{record.viaByteSend ? <Badge variant="brand">ByteSend</Badge> : null}
<span className="font-mono text-xs font-normal">{record.sourceHost ? `${record.sourceHost} (${record.sourceIp})` : record.sourceIp}</span>
</span>
),
},
{ label: "Messages", value: record.count.toLocaleString() },
{
label: "Result",
value: (
<span className="flex flex-wrap justify-end gap-1">
<Badge variant={record.dkim === "pass" ? "success" : "danger"}>DKIM {record.dkim}</Badge>
<Badge variant={record.spf === "pass" ? "success" : "danger"}>SPF {record.spf}</Badge>
{record.disposition !== "none" ? <Badge variant="warning">{record.disposition}</Badge> : null}
</span>
),
},
{ label: "From", value: record.headerFrom },
...(record.envelopeFrom ? [{ label: "Envelope from", value: record.envelopeFrom }] : []),
{ label: "DKIM signatures", value: <AuthResults results={record.dkimResults} /> },
{ label: "SPF checks", value: <AuthResults results={record.spfResults} /> },
]}
/>
</Panel>
<SourceCard key={record.id} record={record} />
))}
</div>
</div>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/server/api/routers/dmarc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,16 @@ export const dmarcRouter = createTRPCRouter({
const orgDomains = [...new Set(domains.map((d) => tldts.getDomain(d.name) ?? d.name))];
const checks = await Promise.all(
orgDomains.map(async (orgDomain) => {
const address = addresses.byOrgDomain.get(orgDomain) ?? addresses.hosted;
const check = await checkDmarcRecord(orgDomain, address);
const preferred = addresses.byOrgDomain.get(orgDomain) ?? addresses.hosted;
const check = await checkDmarcRecord(orgDomain, preferred);
const usesHosted =
!check.reportsToUs &&
Boolean(addresses.hosted && check.record?.toLowerCase().includes(`mailto:${addresses.hosted.toLowerCase()}`));
const address = usesHosted ? addresses.hosted : preferred;
return {
...check,
reportsToUs: check.reportsToUs || usesHosted,
suggested: usesHosted ? check.record : check.suggested,
address,
sendingDomains: domains.filter((d) => (tldts.getDomain(d.name) ?? d.name) === orgDomain).map((d) => d.name),
};
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/server/service/cloudflare-dns-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function desiredRecords(domain: Domain): DesiredRecord[] {
const receiving = domain.inboundEnabled
? receivingRecords(domain).map((record): DesiredRecord => ({
type: record.type,
name: record.name === ROOT_RECEIVING ? domain.name : `${record.name}.${domain.name}`,
name: record.name === ROOT_RECEIVING ? zone : `${record.name}.${zone}`,
content: record.value,
priority: record.priority,
kind: record.type === "MX" ? "mx" : "dkim",
Expand Down
25 changes: 19 additions & 6 deletions apps/web/src/server/service/cloudflare-dns-service.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import type { Domain } from "@prisma/client";

vi.mock("../logger/log", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
vi.mock("./domain-service", () => ({
buildDnsRecords: (domain: Domain) => [
{ type: "MX", name: "mail", value: `feedback-smtp.${domain.region}.amazonses.com`, priority: "10" },
{ type: "TXT", name: "bytesend._domainkey", value: `p=${domain.publicKey}` },
{ type: "TXT", name: "mail", value: "v=spf1 include:amazonses.com ~all" },
{ type: "TXT", name: "_dmarc", value: "v=DMARC1; p=none;" },
],
buildDnsRecords: (domain: Domain) => {
const suffix = domain.subdomain ? `.${domain.subdomain}` : "";
return [
{ type: "MX", name: `mail${suffix}`, value: `feedback-smtp.${domain.region}.amazonses.com`, priority: "10" },
{ type: "TXT", name: `bytesend._domainkey${suffix}`, value: `p=${domain.publicKey}` },
{ type: "TXT", name: `mail${suffix}`, value: "v=spf1 include:amazonses.com ~all" },
{ type: "TXT", name: "_dmarc", value: "v=DMARC1; p=none;" },
];
},
}));
vi.mock("./inbound-domain-service", () => ({ receivingRecords: () => [] }));

Expand Down Expand Up @@ -37,6 +40,16 @@ describe("desiredRecords", () => {
"TXT _dmarc.acme.com",
]);
});

it("names a subdomain's records once, under the root zone", () => {
const sub = { ...domain, name: "trans.acme.com", subdomain: "trans" } as Domain;
expect(desiredRecords(sub).map((r) => `${r.type} ${r.name}`)).toEqual([
"MX mail.trans.acme.com",
"TXT bytesend._domainkey.trans.acme.com",
"TXT mail.trans.acme.com",
"TXT _dmarc.acme.com",
]);
});
});

describe("planRecord", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/server/service/domain-region-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class DomainRegionService {
const [identity, dkimDns, mxDns] = await Promise.all([
ses.getDomainIdentity(domain.name, domain.pendingRegion),
checkDkimDnsRecord(domain.pendingDkimSelector, domain.name, domain.pendingPublicKey),
checkMxDnsRecord(`${mailDomainLabel(domain)}.${domain.name}`, domain.pendingRegion),
checkMxDnsRecord(`mail.${domain.name}`, domain.pendingRegion),
]);
const dnsPrecheck: { dkim: DnsCheckResult; mx: DnsCheckResult } = { dkim: dkimDns, mx: mxDns };

Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/server/service/domain-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,7 @@ async function recheckDnsHealth(domain: Domain) {
export async function checkDomainDnsHealth(
domain: Pick<Domain, "name" | "subdomain" | "dkimSelector" | "publicKey" | "region">,
): Promise<string[]> {
const subdomainSuffix = domain.subdomain ? `.${domain.subdomain}` : "";
const mailDomain = `mail${subdomainSuffix}.${domain.name}`;
const mailDomain = `mail.${domain.name}`;
const dkimSelector = domain.dkimSelector ?? "bytesend";

const [dkim, spf, mx] = await Promise.all([
Expand Down Expand Up @@ -703,8 +702,7 @@ export async function refreshDomainVerification(

const verificationState = await getDomainVerificationState(domain.id);
const previousStatus = domain.status;
const subdomainSuffix = domain.subdomain ? `.${domain.subdomain}` : "";
const mailDomain = `mail${subdomainSuffix}.${domain.name}`;
const mailDomain = `mail.${domain.name}`;
const dkimSelector = domain.dkimSelector ?? "bytesend";

const [domainIdentity, dkimDns, spfDns, mxDns] = await Promise.all([
Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/server/service/inbound-domain-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ const INBOUND_DKIM_SELECTOR = "bytesend-inbound";

type ReceivingDomain = Pick<
Domain,
"id" | "name" | "region" | "status" | "inboundEnabled" | "inboundSubdomain" | "inboundStatus" | "inboundCheckedAt" | "inboundDkimSelector" | "inboundPublicKey"
"id" | "name" | "subdomain" | "region" | "status" | "inboundEnabled" | "inboundSubdomain" | "inboundStatus" | "inboundCheckedAt" | "inboundDkimSelector" | "inboundPublicKey"
>;

export const RECEIVING_DOMAIN_SELECT = {
id: true,
name: true,
subdomain: true,
region: true,
status: true,
inboundEnabled: true,
Expand All @@ -43,17 +44,17 @@ export function receivingFqdn(domain: Pick<Domain, "name" | "inboundSubdomain">)
return isRootReceiving(domain) ? domain.name : `${domain.inboundSubdomain}.${domain.name}`;
}

export function receivingRecords(domain: ReceivingDomain) {
export function receivingRecords(domain: Pick<ReceivingDomain, "subdomain" | "inboundSubdomain" | "inboundDkimSelector" | "inboundPublicKey">) {
if (!domain.inboundSubdomain) return [];
const host = isRootReceiving(domain) ? "" : domain.inboundSubdomain;
const relativeToZone = (...labels: string[]) => [...labels, domain.subdomain].filter(Boolean).join(".") || ROOT_RECEIVING;
const records: Array<{ type: "MX" | "TXT"; name: string; value: string; priority?: number }> = [
{ type: "MX", name: isRootReceiving(domain) ? ROOT_RECEIVING : domain.inboundSubdomain, value: inboundMxHost(), priority: 10 },
{ type: "MX", name: relativeToZone(host), value: inboundMxHost(), priority: 10 },
];
if (domain.inboundDkimSelector && domain.inboundPublicKey) {
records.push({
type: "TXT",
name: isRootReceiving(domain)
? `${domain.inboundDkimSelector}._domainkey`
: `${domain.inboundDkimSelector}._domainkey.${domain.inboundSubdomain}`,
name: relativeToZone(`${domain.inboundDkimSelector}._domainkey`, host),
value: `p=${domain.inboundPublicKey}`,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ describe("receiving addresses", () => {
{ type: "TXT", name: "bytesend-inbound._domainkey", value: "p=KEY" },
]);
});

it("names records from the root domain when the sending domain is a subdomain", () => {
const sub = { ...base, name: "trans.acme.com", subdomain: "trans", inboundDkimSelector: "bytesend-inbound", inboundPublicKey: "KEY" };
expect(receivingFqdn({ ...sub, inboundSubdomain: "reply" })).toBe("reply.trans.acme.com");
expect(receivingRecords({ ...sub, inboundSubdomain: "reply" }).map((r) => r.name)).toEqual(["reply.trans", "bytesend-inbound._domainkey.reply.trans"]);
expect(receivingRecords({ ...sub, inboundSubdomain: "@" }).map((r) => r.name)).toEqual(["trans", "bytesend-inbound._domainkey.trans"]);
});
});

describe("enableReceiving", () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/server/service/team-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ export class TeamService {
const redis = getRedis();
const raw = await redis.get(TeamService.cacheKey(teamId));
if (raw) {
return JSON.parse(raw) as Team;
const cached = JSON.parse(raw) as Team;
return {
...cached,
createdAt: new Date(cached.createdAt),
updatedAt: new Date(cached.updatedAt),
sendingApprovedAt: cached.sendingApprovedAt ? new Date(cached.sendingApprovedAt) : null,
};
}
const fresh = await TeamService.refreshTeamCache(teamId);
if (!fresh) {
Expand Down
Loading