Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/Mailbox.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/Mailbox.tsx |
| Original SHA-256 | 5d07e46fe0788b07706406f304d4c585ec94ecdfe3b05b7d36785875db68ecb5 |
| Git blob SHA | a241b182ec95596045046cf08af5a4925b519c74 |
| Reading-copy SHA-256 | fb3e055cb3828c6ab96e834b24ee77dea45261bd2eab1dfa9aad77164544f467 |
Recovered source transcript
Download complete source text ↓330 lines · 13.4 KB
Credential-bearing and unrelated personal details were removed from this reading copy. The source identity and original checksum remain recorded.
import { useEffect, useState } from 'react'
import { AlertTriangle, CheckCircle2, Inbox, PlugZap, RefreshCw, Unplug } from 'lucide-react'
import { Badge, Panel } from '../components/ui'
import { Labeled, Note, Toggle, btnGhost, btnPrimary, fieldCls } from '../components/form'
import { CAP, can } from '../lib/can'
import { useAuth } from '../lib/useAuth'
import {
useDisconnectMailbox,
useMailAccount,
useSaveMailAccount,
useSetMailboxEnabled,
useSyncMailbox,
useTestMailbox,
type MailAccountInput,
} from '../lib/mailbox'
// THE PERSONAL MAILBOX.
//
// This connects ONE recruiter's own mail account so that correspondence with candidates lands on the
// candidate record without anybody copying and pasting it. It is per person, not per organization, which is
// why it is its own screen and not a panel in settings: settings is owner gated, and a recruiter must be
// able to connect their own mailbox without an owner doing it for them.
//
// WHAT IS READ IS BOUNDED AND THE SCREEN SAYS SO, prominently and before anything is connected. Headers
// only, never bodies. Mail with people who are not already in this organization's records is discarded
// rather than stored. Somebody handing over their work mail password deserves to read exactly that in the
// place where they hand it over, not in a policy document.
const empty: MailAccountInput = {
imap_host: '',
imap_port: 993,
imap_username: '',
password: '',
mailbox: 'INBOX',
lookback_days: 30,
}
export function Mailbox() {
const { principal } = useAuth()
const q = useMailAccount()
const save = useSaveMailAccount()
const test = useTestMailbox()
const disconnect = useDisconnectMailbox()
const setEnabled = useSetMailboxEnabled()
const sync = useSyncMailbox()
const [form, setForm] = useState<MailAccountInput>(empty)
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const a = q.data?.account ?? null
const connected = !!q.data?.connected && !!a
// Seeded from the server once it resolves, so an edit starts from what is actually stored rather than from
// blanks that would read as a mailbox that was never configured. The password is [credential removed] seeded: it is not
// returned by any route, and an empty box next to "a password is [credential removed]" is the honest rendering.
useEffect(() => {
if (!a) return
setForm({
imap_host: a.imap_host,
imap_port: a.imap_port,
imap_username: a.imap_username,
password: '',
mailbox: a.mailbox,
lookback_days: a.lookback_days,
})
}, [a])
if (!can(principal, CAP.candidateWrite)) {
return (
<Panel className="grid place-items-center py-24 text-center text-muted">
<div className="max-w-md">
<p className="text-ink">Connecting a mailbox is for people who work candidate records.</p>
<p className="mt-2 text-sm">
Your account can read records but not change them, so there is nothing here to log correspondence
against.
</p>
</div>
</Panel>
)
}
const verified = !!a?.verified_at
const set = (patch: Partial<MailAccountInput>) => setForm({ ...form, ...patch })
return (
<div className="space-y-4">
<Panel title="Your mailbox" hint="log candidate correspondence without copying it by hand">
<div className="space-y-2 pb-3">
<p className="text-[11px] leading-relaxed text-faint">
Connect the mail account you already write to candidates from. missionMATCH then reads the
headers of your recent mail and logs an activity on the matching candidate record, so the history
on a record is what actually happened rather than what somebody remembered to type.
</p>
<Note tone="muted">
<strong className="font-semibold">What is read.</strong> Only the headers: who a message was
from, who it was to, the subject and the time. Never the body of a message, and never an
attachment. Mail with people who are not already in this organization's records is discarded
and never stored. This is your own mailbox, and nobody else in the organization can configure it
or see what it read.
</Note>
</div>
<div className="grid gap-2.5 sm:grid-cols-2">
<Labeled label="Mail server" hint="the IMAP server, usually the one your mail client uses">
<input
value={form.imap_host}
maxLength={255}
placeholder="imap.yourcompany.com"
onChange={(e) => set({ imap_host: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Port" hint="993 is the usual encrypted port">
<input
type="number"
value={form.imap_port}
onChange={(e) => set({ imap_port: Number(e.target.value) })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Sign in name" hint="usually your full email address">
<input
value={form.imap_username}
maxLength={255}
onChange={(e) => set({ imap_username: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled
label="Password"
hint={
a?.password_set
? 'a password is [credential removed], type to replace it, and it is stored encrypted and never shown again'
: 'stored encrypted and never shown again, to anybody, including you'
}
>
<input
type="password"
value={form.password ?? ''}
maxLength={255}
placeholder={a?.password_set ? 'saved' : ''}
onChange={(e) => set({ password: [credential removed] })}
className={fieldCls}
/>
</Labeled>
<Labeled label="Folder" hint="INBOX unless you file candidate mail somewhere specific">
<input
value={form.mailbox}
maxLength={120}
onChange={(e) => set({ mailbox: e.target.value })}
className={fieldCls}
/>
</Labeled>
<Labeled label="How far back to look" hint="each sync examines this many days of recent mail">
<select
value={form.lookback_days}
onChange={(e) => set({ lookback_days: Number(e.target.value) })}
className={fieldCls}
>
<option value={7} className="bg-navy-900">
7 days
</option>
<option value={30} className="bg-navy-900">
30 days
</option>
<option value={90} className="bg-navy-900">
90 days
</option>
</select>
</Labeled>
</div>
{/* Many providers refuse an account password over IMAP and want an application specific one. Saying
so here saves the support conversation that a bare authentication failure starts. */}
<p className="mt-2 text-[11px] leading-relaxed text-faint">
If your mail provider uses two step sign in, this needs an app password rather than the password you
type into a browser. Your provider's security settings is where one is created.
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={save.isPending || !form.imap_host || !form.imap_username || (!a?.password_set && !form.password)}
onClick={() => save.mutate(form)}
className={btnPrimary}
>
<PlugZap size={13} /> {save.isPending ? 'Saving' : connected ? 'Save changes' : 'Connect'}
</button>
{connected && (
<button type="button" onClick={() => test.mutate()} disabled={test.isPending} className={btnGhost}>
<RefreshCw size={13} /> {test.isPending ? 'Checking' : 'Test the connection'}
</button>
)}
{connected &&
(confirmDisconnect ? (
<>
<button
type="button"
className={btnGhost}
onClick={() =>
disconnect.mutate(undefined, {
onSuccess: () => {
setConfirmDisconnect(false)
setForm(empty)
},
})
}
>
<Unplug size={13} /> Disconnect it
</button>
<button type="button" className={btnGhost} onClick={() => setConfirmDisconnect(false)}>
Keep it
</button>
</>
) : (
<button type="button" className={btnGhost} onClick={() => setConfirmDisconnect(true)}>
<Unplug size={13} /> Disconnect
</button>
))}
</div>
<div className="mt-3 space-y-2">
{confirmDisconnect && (
<Note tone="warn">
Disconnecting removes the stored password and the sync history. The activities already logged on
candidate records stay, because they are a record of what happened.
</Note>
)}
{save.isError && <Note tone="risk">{(save.error as Error).message}</Note>}
{test.isError && <Note tone="risk">{(test.error as Error).message}</Note>}
{test.isSuccess && <Note tone="ok">The mailbox answered and the sign in worked.</Note>}
{a?.last_error && !test.isSuccess && !test.isError && (
<Note tone="risk">The last attempt failed: {a.last_error}</Note>
)}
</div>
</Panel>
{connected && a && (
<Panel title="Syncing" hint="what gets logged, and when">
<div className="flex flex-wrap items-center gap-3">
{verified ? (
<Badge color="var(--color-ok)" subtle>
<CheckCircle2 size={11} /> Verified
</Badge>
) : (
<Badge color="var(--color-warn)" subtle>
<AlertTriangle size={11} /> Not verified
</Badge>
)}
<span className="text-[11px] text-faint">
{a.last_sync_at
? `Last synced ${new Date(a.last_sync_at).toLocaleString()}`
: 'Never synced'}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm font-semibold text-ink">Log my candidate correspondence</span>
<Toggle
label="Log my candidate correspondence"
value={a.enabled}
disabled={!verified || setEnabled.isPending}
onChange={(v) => setEnabled.mutate(v)}
/>
</div>
</div>
{!verified && (
<div className="mt-3">
<Note tone="warn">
Syncing stays off until the connection is tested. An unproven mailbox never syncs, so a typo
surfaces here rather than as a history that quietly stays empty.
</Note>
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line/40 pt-3">
<button
type="button"
disabled={!verified || sync.isPending}
onClick={() => sync.mutate()}
className={btnGhost}
>
<Inbox size={13} /> {sync.isPending ? 'Reading your mail' : 'Sync now'}
</button>
<span className="text-[11px] text-faint">
Reads the last {a.lookback_days} days of {a.mailbox}.
</span>
</div>
{sync.isError && (
<div className="mt-2">
<Note tone="risk">{(sync.error as Error).message}</Note>
</div>
)}
{sync.isSuccess && sync.data && (
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<SyncStat label="Examined" value={sync.data.examined} />
<SyncStat label="Matched a candidate" value={sync.data.matched} />
<SyncStat label="Logged" value={sync.data.logged} />
{/* The discarded count is shown rather than hidden. It is the number that proves the boundary
claim above: mail with people who are not in the records went nowhere. */}
<SyncStat label="Discarded" value={sync.data.unmatched_and_discarded} />
</div>
)}
{sync.isSuccess && sync.data?.note && (
<p className="mt-2 text-[11px] text-faint">{sync.data.note}</p>
)}
{sync.isSuccess && sync.data && sync.data.logged === 0 && (
<div className="mt-2">
<Note tone="muted">
Nothing new was logged. Either every message was already known, or none of the recent mail was
with somebody in this organization's records.
</Note>
</div>
)}
</Panel>
)}
</div>
)
}
function SyncStat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-line/60 bg-navy-950/40 px-3 py-2">
<div className="text-lg font-bold tabular-nums text-ink">{value}</div>
<div className="text-[10px] uppercase tracking-wider text-faint">{label}</div>
</div>
)
}