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
2 changes: 2 additions & 0 deletions apps/admin/src/features/inbox/inbox-views.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -93,6 +94,7 @@ export function InboxReader({ id }: { id: string }) {
<span className="mono">{sel.from}</span>
<span className="sep">·</span>
<span>to {sel.recipient}</span>
<AuthVerdictBadge verdict={sel.authVerdict} />
<span className="spacer" />
<span className={`pill ${STATUS_CLS[sel.status]} tight`}>
{INBOUND_EMAIL_STATUS_LABELS[sel.status]}
Expand Down
44 changes: 44 additions & 0 deletions apps/admin/src/features/mail/mail-badges.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`pill ${view.cls} tight`} title={view.title}>
{MAIL_AUTH_VERDICT_LABELS[verdict]}
</span>
)
}

export function PublicationBadge({
publication,
isReport,
className = "",
}: {
publication: MailReplyPublication | null | undefined
isReport: boolean
className?: string
}) {
if (!publication) return null
return (
<span
className={`pill ${PUBLICATION_CLS[publication]} tight ${className}`}
title={publicationTitle(publication, isReport)}
>
{MAIL_REPLY_PUBLICATION_LABELS[publication]}
</span>
)
}
29 changes: 15 additions & 14 deletions apps/admin/src/features/mail/mail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
MAIL_STATUS_LABELS,
relativeAgo,
type MailMessageDTO,
type MailStatus,
type MailThreadDTO,
type MailThreadListItemDTO,
} from "@civfix/shared"
Expand Down Expand Up @@ -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"
Expand All @@ -43,16 +44,6 @@ import type { SectionPageProps } from "@/components/shell/page-registry"

type Folder = "outreach" | "inbox"

const STATUS_CLS: Record<MailStatus, string> = {
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<string, string> = {
all: "All",
in: "Inbound",
Expand Down Expand Up @@ -232,7 +223,7 @@ function MailRow({
<div className="mail-subject">{item.subject || "(no subject)"}</div>
<div className="mail-preview">{item.preview}</div>
</div>
<span className={`pill ${STATUS_CLS[item.status]} tight mail-status-pill`}>
<span className={`pill ${MAIL_STATUS_CLS[item.status]} tight mail-status-pill`}>
{MAIL_STATUS_LABELS[item.status]}
</span>
</div>
Expand Down Expand Up @@ -322,7 +313,7 @@ function MailReader({ threadId }: { threadId: string }) {
</>
)}
<span className="spacer" />
<span className={`pill ${STATUS_CLS[sel.status]} tight`}>
<span className={`pill ${MAIL_STATUS_CLS[sel.status]} tight`}>
{MAIL_STATUS_LABELS[sel.status]}
</span>
</div>
Expand Down Expand Up @@ -366,7 +357,17 @@ function MailReader({ threadId }: { threadId: string }) {
<span className="mail-msg-ts mono" title={tsTitle(msg.ts)}>
{ts(msg.ts)}
</span>
{isOut && <DeliveryBadge delivery={msg.delivery} />}
{isOut ? (
<DeliveryBadge delivery={msg.delivery} />
) : (
<>
<AuthVerdictBadge verdict={msg.authVerdict} />
<PublicationBadge
publication={msg.publication}
isReport={sel.reportId !== null}
/>
</>
)}
</div>
<p className="mail-msg-body">{msg.body}</p>
{msg.truncated && (
Expand Down
44 changes: 44 additions & 0 deletions apps/admin/src/features/mail/mail-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
35 changes: 35 additions & 0 deletions apps/admin/src/features/mail/mail-presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { MailAuthVerdict, MailReplyPublication, MailStatus } from "@civfix/shared"

export const MAIL_STATUS_CLS: Record<MailStatus, string> = {
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<MailAuthVerdict, { cls: string; title: string }> = {
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<MailReplyPublication, string> = {
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."
}
Loading