diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index 0ae47a0..62f6e4d 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -47,6 +47,7 @@ import { } from "@/features/inbox/inbox-feed" 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" @@ -264,6 +265,7 @@ function MailReader({ threadId, eventId = null }: { threadId: string; eventId?: const who = correspondent(sel) const whoLabel = sel.org || who + const hasWithheld = sel.messages.some((m) => m.publication === "withheld") const sendReply = () => { const body = text.trim() @@ -391,6 +393,9 @@ function MailReader({ threadId, eventId = null }: { threadId: string; eventId?: )}

{msg.body}

+ {msg.publication === "withheld" && ( + + )} {msg.truncated && (
This message was cut at 64 KB for display.
)} @@ -424,7 +429,7 @@ function MailReader({ threadId, eventId = null }: { threadId: string; eventId?: 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..2f9c501 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,46 @@ describe("mail badges", () => { expect(publicationTitle("pending", false)).toMatch(/posted shortly/) }) }) + +describe("withheld reply review", () => { + 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("auth") + expect(withheldReason(undefined)).toBe("auth") + }) + + 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 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", () => { + 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..881ade8 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 === "pass" ? "domain" : "auth" +} + +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 052579b..99a6875 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, @@ -104,6 +111,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 390f715..dfc918e 100644 --- a/apps/admin/src/styles/admin.css +++ b/apps/admin/src/styles/admin.css @@ -2844,6 +2844,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 */