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
33 changes: 33 additions & 0 deletions __tests__/dmarc-dns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// __tests__/dmarc-dns.test.ts
import { describe, it, expect } from "vitest";
import { classifyDnsError, isDkimKeyRecord } from "@/lib/dmarc-dns";

describe("classifyDnsError", () => {
it("treats NXDOMAIN / no-data as a genuinely missing record", () => {
expect(classifyDnsError({ code: "ENOTFOUND" })).toBe("missing");
expect(classifyDnsError({ code: "ENODATA" })).toBe("missing");
});

it("treats resolver failures as transient, never a verdict", () => {
expect(classifyDnsError({ code: "ESERVFAIL" })).toBe("transient");
expect(classifyDnsError({ code: "ETIMEOUT" })).toBe("transient");
expect(classifyDnsError({ code: "ECONNREFUSED" })).toBe("transient");
expect(classifyDnsError(new Error("boom"))).toBe("transient");
expect(classifyDnsError(null)).toBe("transient");
});
});

describe("isDkimKeyRecord", () => {
it("accepts real DKIM key records", () => {
expect(isDkimKeyRecord("v=DKIM1; k=rsa; p=MIGfMA0GCSq")).toBe(true);
expect(isDkimKeyRecord("k=rsa; p=MIGfMA0GCSq")).toBe(true); // v= tag omitted
expect(isDkimKeyRecord("v=DKIM1; p=")).toBe(true); // revoked, still a DKIM record
});

it("rejects unrelated TXT records at the probed name", () => {
expect(isDkimKeyRecord("v=spf1 include:_spf.google.com -all")).toBe(false);
expect(isDkimKeyRecord("google-site-verification=abc123")).toBe(false);
expect(isDkimKeyRecord("MS=ms12345678")).toBe(false);
expect(isDkimKeyRecord("")).toBe(false);
});
});
63 changes: 45 additions & 18 deletions app/api/tools/dmarc-check/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from "next/server";
import { promises as dns } from "node:dns";
import { rateLimit } from "@/server/rate-limit";
import { classifyDnsError, isDkimKeyRecord } from "@/lib/dmarc-dns";

// Common DKIM selectors used by major mail providers. We probe these because
// DKIM selector names aren't discoverable from the domain itself — DNS gives
Expand Down Expand Up @@ -131,23 +132,40 @@ function isValidDomain(s: string): boolean {
return re.test(s);
}

// Resolve a TXT lookup, distinguishing "no such record" (return null) from a
// transient resolver failure (throw) so a SERVFAIL/timeout never masquerades as
// a missing record and falsely tanks the score.
async function lookupTxt(host: string): Promise<string[][] | null> {
try {
return await dns.resolveTxt(host);
} catch {
} catch (err) {
if (classifyDnsError(err) === "transient") throw err;
return null;
}
}

async function lookupMx(domain: string): Promise<{ exchange: string; priority: number }[] | null> {
try {
const records = await dns.resolveMx(domain);
return records.sort((a, b) => a.priority - b.priority);
} catch {
return (await dns.resolveMx(domain)).sort((a, b) => a.priority - b.priority);
} catch (err) {
if (classifyDnsError(err) === "transient") throw err;
return null;
}
}

// DKIM selector probe — best-effort. A non-existent selector (the common case)
// or any resolver hiccup is simply "not found"; a present TXT counts only when
// it's actually a DKIM key, not a stray record sitting at the probed name.
async function probeDkim(selector: string, domain: string): Promise<DkimResult> {
try {
const txt = await dns.resolveTxt(`${selector}._domainkey.${domain}`);
const record = txt.map((parts) => parts.join("")).find(isDkimKeyRecord);
return { selector, found: !!record, record };
} catch {
return { selector, found: false };
}
}

function findSpf(txtRecords: string[][] | null): string | null {
if (!txtRecords) return null;
// SPF records may be split across multiple strings in a single TXT record.
Expand Down Expand Up @@ -320,27 +338,36 @@ export async function POST(request: NextRequest) {
);
}

// Run all DNS lookups in parallel for speed.
const [rootTxt, dmarcTxt, mxRecords, ...dkimResults] = await Promise.all([
lookupTxt(domain),
lookupTxt(`_dmarc.${domain}`),
lookupMx(domain),
...DKIM_SELECTORS.map((selector) =>
lookupTxt(`${selector}._domainkey.${domain}`).then((txt) => ({
selector,
found: !!txt && txt.length > 0,
record: txt?.[0]?.join("") ?? undefined,
}))
),
]);
// Core records first: a transient resolver failure here must surface as an
// error, not as a false "everything is missing" verdict.
let rootTxt: string[][] | null;
let dmarcTxt: string[][] | null;
let mxRecords: { exchange: string; priority: number }[] | null;
try {
[rootTxt, dmarcTxt, mxRecords] = await Promise.all([
lookupTxt(domain),
lookupTxt(`_dmarc.${domain}`),
lookupMx(domain),
]);
} catch {
return NextResponse.json(
{ error: "Couldn't complete the DNS lookup right now. Please try again in a moment." },
{ status: 503 }
);
}

// DKIM selector probes are best-effort and never fail the whole request.
const dkimResults = await Promise.all(
DKIM_SELECTORS.map((selector) => probeDkim(selector, domain))
);

const spfRecord = findSpf(rootTxt);
const spf = { record: spfRecord, ...analyzeSpf(spfRecord) };

const dmarcRecord = findDmarc(dmarcTxt);
const dmarcAnalysis = analyzeDmarc(dmarcRecord);

const dkimFound = (dkimResults as DkimResult[]).filter((r) => r.found);
const dkimFound = dkimResults.filter((r) => r.found);
const dkimStatus: "ok" | "warn" | "missing" =
dkimFound.length >= 1 ? "ok" : "missing";

Expand Down
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ export async function generateMetadata(): Promise<Metadata> {
siteName: "Akritos",
title: TITLE,
description: DESCRIPTION,
images: ["/og-default.png"],
},
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION },
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: ["/og-default.png"] },
robots: { index: true, follow: true },
alternates: { canonical: "https://akritos.com" },
verification: {
Expand Down
23 changes: 20 additions & 3 deletions app/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ import {
ShieldAlert,
Key,
Mail,
type LucideIcon,
} from "lucide-react";

type ServiceEntry = {
icon: LucideIcon;
title: string;
description: string;
details: string[];
link?: { label: string; href: string };
};

async function getCompanyName() {
try {
const setting = await db.setting.findUnique({ where: { key: "company_name" } });
Expand Down Expand Up @@ -95,7 +104,7 @@ const coreServices = [
},
];

const advisoryServices = [
const advisoryServices: ServiceEntry[] = [
{
icon: CreditCard,
title: "Payment Processing & PCI Scope Reduction",
Expand Down Expand Up @@ -155,8 +164,8 @@ const advisoryServices = [
"Vendor evaluation — data terms, audit trail, exit risk",
"Team training — spotting confidently-wrong output",
"SME workflow design — keeping the human as decision-maker",
"Linked: detailed page at /ai-risk",
],
link: { label: "Read the full AI risk & guardrails breakdown", href: "/ai-risk" },
},
{
icon: Mail,
Expand All @@ -169,8 +178,8 @@ const advisoryServices = [
"30-day monitoring period to catch legitimate senders we missed",
"Move to p=quarantine or p=reject once alignment is clean",
"Documentation you own — DNS records, provider settings, monitoring access",
"Linked: free checker tool at /tools/dmarc-check",
],
link: { label: "Check your domain with the free DMARC, SPF & DKIM checker", href: "/tools/dmarc-check" },
},
];

Expand Down Expand Up @@ -364,6 +373,14 @@ export default async function ServicesPage() {
<p className="text-base leading-relaxed text-bone/60">
{s.description}
</p>
{s.link && (
<Link
href={s.link.href}
className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-conviction underline underline-offset-2 hover:text-conviction/80"
>
{s.link.label} <ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
<ul className="space-y-2">
{s.details.map((d) => (
Expand Down
14 changes: 12 additions & 2 deletions app/tools/dmarc-check/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import Link from "next/link";
import { SiteNav } from "@/components/site/site-nav";
import { SiteFooter } from "@/components/site/site-footer";
import { DmarcCheckForm } from "@/components/tools/dmarc-check-form";
import { JsonLd, softwareApplicationSchema, breadcrumbSchema } from "@/components/site/json-ld";
import { db } from "@/server/db";
import { ArrowRight, Mail, ShieldCheck, MailWarning } from "lucide-react";

const SITE_URL = "https://akritos.com";
const URL = `${SITE_URL}/tools/dmarc-check`;
const TITLE = "Free DMARC, SPF & DKIM Checker — Akritos";
// No brand suffix here — the root layout's title template appends " | Akritos".
const TITLE = "Free DMARC, SPF & DKIM Checker";
const DESCRIPTION =
"Free tool: enter your domain to instantly check SPF, DKIM, DMARC, and MX records. Plain-English explanations of what's wrong and how to fix it. No email required to use.";

Expand All @@ -25,8 +27,9 @@ export const metadata: Metadata = {
title: TITLE,
description: DESCRIPTION,
siteName: "Akritos",
images: [`${SITE_URL}/og-default.png`],
},
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION },
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: [`${SITE_URL}/og-default.png`] },
};

async function getCompanyName() {
Expand Down Expand Up @@ -61,6 +64,13 @@ export default async function DmarcCheckPage() {

return (
<div className="flex min-h-dvh flex-col bg-midnight">
<JsonLd data={softwareApplicationSchema({ name: "DMARC, SPF & DKIM Checker", description: DESCRIPTION, url: URL })} />
<JsonLd
data={breadcrumbSchema([
{ name: "Akritos", url: SITE_URL },
{ name: "DMARC, SPF & DKIM Checker", url: URL },
])}
/>
<SiteNav companyName={companyName} />

{/* Hero + form */}
Expand Down
11 changes: 10 additions & 1 deletion app/tools/dmarc-report/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Link from "next/link";
import { SiteNav } from "@/components/site/site-nav";
import { SiteFooter } from "@/components/site/site-footer";
import { DmarcReportForm } from "@/components/tools/dmarc-report-form";
import { JsonLd, softwareApplicationSchema, breadcrumbSchema } from "@/components/site/json-ld";
import { db } from "@/server/db";
import { ArrowRight, FileSearch, ShieldCheck, Eye } from "lucide-react";

Expand All @@ -25,8 +26,9 @@ export const metadata: Metadata = {
title: TITLE,
description: DESCRIPTION,
siteName: "Akritos",
images: [`${SITE_URL}/og-default.png`],
},
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION },
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: [`${SITE_URL}/og-default.png`] },
};

async function getCompanyName() {
Expand Down Expand Up @@ -61,6 +63,13 @@ export default async function DmarcReportPage() {

return (
<div className="flex min-h-dvh flex-col bg-midnight">
<JsonLd data={softwareApplicationSchema({ name: "DMARC Report Analyzer", description: DESCRIPTION, url: URL })} />
<JsonLd
data={breadcrumbSchema([
{ name: "Akritos", url: SITE_URL },
{ name: "DMARC Report Analyzer", url: URL },
])}
/>
<SiteNav companyName={companyName} />

{/* Hero + tool */}
Expand Down
26 changes: 26 additions & 0 deletions components/site/json-ld.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,29 @@ export function breadcrumbSchema(items: { name: string; url: string }[]) {
})),
};
}

export function softwareApplicationSchema(app: {
name: string;
description: string;
url: string;
category?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "WebApplication",
name: app.name,
description: app.description,
url: app.url,
applicationCategory: app.category ?? "SecurityApplication",
operatingSystem: "Web",
browserRequirements: "Requires JavaScript.",
// `offers` (free), not a fabricated aggregateRating, is what makes the tool
// eligible for the Software App rich result — honest, no invented reviews.
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
provider: {
"@type": "Organization",
name: "Akritos Technology Partners LLC",
url: "https://akritos.com",
},
};
}
20 changes: 20 additions & 0 deletions docs/LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,26 @@ Read at session start (loaded by `/boot`). Add to it whenever:

---

## 2026-06-18 — File-based `opengraph-image` doesn't cover pages that export their own `openGraph`

**What happened:** Added a root `app/opengraph-image.tsx` as a site-wide default card. Curling `/tools/dmarc-check` showed no `og:image` — a page that exports its own `metadata.openGraph` object does not inherit an ancestor segment's `opengraph-image` file; the file convention only fills routes that don't define `openGraph` themselves.

**Lesson:** For any page with custom `openGraph`/`twitter` metadata, set `images` explicitly. We dropped the dynamic route for one static `public/og-default.png` referenced from the root layout (default) and from each overriding page. Verify `og:image` with a real request — never assume inheritance.

**Where it applies:** Every public page with custom OG/Twitter metadata. Brand rasters are regenerated via `scripts/gen-brand-assets.mjs`.

---

## 2026-06-18 — DNS "no record" must be distinguished from a resolver failure

**What happened:** The DMARC checker's `lookupTxt`/`lookupMx` swallowed every DNS error to `null`, so a transient SERVFAIL/timeout read identically to NXDOMAIN — telling a user on a flaky network they have no SPF/DMARC and tanking their score. Separately, any TXT at a probed `_domainkey` selector counted as a valid DKIM key (+20), even an unrelated record.

**Lesson:** Classify DNS errors by code (`ENOTFOUND`/`ENODATA` = genuinely missing; anything else = transient → surface an error, don't assert "missing"). When probing for a record type, validate the content (a DKIM key declares `v=DKIM1` or `p=`) — presence at the name is not a match.

**Where it applies:** `app/api/tools/dmarc-check/route.ts`, `lib/dmarc-dns.ts`. Any DNS-probe-based checker.

---

## How to add to this file

When you finish a task and a real lesson emerged, add an entry. Keep it terse. The point is to avoid repeating the mistake — not to write an essay. If the lesson is big enough to drive an architectural change, it goes in `docs/DECISIONS.md` instead. If it's about how the codebase works, update `CLAUDE.md`. If it's about how *we* work — it lives here.
28 changes: 28 additions & 0 deletions lib/dmarc-dns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// lib/dmarc-dns.ts
// Pure DNS-record classification helpers for the email-auth checker. Kept out of
// the route handler so they can be unit-tested without performing live DNS.

/**
* Classify a node:dns rejection. A record that genuinely doesn't exist must not
* be confused with a resolver that's momentarily failing — otherwise a SERVFAIL
* or timeout gets reported to the user as "you have no SPF/DMARC", tanking their
* score on a false negative.
*/
export function classifyDnsError(err: unknown): "missing" | "transient" {
const code = (err as { code?: string } | null)?.code;
// ENOTFOUND = NXDOMAIN; ENODATA = name exists but no record of this type.
// Both mean the record truly isn't there. Everything else (SERVFAIL, timeout,
// refused, …) is a resolver problem, not a verdict about the domain.
if (code === "ENOTFOUND" || code === "ENODATA") return "missing";
return "transient";
}

/**
* True only when a TXT record at `<selector>._domainkey` is actually a DKIM key
* — it declares v=DKIM1 or publishes a p= public-key tag. Guards against
* counting an unrelated/leftover TXT at the probed name as a valid selector,
* which would otherwise award DKIM credit for a record that isn't DKIM.
*/
export function isDkimKeyRecord(record: string): boolean {
return /(^|;|\s)v=DKIM1\b/i.test(record) || /(^|;|\s)p=/i.test(record);
}
Binary file added public/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og-default.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading