From 4e2098d9b4e470a4c4cd89e23aa27b2cd99f56cc Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 00:40:32 -0700 Subject: [PATCH 1/3] mail reader shows sender verification and reply publication --- apps/admin/src/features/inbox/inbox-views.tsx | 2 + apps/admin/src/features/mail/mail-badges.tsx | 44 +++++++++++++++++++ apps/admin/src/features/mail/mail-page.tsx | 29 ++++++------ .../features/mail/mail-presentation.test.ts | 44 +++++++++++++++++++ .../src/features/mail/mail-presentation.ts | 35 +++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 apps/admin/src/features/mail/mail-badges.tsx create mode 100644 apps/admin/src/features/mail/mail-presentation.test.ts create mode 100644 apps/admin/src/features/mail/mail-presentation.ts diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx index 8f507e3..b8f7b9e 100644 --- a/apps/admin/src/features/inbox/inbox-views.tsx +++ b/apps/admin/src/features/inbox/inbox-views.tsx @@ -12,6 +12,7 @@ import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { useInboxMessage, useSetInboxStatus } from "@/features/inbox/use-inbox" +import { AuthVerdictBadge } from "@/features/mail/mail-badges" import { useToast } from "@/store/ui-store" @@ -93,6 +94,7 @@ export function InboxReader({ id }: { id: string }) { {sel.from} ยท to {sel.recipient} + {INBOUND_EMAIL_STATUS_LABELS[sel.status]} diff --git a/apps/admin/src/features/mail/mail-badges.tsx b/apps/admin/src/features/mail/mail-badges.tsx new file mode 100644 index 0000000..cefd8d7 --- /dev/null +++ b/apps/admin/src/features/mail/mail-badges.tsx @@ -0,0 +1,44 @@ +"use client" + +import { + MAIL_AUTH_VERDICT_LABELS, + MAIL_REPLY_PUBLICATION_LABELS, + type MailAuthVerdict, + type MailReplyPublication, +} from "@civfix/shared" + +import { + AUTH_VERDICT_VIEW, + PUBLICATION_CLS, + publicationTitle, +} from "@/features/mail/mail-presentation" + +export function AuthVerdictBadge({ verdict }: { verdict: MailAuthVerdict | null | undefined }) { + if (!verdict) return null + const view = AUTH_VERDICT_VIEW[verdict] + return ( + + {MAIL_AUTH_VERDICT_LABELS[verdict]} + + ) +} + +export function PublicationBadge({ + publication, + isReport, + className = "", +}: { + publication: MailReplyPublication | null | undefined + isReport: boolean + className?: string +}) { + if (!publication) return null + return ( + + {MAIL_REPLY_PUBLICATION_LABELS[publication]} + + ) +} diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index daf660c..fd9e3ff 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -7,7 +7,6 @@ import { MAIL_STATUS_LABELS, relativeAgo, type MailMessageDTO, - type MailStatus, type MailThreadDTO, type MailThreadListItemDTO, } from "@civfix/shared" @@ -35,6 +34,8 @@ import { import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" import { useInboxListInfinite, useSetInboxStatus } from "@/features/inbox/use-inbox" import { InboxRow, InboxReader } from "@/features/inbox/inbox-views" +import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" +import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" import { useNav, useToast } from "@/store/ui-store" import { toAppError } from "@/lib/api" import { errorMessage } from "@/lib/error-messages" @@ -43,16 +44,6 @@ import type { SectionPageProps } from "@/components/shell/page-registry" type Folder = "outreach" | "inbox" -const STATUS_CLS: Record = { - replied: "status-ok", - delivered: "status-ok", - auto: "status-progress", - opened: "status-progress", - sent: "status-progress", - needs_action: "status-flag", - bounced: "status-flag", -} - const BOX_LABEL: Record = { all: "All", in: "Inbound", @@ -232,7 +223,7 @@ function MailRow({
{item.subject || "(no subject)"}
{item.preview}
- + {MAIL_STATUS_LABELS[item.status]} @@ -322,7 +313,7 @@ function MailReader({ threadId }: { threadId: string }) { )} - + {MAIL_STATUS_LABELS[sel.status]} @@ -366,7 +357,17 @@ function MailReader({ threadId }: { threadId: string }) { {ts(msg.ts)} - {isOut && } + {isOut ? ( + + ) : ( + <> + + + + )}

{msg.body}

{msg.truncated && ( diff --git a/apps/admin/src/features/mail/mail-presentation.test.ts b/apps/admin/src/features/mail/mail-presentation.test.ts new file mode 100644 index 0000000..f1ae311 --- /dev/null +++ b/apps/admin/src/features/mail/mail-presentation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest" +import { + MAIL_AUTH_VERDICT_LABELS, + MAIL_REPLY_PUBLICATION_LABELS, + MailAuthVerdictSchema, + MailReplyPublicationSchema, + MailStatusSchema, +} from "@civfix/shared" + +import { + AUTH_VERDICT_VIEW, + MAIL_STATUS_CLS, + PUBLICATION_CLS, + publicationTitle, +} from "./mail-presentation" + +describe("mail badges", () => { + it("styles every mail status, verdict and publication state", () => { + for (const status of MailStatusSchema.options) expect(MAIL_STATUS_CLS[status]).toBeTruthy() + for (const verdict of MailAuthVerdictSchema.options) { + expect(AUTH_VERDICT_VIEW[verdict].cls).toBeTruthy() + expect(AUTH_VERDICT_VIEW[verdict].title).toBeTruthy() + expect(MAIL_AUTH_VERDICT_LABELS[verdict]).toBeTruthy() + } + for (const publication of MailReplyPublicationSchema.options) { + expect(PUBLICATION_CLS[publication]).toBeTruthy() + expect(MAIL_REPLY_PUBLICATION_LABELS[publication]).toBeTruthy() + } + }) + + it("flags a failed sender check and a withheld reply", () => { + expect(AUTH_VERDICT_VIEW.fail.cls).toBe("status-flag") + expect(AUTH_VERDICT_VIEW.pass.cls).toBe("status-ok") + expect(PUBLICATION_CLS.withheld).toBe("status-flag") + expect(PUBLICATION_CLS.published).toBe("status-ok") + }) + + it("says where a published reply went", () => { + expect(publicationTitle("published", true)).toBe("Posted to the report chat and timeline.") + expect(publicationTitle("published", false)).toBe("Added to the event timeline.") + expect(publicationTitle("withheld", true)).toMatch(/Not posted publicly/) + expect(publicationTitle("pending", false)).toMatch(/posted shortly/) + }) +}) diff --git a/apps/admin/src/features/mail/mail-presentation.ts b/apps/admin/src/features/mail/mail-presentation.ts new file mode 100644 index 0000000..99df5c0 --- /dev/null +++ b/apps/admin/src/features/mail/mail-presentation.ts @@ -0,0 +1,35 @@ +import type { MailAuthVerdict, MailReplyPublication, MailStatus } from "@civfix/shared" + +export const MAIL_STATUS_CLS: Record = { + replied: "status-ok", + delivered: "status-ok", + auto: "status-progress", + opened: "status-progress", + sent: "status-progress", + needs_action: "status-flag", + bounced: "status-flag", +} + +export const AUTH_VERDICT_VIEW: Record = { + pass: { cls: "status-ok", title: "The sender's domain passed authentication." }, + fail: { + cls: "status-flag", + title: "This message failed sender authentication and may be forged.", + }, + unknown: { + cls: "status-progress", + title: "No authentication result was recorded for this message.", + }, +} + +export const PUBLICATION_CLS: Record = { + withheld: "status-flag", + pending: "status-progress", + published: "status-ok", +} + +export function publicationTitle(publication: MailReplyPublication, isReport: boolean): string { + if (publication === "withheld") return "Not posted publicly. Open the thread to review it." + if (publication === "pending") return "Approved. It will be posted shortly." + return isReport ? "Posted to the report chat and timeline." : "Added to the event timeline." +} From 976575389a4a16e8cb0e0fb81ea43c3548b0d9e0 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 00:43:34 -0700 Subject: [PATCH 2/3] publish a withheld city reply from the mail reader --- apps/admin/src/features/mail/mail-page.tsx | 7 ++- .../features/mail/mail-presentation.test.ts | 44 ++++++++++++++ .../src/features/mail/mail-presentation.ts | 34 +++++++++++ apps/admin/src/features/mail/use-mail.test.ts | 46 +++++++++++++++ apps/admin/src/features/mail/use-mail.ts | 25 +++++++- .../src/features/mail/withheld-reply-note.tsx | 58 +++++++++++++++++++ apps/admin/src/styles/admin.css | 2 + 7 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/features/mail/use-mail.test.ts create mode 100644 apps/admin/src/features/mail/withheld-reply-note.tsx diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index fd9e3ff..01bfa12 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -36,6 +36,7 @@ import { useInboxListInfinite, useSetInboxStatus } from "@/features/inbox/use-in import { InboxRow, InboxReader } from "@/features/inbox/inbox-views" import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" +import { WithheldReplyNote } from "@/features/mail/withheld-reply-note" import { useNav, useToast } from "@/store/ui-store" import { toAppError } from "@/lib/api" import { errorMessage } from "@/lib/error-messages" @@ -253,6 +254,7 @@ function MailReader({ threadId }: { threadId: string }) { const who = correspondent(sel) const whoLabel = sel.org || who + const hasWithheld = sel.messages.some((m) => m.publication === "withheld") const sendReply = () => { const body = text.trim() @@ -370,6 +372,9 @@ function MailReader({ threadId }: { threadId: string }) { )}

{msg.body}

+ {msg.publication === "withheld" && ( + + )} {msg.truncated && (
This message was cut at 64 KB for display.
)} @@ -403,7 +408,7 @@ function MailReader({ threadId }: { threadId: string }) { reporting form. )} - {sel.dir === "in" && sel.status === "needs_action" && ( + {sel.dir === "in" && sel.status === "needs_action" && !hasWithheld && (
Suggested: re-route this jurisdiction's contact in Jurisdictions. diff --git a/apps/admin/src/features/mail/mail-presentation.test.ts b/apps/admin/src/features/mail/mail-presentation.test.ts index f1ae311..6d987f0 100644 --- a/apps/admin/src/features/mail/mail-presentation.test.ts +++ b/apps/admin/src/features/mail/mail-presentation.test.ts @@ -11,7 +11,12 @@ import { AUTH_VERDICT_VIEW, MAIL_STATUS_CLS, PUBLICATION_CLS, + PUBLISH_TOAST, + domainOfAddress, publicationTitle, + publishConfirmBody, + withheldNote, + withheldReason, } from "./mail-presentation" describe("mail badges", () => { @@ -42,3 +47,42 @@ describe("mail badges", () => { expect(publicationTitle("pending", false)).toMatch(/posted shortly/) }) }) + +describe("withheld reply review", () => { + it("treats a failed or missing sender check as a possible forgery", () => { + expect(withheldReason("fail")).toBe("auth") + expect(withheldReason("unknown")).toBe("auth") + expect(withheldReason("pass")).toBe("domain") + expect(withheldReason(null)).toBe("domain") + }) + + it("explains why the reply was held back and where publishing sends it", () => { + expect(withheldNote({ authVerdict: "fail", from: "x@city.gov" }, true)).toMatch( + /^This reply failed sender authentication, so it wasn't posted to the report chat\. It could be forged/, + ) + expect(withheldNote({ authVerdict: "unknown", from: "x@city.gov" }, false)).toMatch( + /^This reply couldn't be authenticated, so it wasn't posted to the event timeline\./, + ) + expect(withheldNote({ authVerdict: "pass", from: "clerk@vendor.example" }, true)).toMatch( + /^This reply came from vendor\.example, which isn't a domain this thread was sent to, so it wasn't posted to the report chat\./, + ) + expect(withheldNote({ authVerdict: null, from: "" }, false)).toMatch(/^This reply came from an unknown sender,/) + }) + + it("shows the sender's domain, never the whole address", () => { + expect(domainOfAddress("clerk@city.gov")).toBe("city.gov") + expect(withheldNote({ authVerdict: "pass", from: "clerk@city.gov" }, true)).not.toMatch(/clerk@/) + }) + + it("warns what publishing does before it happens", () => { + expect(publishConfirmBody(true)).toMatch(/report's public chat.*reporter gets a notification/) + expect(publishConfirmBody(false)).toMatch(/event's timeline/) + }) + + it("has a toast for every publish outcome", () => { + for (const publication of MailReplyPublicationSchema.options) { + expect(PUBLISH_TOAST[publication]).toBeTruthy() + } + expect(PUBLISH_TOAST.published).toBe("Reply published") + }) +}) diff --git a/apps/admin/src/features/mail/mail-presentation.ts b/apps/admin/src/features/mail/mail-presentation.ts index 99df5c0..c63e890 100644 --- a/apps/admin/src/features/mail/mail-presentation.ts +++ b/apps/admin/src/features/mail/mail-presentation.ts @@ -33,3 +33,37 @@ export function publicationTitle(publication: MailReplyPublication, isReport: bo if (publication === "pending") return "Approved. It will be posted shortly." return isReport ? "Posted to the report chat and timeline." : "Added to the event timeline." } + +export function withheldReason(verdict: MailAuthVerdict | null | undefined): "auth" | "domain" { + return verdict === "fail" || verdict === "unknown" ? "auth" : "domain" +} + +export function domainOfAddress(address: string): string { + return address.slice(address.lastIndexOf("@") + 1) +} + +export function withheldNote( + msg: { authVerdict?: MailAuthVerdict | null; from: string }, + isReport: boolean, +): string { + const target = isReport ? "the report chat" : "the event timeline" + if (withheldReason(msg.authVerdict) === "auth") { + const check = + msg.authVerdict === "fail" ? "failed sender authentication" : "couldn't be authenticated" + return `This reply ${check}, so it wasn't posted to ${target}. It could be forged: confirm it with the city before publishing, or use Mark replied to leave it withheld.` + } + const sender = domainOfAddress(msg.from) || "an unknown sender" + return `This reply came from ${sender}, which isn't a domain this thread was sent to, so it wasn't posted to ${target}. Publish it if it's a genuine reply from the city, or use Mark replied to leave it withheld.` +} + +export function publishConfirmBody(isReport: boolean): string { + return isReport + ? "It will be posted in the report's public chat, the report moves to In progress if it's still open, and the reporter gets a notification. This can't be undone." + : "It will be added to the event's timeline. This can't be undone." +} + +export const PUBLISH_TOAST: Record = { + published: "Reply published", + pending: "Reply approved. It will be published shortly.", + withheld: "The reply is still withheld. Please try again.", +} diff --git a/apps/admin/src/features/mail/use-mail.test.ts b/apps/admin/src/features/mail/use-mail.test.ts new file mode 100644 index 0000000..83a3405 --- /dev/null +++ b/apps/admin/src/features/mail/use-mail.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs" + +import { MutationObserver, QueryClient } from "@tanstack/react-query" +import { describe, expect, it, vi } from "vitest" + +import type * as apiModule from "@/lib/api" +import { api } from "@/lib/api" +import { queryKeys } from "@/lib/query" + +import { publishMailReplyOptions } from "./use-mail" + +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: { publishMailReply: vi.fn() }, +})) + +describe("publish reply", () => { + it("publishes the one message and refreshes the thread, mail lists, reports and events", async () => { + vi.mocked(api.publishMailReply).mockResolvedValue({ publication: "published" }) + const qc = new QueryClient() + const keys = [ + queryKeys.mail.detail("t1"), + queryKeys.mail.list({}), + queryKeys.reports.detail("r1"), + queryKeys.events.detail("c1"), + ] + for (const key of keys) qc.setQueryData(key, {}) + + const res = await new MutationObserver(qc, publishMailReplyOptions(qc)).mutate({ + id: "t1", + messageId: "m1", + }) + + expect(res).toEqual({ publication: "published" }) + expect(api.publishMailReply).toHaveBeenCalledWith({ id: "t1", messageId: "m1" }) + for (const key of keys) expect(qc.getQueryState(key)?.isInvalidated).toBe(true) + }) + + it("asks the operator to confirm before publishing", () => { + const source = readFileSync(new URL("./withheld-reply-note.tsx", import.meta.url), "utf8") + const confirm = source.indexOf("await confirmDialog(") + expect(confirm).toBeGreaterThan(-1) + expect(source.indexOf("if (!ok) return")).toBeGreaterThan(confirm) + expect(source.indexOf("publish.mutate(")).toBeGreaterThan(source.indexOf("if (!ok) return")) + }) +}) diff --git a/apps/admin/src/features/mail/use-mail.ts b/apps/admin/src/features/mail/use-mail.ts index 7c693d8..32cbd32 100644 --- a/apps/admin/src/features/mail/use-mail.ts +++ b/apps/admin/src/features/mail/use-mail.ts @@ -1,6 +1,12 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + mutationOptions, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import type { ComposeRequest, GetForwardTemplateDefaultResponse, @@ -11,6 +17,7 @@ import type { MarkMailReadRequest, PreviewForwardTemplateRequest, PreviewForwardTemplateResponse, + PublishMailReplyRequest, ReplyRequest, ResendRequest, SetForwardTemplateDefaultRequest, @@ -103,6 +110,22 @@ export function useResendMail() { }) } +export function publishMailReplyOptions(qc: ReturnType) { + return mutationOptions({ + mutationFn: (input: PublishMailReplyRequest) => api.publishMailReply(input), + onSuccess: (_res, { id }) => { + invalidateMail(qc, id) + qc.invalidateQueries({ queryKey: queryKeys.reports.all }) + qc.invalidateQueries({ queryKey: queryKeys.events.all }) + }, + }) +} + +export function usePublishMailReply() { + const qc = useQueryClient() + return useMutation(publishMailReplyOptions(qc)) +} + export function useForwardTemplateDefault() { return useQuery({ queryKey: queryKeys.mail.forwardTemplate, diff --git a/apps/admin/src/features/mail/withheld-reply-note.tsx b/apps/admin/src/features/mail/withheld-reply-note.tsx new file mode 100644 index 0000000..d0c90ad --- /dev/null +++ b/apps/admin/src/features/mail/withheld-reply-note.tsx @@ -0,0 +1,58 @@ +"use client" + +import type { MailMessageDTO } from "@civfix/shared" + +import { Icons } from "@/components/icons" +import { confirmDialog } from "@/components/shared/dialog" +import { usePublishMailReply } from "@/features/mail/use-mail" +import { + PUBLISH_TOAST, + publishConfirmBody, + withheldNote, + withheldReason, +} from "@/features/mail/mail-presentation" +import { errorMessage } from "@/lib/error-messages" +import { useToast } from "@/store/ui-store" + +export function WithheldReplyNote({ + threadId, + msg, + isReport, +}: { + threadId: string + msg: MailMessageDTO + isReport: boolean +}) { + const publish = usePublishMailReply() + const toast = useToast() + const auth = withheldReason(msg.authVerdict) === "auth" + + const onPublish = async () => { + const ok = await confirmDialog({ + title: "Publish this reply?", + body: publishConfirmBody(isReport), + confirmLabel: "Publish reply", + cancelLabel: "Cancel", + danger: auth, + }) + if (!ok) return + publish.mutate( + { id: threadId, messageId: msg.id }, + { + onSuccess: (res) => toast(PUBLISH_TOAST[res.publication]), + onError: (err) => + toast(errorMessage(err, {}, { fallback: "Couldn't publish the reply. Please try again." })), + }, + ) + } + + return ( +
+ {auth ? : } + {withheldNote(msg, isReport)} + +
+ ) +} diff --git a/apps/admin/src/styles/admin.css b/apps/admin/src/styles/admin.css index 1fd910e..2026b32 100644 --- a/apps/admin/src/styles/admin.css +++ b/apps/admin/src/styles/admin.css @@ -2843,6 +2843,8 @@ button.prow-main { color: inherit; font: inherit; background: none; border: 0; p } .mail-bounce-note { background: var(--bloom-50); color: var(--bloom-700); border: 1px solid color-mix(in oklab, var(--bloom) 18%, transparent); } .mail-action-note { background: var(--sky-50); color: var(--sky-700); border: 1px solid color-mix(in oklab, var(--sky) 20%, transparent); } +.mail-withheld-note { margin-top: 8px; } +.mail-withheld-note .btn { flex-shrink: 0; margin-left: auto; } .mail-reader-foot { padding: 14px 18px; border-top: 1px solid var(--border-soft); display: flex; gap: 8px; flex-wrap: wrap; } /* Mail thread bubbles */ From bd48394418249015f59f87122aef8e804e2d370c Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 09:39:31 -0700 Subject: [PATCH 3/3] treat a withheld reply with no recorded sender check as unauthenticated --- apps/admin/src/features/mail/mail-presentation.test.ts | 10 +++++++--- apps/admin/src/features/mail/mail-presentation.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/features/mail/mail-presentation.test.ts b/apps/admin/src/features/mail/mail-presentation.test.ts index 6d987f0..2f9c501 100644 --- a/apps/admin/src/features/mail/mail-presentation.test.ts +++ b/apps/admin/src/features/mail/mail-presentation.test.ts @@ -49,11 +49,12 @@ describe("mail badges", () => { }) describe("withheld reply review", () => { - it("treats a failed or missing sender check as a possible forgery", () => { + it("treats a failed, missing or unrecorded sender check as a possible forgery", () => { expect(withheldReason("fail")).toBe("auth") expect(withheldReason("unknown")).toBe("auth") expect(withheldReason("pass")).toBe("domain") - expect(withheldReason(null)).toBe("domain") + expect(withheldReason(null)).toBe("auth") + expect(withheldReason(undefined)).toBe("auth") }) it("explains why the reply was held back and where publishing sends it", () => { @@ -66,7 +67,10 @@ describe("withheld reply review", () => { expect(withheldNote({ authVerdict: "pass", from: "clerk@vendor.example" }, true)).toMatch( /^This reply came from vendor\.example, which isn't a domain this thread was sent to, so it wasn't posted to the report chat\./, ) - expect(withheldNote({ authVerdict: null, from: "" }, false)).toMatch(/^This reply came from an unknown sender,/) + expect(withheldNote({ authVerdict: null, from: "" }, false)).toMatch( + /^This reply couldn't be authenticated, so it wasn't posted to the event timeline\./, + ) + expect(withheldNote({ authVerdict: "pass", from: "" }, false)).toMatch(/^This reply came from an unknown sender,/) }) it("shows the sender's domain, never the whole address", () => { diff --git a/apps/admin/src/features/mail/mail-presentation.ts b/apps/admin/src/features/mail/mail-presentation.ts index c63e890..881ade8 100644 --- a/apps/admin/src/features/mail/mail-presentation.ts +++ b/apps/admin/src/features/mail/mail-presentation.ts @@ -35,7 +35,7 @@ export function publicationTitle(publication: MailReplyPublication, isReport: bo } export function withheldReason(verdict: MailAuthVerdict | null | undefined): "auth" | "domain" { - return verdict === "fail" || verdict === "unknown" ? "auth" : "domain" + return verdict === "pass" ? "domain" : "auth" } export function domainOfAddress(address: string): string {