You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Parent: the email notifications epic. Blocked by the delivery-event webhook ticket.
Why
The question a manager actually asks is never "show me the email log" — it's "did this applicant get their acceptance?" That question is asked while looking at the application. The admin log page answers it for admins; this answers it where the question arises, for the person asking it.
Scope
On /applications/[id], surface the email history for that application: which emails were sent, when, and what happened to them — delivered, bounced, still scheduled, failed.
Data: a new function in prisma/data/ returning EmailLog rows for the application, scoped by the same listable application scope as getApplicationForReview. A manager sees mail for their own positions' applications and nothing else.
OTP rows are structurally excluded — they carry no applicationId, so scoping by it filters them out for free. Do not filter by template.
A scheduled decision email should be visibly scheduled, since Applicant Emails And Reviewer Warnings #548 delays them ~15 minutes. That is the one state where a reviewer might still act on what they see.
Whether this belongs on the page or inside #399's status dialog. The dialog already shows status history and is where a reviewer goes to understand what happened to an application; email is arguably the same kind of question. The counter-argument is that the dialog is already doing two jobs.
Non-goals
No resending from this view.
No email body preview — subject, status and timestamps only.
No applicant-facing version of this. The applicant knows what they received.
Acceptance criteria
A manager sees the email history for an application on a position they manage.
A manager cannot see it for an application outside their scope.
OTP rows never appear.
A scheduled-but-unsent decision email is shown as scheduled.
A bounced email is visibly distinguishable from a delivered one.
Empty state handled — an application with no logged email.
No migration is added.
WORKFLOWS.md PM-9 updated to describe the new section.
npm run prettier:check, eslint:check, tsc:check, test all pass.
Tests
tests/db/ — the query returns only rows for the given application; a manager outside the scope gets nothing; OTP rows are absent; ordering is newest-first.
tests/unit/ — the status-to-label mapping, including the scheduled and bounced cases.
Implementation Plan
Overview
A read-only "Email history" SectionCard at the bottom of /applications/[id], in its own <Suspense> boundary, fed by one new prisma/data query that mirrors getApplicationStatusHistory's scoping exactly. Each row is subject + status badge + the one timestamp that matters for that status, plus a plain-English sentence for every state that isn't delivered — that sentence is what makes a bounce distinguishable from a delivery, and what keeps sent from reading as proof of receipt (ENGINEERING.md §2). The status vocabulary (labels, badge variants, copy) lands in lib/constants.ts / lib/utils.ts as shared, pure, unit-tested maps, because #553 needs the same mapping and must reuse it rather than re-derive it.
Resolving the open question: the page, not the status dialog. The dialog is modal, transient and action-shaped — you open it to change something, and you close it. Email history is reference material a reviewer scans while reading the application, and a modal is the wrong container for something you want visible alongside the answers. It's also already doing two jobs, and #548 is adding a third (the pending-decision-email notice on Undo). That notice is the genuinely actionable email fact and belongs in the dialog; the audit trail does not. Placement is last, below both answer groups: "Other applications" is context about the person and stays on top, the answers are the substance of review, and the outbound-mail trail is the lowest-priority thing on the page.
Scope boundary vs. siblings.#548 owns writing rows (applicationId, scheduled/cancelled) and the dialog's decision-email notice; #553 owns the admin-wide table with search, filters, pagination and the failure strip. This ticket adds only the per-application read surface and the shared status vocabulary both pages render. Until #548 lands there are no rows carrying an applicationId, so the section renders its empty state in every real scenario — expected, not a bug.
Changes
prisma/data/applications.ts — getApplicationEmailHistory(applicationId, user); listable scope through the application relation, newest first.
lib/types.ts — ApplicationEmailEntry, the client-safe row shape (no recipient address, no provider id, no raw provider error).
lib/constants.ts — EMAIL_STATUS_LABELS, EMAIL_STATUS_BADGE_VARIANT, EMAIL_STATUS_DESCRIPTIONS, following the APPLICATION_STATUS_* precedent. Shared with Add An Admin Email Log Page #553.
lib/utils.ts — getEmailLogOccurredAt (which timestamp a status actually means) and getEmailLogDescription (the bounce-type branch); both pure.
components/features/status-badge.tsx — EmailStatusBadge, next to the two existing badges.
components/features/application-email-history.tsx — new; async server component, same shape as applicant-other-applications.tsx.
components/ui/section-card.tsx — new 'badge-stacked' skeleton row shape (two-line row: title + badge, then a muted line), so the fallback matches the real layout.
app/(main)/(auth)/applications/[id]/page.tsx — mount the section last, inside its own <Suspense>.
app/(main)/(auth)/applications/[id]/loading.tsx — add the matching skeleton; while there, reorder the existing skeletons to match the page (the "Other applications" card moved above the answer groups in Show A Reviewer An Applicant's Other Applications #550 and this file wasn't updated).
Add EMAIL_STATUS_LABELS: Record<$Enums.EmailStatus, string> to lib/constants.ts: scheduled Scheduled · sent Sent · delivered Delivered · bounced Bounced · complained Spam complaint · suppressed Blocked · failed Failed · cancelled Cancelled.
Add EMAIL_STATUS_BADGE_VARIANT: Record<$Enums.EmailStatus, BadgeVariant>: delivered success · scheduled info · sent secondary · cancelled outline · bounced/complained/suppressed/failed destructive. sent is deliberately notsuccess — §2 says a sent row is unknown, not delivered.
Add EMAIL_STATUS_DESCRIPTIONS: Record<$Enums.EmailStatus, string | null> with the copy under UX states; delivered is null (the badge and timestamp already say it), bounced is null because getEmailLogDescription branches on bounceType for it.
Add getEmailLogDescription({ status, bounceType }) to lib/utils.ts: for bounced, map 'Permanent' / 'Transient' / anything else (including null) to the three bounce sentences; otherwise return EMAIL_STATUS_DESCRIPTIONS[status].
Add getEmailLogOccurredAt({ status, scheduledAt, sentAt, deliveredAt, createdAt }) to lib/utils.ts — a switch with a never default so a new enum member is a compile error: scheduled → scheduledAt ?? createdAt; delivered → deliveredAt ?? sentAt ?? createdAt; every other member → sentAt ?? createdAt (which is createdAt for failed/cancelled, neither of which ever has a sentAt).
Add ApplicationEmailEntry = { id: string; subject: string; status: $Enums.EmailStatus; bounceType: string \| null; occurredAt: Date } to lib/types.ts, with a one-line comment that the address, provider id and raw provider error are deliberately withheld.
Add getApplicationEmailHistory(applicationId, user: Reviewer): Promise<ApplicationEmailEntry[]> to prisma/data/applications.ts, directly below getApplicationStatusHistory and carrying the same style of scope comment.
Query: where: { applicationId, application: buildApplicationWhere(user, 'listable') }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], selecting only id, subject, status, bounceType, scheduledAt, sentAt, deliveredAt, createdAt. Map through getEmailLogOccurredAt and drop the raw timestamps. No take — an application accrues a handful of rows at most.
Add EmailStatusBadge({ status }) to components/features/status-badge.tsx, mirroring ApplicationStatusBadge.
Build ApplicationEmailHistory({ applicationId, user }) in components/features/application-email-history.tsx: SectionCard title="Email history" titleAs="h2" subtitle="Emails sent to this applicant about this application.", then either SectionCardEmpty or the row list.
Row markup — <ul className="divide-y">, each <li className="px-4 py-3"> with a first line flex flex-wrap items-center gap-x-3 gap-y-1 holding the subject (min-w-0 flex-1 text-sm font-medium) and the badge, then a second line text-muted-foreground text-xs holding <LocalTime date={entry.occurredAt} precision="datetime" /> and, when getEmailLogDescription returns a string, {' · '} plus that sentence. No links, no buttons — nothing in this section is interactive.
Add the 'badge-stacked' case to SectionCardSkeletonRow: border-b px-4 py-3 last:border-0 wrapping a first row of h-4 flex-1 + h-5 w-20 rounded-md, then mt-1.5 h-3 w-48. Add it to the SectionCardSkeletonRowShape union; the existing never default keeps the switch exhaustive.
Mount it in page.tsx as the last child of the sections stack, wrapped in <Suspense fallback={<SectionCardSkeleton rowShape="badge-stacked" hasSubtitle hasLink={false} rows={2} />}> — its own boundary, so a slow email query never holds up the answers.
Add the same skeleton to loading.tsx and reorder that file's cards to badge-meta (other applications) → two AnswersCardSkeleton → badge-stacked (email history), matching the page.
Update PM-9 in docs/WORKFLOWS.md: one happy-path sentence for the new section (what it lists, the listable scope through the relation, that OTP rows are structurally absent because they carry no applicationId, and that sent is not proof of receipt) and three failure/edge bullets (empty state; a bounce showing permanent vs transient; a scheduled decision email reading as not yet sent).
Write the tests below, then prettier:check, eslint:check, tsc:check, test.
Data & contracts
No migration, no schema change, no server action. Every column and enum member ships in #547 and the epic forbids a second migration; this ticket adds a read path only, so there is no zod schema and no { error } contract.
Authorization is the whole contract.getApplicationEmailHistory never trusts its caller: the nested application: buildApplicationWhere(user, 'listable') re-derives the scope inside the query, exactly as getApplicationStatusHistory does, so a guessed applicationId returns [] rather than another manager's mail. The applicationId equality filter is what excludes OTP rows — they are written with applicationId: null and can never match — so no template filter exists to be forgotten or bypassed.
Error model. Failure here is a data-fetch throw during render, which per §4 hits the single global boundary — nothing is caught, nothing is logged, no fallback row is invented. An empty result is a legitimate answer, not an error.
What crosses to the client. Only ApplicationEmailEntry. to is withheld (redundant — the applicant's address is already in the page header) and providerMessageId / error are withheld as internal; bounceType crosses because Permanent vs Transient is the difference between "wrong address" and "try later", which is the manager's actual question. #553 renders the raw error — that page is admin-only.
UX states
Loading — SectionCardSkeleton rowShape="badge-stacked" (2 rows) inside the section's own <Suspense>; the two-line row shape means no shift when the real list resolves.
Empty — SectionCardEmpty with the lucide Mail icon, title "No emails yet", description "Nothing has been emailed to this applicant about this application." No action button: there is nothing a reviewer can do here, and inventing one would imply a send.
Error — the global boundary (app/(main)/error.tsx) with its Try again; no per-section fallback.
Row copy (second line, after the timestamp):
scheduled — "Not sent yet. Changing this application's status again cancels it."
sent — "Handed off to the email provider — delivery not confirmed yet."
delivered — no sentence; the badge and timestamp are the whole story.
bounced + Permanent — "The address rejected it permanently — the applicant did not receive this."
bounced + Transient — "Temporarily undeliverable — the applicant did not receive this."
bounced, type unknown — "This could not be delivered."
complained — "The applicant marked this as spam."
suppressed — "Blocked before sending because the address is on the provider's suppression list."
failed — "This was never sent."
cancelled — "Cancelled before it was sent."
Accessibility — titleAs="h2" keeps the page's h1 → h2 hierarchy intact; a semantic <ul>/<li> list; status is carried by badge text, not colour alone, and the description sentence states the outcome in words, so a bounce and a delivery are distinguishable without perceiving colour; LocalTime renders a <time> with the exact instant in dateTime/title. No focus or touch-target concerns — the section contains no interactive elements.
Responsive — mobile-first: the first line is flex-wrap with gap-y-1 so the badge drops below a long subject at 375px; no fixed widths.
Testing
Nothing writes an EmailLog row with an applicationId until #548 ships, so the manual pass needs hand-seeded rows. Use the Vercel preview (the local .env can't sign in) and its Neon branch's SQL editor.
Open an application you manage on the preview — the "Email history" section renders last, below both answer groups, showing "No emails yet".
In the Neon SQL editor for that preview branch, insert five EmailLog rows against that application's id, all with the applicant's address: application_received/sent with a sentAt; application_accepted/scheduled with a scheduledAt ~15 min out; a delivered row with deliveredAt; a bounced row with bounceType = 'Permanent'; a cancelled row.
Reload: five rows, newest first, each with the right badge, the right timestamp for its status (the scheduled row shows its future scheduledAt, the delivered row its deliveredAt), and the sentence from UX states.
Confirm the bounce is unmistakable next to the delivered row — badge text, colour and sentence all differ — and that the sent row does not read as delivered.
Insert one row with applicationId = NULL, template = 'otp' and the same recipient; reload — it does not appear.
Sign in as a manager of a different position and open that application by URL: still notFound(), so no email data is reachable.
As an admin, open the same application — the section renders identically.
Throttle to Slow 3G and reload: the skeleton shows two two-line rows and the answers paint before it resolves, with no layout shift when it does.
Check 375px / 768px / 1280px and both themes.
npm run prettier:check && npm run eslint:check && npm run tsc:check && npm run test.
Automated tests
tests/db/application-email-history.test.ts — seed two managers, an admin, two positions and two applications, plus EmailLog rows created directly with prisma.emailLog.create and a TEST_PREFIX recipient (cleanupFixtures already sweeps those, and no fixture change is needed). Assert: the managing manager gets exactly that application's rows; the other manager gets []; the admin gets the rows; a row on the other application never leaks in; an applicationId: null OTP row with the same recipient is absent; ordering is createdAt desc with the id tiebreak; an application with no rows returns []; each entry's occurredAt is the status-appropriate column.
tests/unit/constants.test.ts — every $Enums.EmailStatus member has a label, a badge variant and a description entry (iterate the enum, so a future member fails the test rather than rendering blank); sent is not mapped to success.
tests/unit/utils.test.ts — getEmailLogDescription for bounced × Permanent / Transient / null, and for scheduled, sent, cancelled; delivered returns null. getEmailLogOccurredAt picks scheduledAt for scheduled, deliveredAt for delivered, sentAt for sent/bounced, createdAt for failed/cancelled, and falls back to createdAt whenever the preferred column is null.
Risks / notes
The section is empty for every real application until Applicant Emails And Reviewer Warnings #548 lands. That is the honest state and the ticket asks for it, but it means the acceptance criteria about scheduled and bounced rows are only demonstrable against seeded data.
The shared status vocabulary is a coupling with Add An Admin Email Log Page #553. It lives in lib/constants.ts on purpose; whichever of the two lands second must import these maps, not add a second set. If Add An Admin Email Log Page #553 needs a longer, admin-flavoured wording it should add its own map beside these rather than editing the manager-facing copy.
lib/email/delivery-events.ts is server-only (it imports prisma), so EMAIL_STATUS_RANK can't be reused in the UI — the rendering maps are a separate, client-safe concern, not a duplicate of it.
No revalidatePath. Nothing here mutates, and the webhook that flips sent → delivered deliberately revalidates nothing — a manager holding the page open sees the old status until they reload. Acceptable: the timestamps are absolute, so a stale row is dated, not wrong.
loading.tsx's current ordering is already stale relative to the page after Show A Reviewer An Applicant's Other Applications #550; fixing it is one line and in the same file this ticket edits, so it's folded in rather than left as a separate ticket.
Parent: the email notifications epic. Blocked by the delivery-event webhook ticket.
Why
The question a manager actually asks is never "show me the email log" — it's "did this applicant get their acceptance?" That question is asked while looking at the application. The admin log page answers it for admins; this answers it where the question arises, for the person asking it.
Scope
On
/applications/[id], surface the email history for that application: which emails were sent, when, and what happened to them — delivered, bounced, still scheduled, failed.prisma/data/returningEmailLogrows for the application, scoped by the samelistableapplication scope asgetApplicationForReview. A manager sees mail for their own positions' applications and nothing else.applicationId, so scoping by it filters them out for free. Do not filter by template.SectionCardbelow the answer groups, or folded into Add Application Status History, Override, And Compact Status Controls #399's status dialog if it fits there more naturally. Decide during planning and say why.applicationIdand every status member ship in Email Foundation And Send Log #547.Open question for the plan gate
Whether this belongs on the page or inside #399's status dialog. The dialog already shows status history and is where a reviewer goes to understand what happened to an application; email is arguably the same kind of question. The counter-argument is that the dialog is already doing two jobs.
Non-goals
Acceptance criteria
WORKFLOWS.mdPM-9 updated to describe the new section.npm run prettier:check,eslint:check,tsc:check,testall pass.Tests
tests/db/— the query returns only rows for the given application; a manager outside the scope gets nothing; OTP rows are absent; ordering is newest-first.tests/unit/— the status-to-label mapping, including the scheduled and bounced cases.Implementation Plan
Overview
A read-only "Email history"
SectionCardat the bottom of/applications/[id], in its own<Suspense>boundary, fed by one newprisma/dataquery that mirrorsgetApplicationStatusHistory's scoping exactly. Each row is subject + status badge + the one timestamp that matters for that status, plus a plain-English sentence for every state that isn'tdelivered— that sentence is what makes a bounce distinguishable from a delivery, and what keepssentfrom reading as proof of receipt (ENGINEERING.md§2). The status vocabulary (labels, badge variants, copy) lands inlib/constants.ts/lib/utils.tsas shared, pure, unit-tested maps, because #553 needs the same mapping and must reuse it rather than re-derive it.Resolving the open question: the page, not the status dialog. The dialog is modal, transient and action-shaped — you open it to change something, and you close it. Email history is reference material a reviewer scans while reading the application, and a modal is the wrong container for something you want visible alongside the answers. It's also already doing two jobs, and #548 is adding a third (the pending-decision-email notice on Undo). That notice is the genuinely actionable email fact and belongs in the dialog; the audit trail does not. Placement is last, below both answer groups: "Other applications" is context about the person and stays on top, the answers are the substance of review, and the outbound-mail trail is the lowest-priority thing on the page.
Scope boundary vs. siblings. #548 owns writing rows (
applicationId,scheduled/cancelled) and the dialog's decision-email notice; #553 owns the admin-wide table with search, filters, pagination and the failure strip. This ticket adds only the per-application read surface and the shared status vocabulary both pages render. Until #548 lands there are no rows carrying anapplicationId, so the section renders its empty state in every real scenario — expected, not a bug.Changes
prisma/data/applications.ts—getApplicationEmailHistory(applicationId, user);listablescope through theapplicationrelation, newest first.lib/types.ts—ApplicationEmailEntry, the client-safe row shape (no recipient address, no provider id, no raw providererror).lib/constants.ts—EMAIL_STATUS_LABELS,EMAIL_STATUS_BADGE_VARIANT,EMAIL_STATUS_DESCRIPTIONS, following theAPPLICATION_STATUS_*precedent. Shared with Add An Admin Email Log Page #553.lib/utils.ts—getEmailLogOccurredAt(which timestamp a status actually means) andgetEmailLogDescription(the bounce-type branch); both pure.components/features/status-badge.tsx—EmailStatusBadge, next to the two existing badges.components/features/application-email-history.tsx— new; async server component, same shape asapplicant-other-applications.tsx.components/ui/section-card.tsx— new'badge-stacked'skeleton row shape (two-line row: title + badge, then a muted line), so the fallback matches the real layout.app/(main)/(auth)/applications/[id]/page.tsx— mount the section last, inside its own<Suspense>.app/(main)/(auth)/applications/[id]/loading.tsx— add the matching skeleton; while there, reorder the existing skeletons to match the page (the "Other applications" card moved above the answer groups in Show A Reviewer An Applicant's Other Applications #550 and this file wasn't updated).docs/WORKFLOWS.md— PM-9 happy path + failure/edge.tests/db/application-email-history.test.ts— new.tests/unit/constants.test.ts,tests/unit/utils.test.ts— extended.Implementation
EMAIL_STATUS_LABELS: Record<$Enums.EmailStatus, string>tolib/constants.ts:scheduledScheduled ·sentSent ·deliveredDelivered ·bouncedBounced ·complainedSpam complaint ·suppressedBlocked ·failedFailed ·cancelledCancelled.EMAIL_STATUS_BADGE_VARIANT: Record<$Enums.EmailStatus, BadgeVariant>:deliveredsuccess ·scheduledinfo ·sentsecondary ·cancelledoutline ·bounced/complained/suppressed/faileddestructive.sentis deliberately notsuccess— §2 says asentrow is unknown, not delivered.EMAIL_STATUS_DESCRIPTIONS: Record<$Enums.EmailStatus, string | null>with the copy under UX states;deliveredisnull(the badge and timestamp already say it),bouncedisnullbecausegetEmailLogDescriptionbranches onbounceTypefor it.getEmailLogDescription({ status, bounceType })tolib/utils.ts: forbounced, map'Permanent'/'Transient'/ anything else (includingnull) to the three bounce sentences; otherwise returnEMAIL_STATUS_DESCRIPTIONS[status].getEmailLogOccurredAt({ status, scheduledAt, sentAt, deliveredAt, createdAt })tolib/utils.ts— aswitchwith aneverdefault so a new enum member is a compile error:scheduled→scheduledAt ?? createdAt;delivered→deliveredAt ?? sentAt ?? createdAt; every other member →sentAt ?? createdAt(which iscreatedAtforfailed/cancelled, neither of which ever has asentAt).ApplicationEmailEntry = { id: string; subject: string; status: $Enums.EmailStatus; bounceType: string \| null; occurredAt: Date }tolib/types.ts, with a one-line comment that the address, provider id and raw providererrorare deliberately withheld.getApplicationEmailHistory(applicationId, user: Reviewer): Promise<ApplicationEmailEntry[]>toprisma/data/applications.ts, directly belowgetApplicationStatusHistoryand carrying the same style of scope comment.where: { applicationId, application: buildApplicationWhere(user, 'listable') },orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], selecting onlyid,subject,status,bounceType,scheduledAt,sentAt,deliveredAt,createdAt. Map throughgetEmailLogOccurredAtand drop the raw timestamps. Notake— an application accrues a handful of rows at most.EmailStatusBadge({ status })tocomponents/features/status-badge.tsx, mirroringApplicationStatusBadge.ApplicationEmailHistory({ applicationId, user })incomponents/features/application-email-history.tsx:SectionCard title="Email history" titleAs="h2" subtitle="Emails sent to this applicant about this application.", then eitherSectionCardEmptyor the row list.<ul className="divide-y">, each<li className="px-4 py-3">with a first lineflex flex-wrap items-center gap-x-3 gap-y-1holding the subject (min-w-0 flex-1 text-sm font-medium) and the badge, then a second linetext-muted-foreground text-xsholding<LocalTime date={entry.occurredAt} precision="datetime" />and, whengetEmailLogDescriptionreturns a string,{' · '}plus that sentence. No links, no buttons — nothing in this section is interactive.'badge-stacked'case toSectionCardSkeletonRow:border-b px-4 py-3 last:border-0wrapping a first row ofh-4 flex-1+h-5 w-20 rounded-md, thenmt-1.5 h-3 w-48. Add it to theSectionCardSkeletonRowShapeunion; the existingneverdefault keeps the switch exhaustive.page.tsxas the last child of the sections stack, wrapped in<Suspense fallback={<SectionCardSkeleton rowShape="badge-stacked" hasSubtitle hasLink={false} rows={2} />}>— its own boundary, so a slow email query never holds up the answers.loading.tsxand reorder that file's cards tobadge-meta(other applications) → twoAnswersCardSkeleton→badge-stacked(email history), matching the page.docs/WORKFLOWS.md: one happy-path sentence for the new section (what it lists, thelistablescope through the relation, that OTP rows are structurally absent because they carry noapplicationId, and thatsentis not proof of receipt) and three failure/edge bullets (empty state; a bounce showing permanent vs transient; a scheduled decision email reading as not yet sent).prettier:check,eslint:check,tsc:check,test.Data & contracts
No migration, no schema change, no server action. Every column and enum member ships in #547 and the epic forbids a second migration; this ticket adds a read path only, so there is no zod schema and no
{ error }contract.Authorization is the whole contract.
getApplicationEmailHistorynever trusts its caller: the nestedapplication: buildApplicationWhere(user, 'listable')re-derives the scope inside the query, exactly asgetApplicationStatusHistorydoes, so a guessedapplicationIdreturns[]rather than another manager's mail. TheapplicationIdequality filter is what excludes OTP rows — they are written withapplicationId: nulland can never match — so no template filter exists to be forgotten or bypassed.Error model. Failure here is a data-fetch throw during render, which per §4 hits the single global boundary — nothing is caught, nothing is logged, no fallback row is invented. An empty result is a legitimate answer, not an error.
What crosses to the client. Only
ApplicationEmailEntry.tois withheld (redundant — the applicant's address is already in the page header) andproviderMessageId/errorare withheld as internal;bounceTypecrosses becausePermanentvsTransientis the difference between "wrong address" and "try later", which is the manager's actual question. #553 renders the rawerror— that page is admin-only.UX states
SectionCardSkeleton rowShape="badge-stacked"(2 rows) inside the section's own<Suspense>; the two-line row shape means no shift when the real list resolves.SectionCardEmptywith the lucideMailicon, title "No emails yet", description "Nothing has been emailed to this applicant about this application." No action button: there is nothing a reviewer can do here, and inventing one would imply a send.app/(main)/error.tsx) with its Try again; no per-section fallback.scheduled— "Not sent yet. Changing this application's status again cancels it."sent— "Handed off to the email provider — delivery not confirmed yet."delivered— no sentence; the badge and timestamp are the whole story.bounced+Permanent— "The address rejected it permanently — the applicant did not receive this."bounced+Transient— "Temporarily undeliverable — the applicant did not receive this."bounced, type unknown — "This could not be delivered."complained— "The applicant marked this as spam."suppressed— "Blocked before sending because the address is on the provider's suppression list."failed— "This was never sent."cancelled— "Cancelled before it was sent."titleAs="h2"keeps the page'sh1 → h2hierarchy intact; a semantic<ul>/<li>list; status is carried by badge text, not colour alone, and the description sentence states the outcome in words, so a bounce and a delivery are distinguishable without perceiving colour;LocalTimerenders a<time>with the exact instant indateTime/title. No focus or touch-target concerns — the section contains no interactive elements.flex-wrapwithgap-y-1so the badge drops below a long subject at 375px; no fixed widths.Testing
Nothing writes an
EmailLogrow with anapplicationIduntil #548 ships, so the manual pass needs hand-seeded rows. Use the Vercel preview (the local.envcan't sign in) and its Neon branch's SQL editor.EmailLogrows against that application's id, all with the applicant's address:application_received/sentwith asentAt;application_accepted/scheduledwith ascheduledAt~15 min out; adeliveredrow withdeliveredAt; abouncedrow withbounceType = 'Permanent'; acancelledrow.scheduledAt, the delivered row itsdeliveredAt), and the sentence from UX states.sentrow does not read as delivered.applicationId = NULL,template = 'otp'and the same recipient; reload — it does not appear.notFound(), so no email data is reachable.npm run prettier:check && npm run eslint:check && npm run tsc:check && npm run test.Automated tests
tests/db/application-email-history.test.ts— seed two managers, an admin, two positions and two applications, plusEmailLogrows created directly withprisma.emailLog.createand aTEST_PREFIXrecipient (cleanupFixturesalready sweeps those, and no fixture change is needed). Assert: the managing manager gets exactly that application's rows; the other manager gets[]; the admin gets the rows; a row on the other application never leaks in; anapplicationId: nullOTP row with the same recipient is absent; ordering iscreatedAtdesc with theidtiebreak; an application with no rows returns[]; each entry'soccurredAtis the status-appropriate column.tests/unit/constants.test.ts— every$Enums.EmailStatusmember has a label, a badge variant and a description entry (iterate the enum, so a future member fails the test rather than rendering blank);sentis not mapped tosuccess.tests/unit/utils.test.ts—getEmailLogDescriptionforbounced×Permanent/Transient/null, and forscheduled,sent,cancelled;deliveredreturnsnull.getEmailLogOccurredAtpicksscheduledAtforscheduled,deliveredAtfordelivered,sentAtforsent/bounced,createdAtforfailed/cancelled, and falls back tocreatedAtwhenever the preferred column is null.Risks / notes
lib/constants.tson purpose; whichever of the two lands second must import these maps, not add a second set. If Add An Admin Email Log Page #553 needs a longer, admin-flavoured wording it should add its own map beside these rather than editing the manager-facing copy.lib/email/delivery-events.tsisserver-only(it imports prisma), soEMAIL_STATUS_RANKcan't be reused in the UI — the rendering maps are a separate, client-safe concern, not a duplicate of it.revalidatePath. Nothing here mutates, and the webhook that flipssent → delivereddeliberately revalidates nothing — a manager holding the page open sees the old status until they reload. Acceptable: the timestamps are absolute, so a stale row is dated, not wrong.loading.tsx's current ordering is already stale relative to the page after Show A Reviewer An Applicant's Other Applications #550; fixing it is one line and in the same file this ticket edits, so it's folded in rather than left as a separate ticket.