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." +}