import { useEffect, useRef, useState } from 'react'; import { AlertCircle, CheckCircle2, Clock, Loader2 } from 'lucide-react'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useOrganizationServiceContext } from '../../../services/OrganizationService.tsx'; import { resolveCampaignSendingIdentity, type CampaignSendingAnswer, type CampaignSendingSetup, type CampaignSendingState, type DnsRecord, } from '../../../config/campaignMailingAddress.ts'; import { Button } from '../../../components/ui/button.tsx'; import { Input } from '../../../components/ui/input.tsx'; import { Label } from '../../../components/ui/label.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { relayServerFailure, ownFailureSentence } from '../../../utils/fetchFailureMessage'; import SandboxDisabled from '../../../components/SandboxMode/SandboxDisabled'; import { environment } from '../../../environments/environment'; import { isSaveRetired, isSaveTimedOut, outcomeUnknown, useSaveScope, type SaveTransport } from '../../../utils/saveOperation'; // phase 2 - the practice verifies its OWN sending domain. // // WHY A PRACTICE HAS TO DO THIS AT ALL, stated on screen rather than assumed: marketing email // goes out from THEIR domain, not ours. Spencer's ruling, and the reason is deliverability - SES // reputation is pooled, so one practice's stale list would otherwise degrade every other practice's // appointment reminders. Until this is verified, campaigns do not send. That is a hard bar, so the // screen has to be honest about it rather than presenting an optional-looking form. // // The DNS records come FROM THE SERVER and are rendered as given. Deriving them here would be a // second definition of something a human retypes into a registrar - and a wrong host does not error // anywhere, it just means the domain never verifies. const StatusLine = ({ status, mailFromStatus, }: { status: 'pending' | 'verified' | 'failed'; mailFromStatus: 'pending' | 'verified' | 'failed' | null; }) => { // DKIM AND THE MAIL FROM VERIFY SEPARATELY, and both are required. Saying only "pending" // sends a practice back to re-read three CNAMEs that are already correct while the MX is the // thing actually missing - and the MX is the one people skip, because it looks like an extra. const mailFromOutstanding = status !== 'verified' && mailFromStatus !== 'verified'; if (status === 'verified') { return ( {/* Says the CONSEQUENCE, not just the state. "Verified" alone leaves a practice wondering whether anything else is needed before they can run a campaign. */} Verified — your campaigns can send, and patients will see your practice as the sender. ); } if (status === 'failed') { return ( Verification failed. Check that each record below was added exactly as shown, then check again. ); } return ( {/* Pending is NOT an error and must not read like one — DNS propagation is minutes to days, and a practice that reads this as a failure will start over needlessly. */} Waiting on DNS. Add the records below at your domain provider, then check again. This can take anywhere from a few minutes to a couple of days. {mailFromOutstanding && ( <> {' '} The MX and TXT records are still outstanding — those are the ones people most often miss, and mail will not send without them. )} ); }; const DnsTable = ({ records }: { records: DnsRecord[] }) => (
{records.map((record) => ( ))}
Type Name / Host Value
{record.type} {record.host} {record.value}
); // Why a setup cannot continue on its own, in the practice's terms. Each one is something a person // can act on or wait for; none of them reads as "nothing happened". const HELD: Record = { 'provider-mismatch': 'Our email provider shows this domain set up differently from what we asked for, so we stopped rather than guess. Contact support.', 'provider-unconfirmed': 'We could not confirm one step with our email provider yet. Continue the setup later; nothing will be sent twice.', 'uncertain-call': 'An earlier setup for this domain left a step we could not confirm, so we will not repeat it. Contact support.', configuration: 'We could not finish preparing your sending settings. Continue the setup to try again.', superseded: 'Your sending domain changed after this setup started, so it was not applied. Cancel it and start again.', }; const SetupPanel = ({ setup }: { setup: CampaignSendingSetup }) => ( Setting up {setup.domain}.{' '} {setup.held ? HELD[setup.held] ?? 'This setup is on hold. Contact support.' : setup.phase === 'provider-ready' ? 'Ready to apply. Continue the setup to finish.' : 'Continue the setup to finish the remaining steps.'} ); const CampaignSendingDomainSection = () => { const { currentOrganization, setWpOrganization } = useAuthServiceContext(); const { getCampaignSendingIdentity, updateCampaignSendingIdentity, checkCampaignSendingIdentity } = useOrganizationServiceContext(); const [sending, setSending] = useState(null); const [domain, setDomain] = useState(''); const [busy, setBusy] = useState(false); const [reading, setReading] = useState(false); const [error, setError] = useState(); const [unconfirmed, setUnconfirmed] = useState(false); const [providerUnavailable, setProviderUnavailable] = useState(false); const working = useRef(false); // One sequence for every read and action: only the latest-started answer may publish. const sequence = useRef(0); const scope = useSaveScope(currentOrganization?.id, () => { working.current = false; setBusy(false); setReading(false); }); // Until the server answers, the copy on the organization is all there is; it carries no records or setup. const selected = sending ? sending.selected?.identity ?? null : resolveCampaignSendingIdentity(currentOrganization); const setup = sending?.setup ?? null; const run = async ( kind: 'read' | 'write', action: (transport: SaveTransport) => Promise, ) => { if (environment.inSandboxMode || (kind === 'write' && working.current)) return; const mine = ++sequence.current; const operation = scope.start(); const latest = () => operation.belongs() && sequence.current === mine; if (kind === 'write') { working.current = true; setBusy(true); } else { setReading(true); } setError(undefined); const publish = (answer: CampaignSendingAnswer) => { // Rendered as the server gave them - never re-derived here, and never into the typed domain. setSending(answer.sending); setProviderUnavailable(answer.provider === 'unavailable'); setUnconfirmed(false); if (answer.organization) setWpOrganization(answer.organization as never); }; try { const answer = await operation.step(() => action(operation.transport), kind === 'write'); operation.admit(); if (!latest()) return; publish(answer); } catch (err) { if (!operation.belongs() || isSaveRetired(err) || !latest()) return; console.error('Campaign sending-domain request failed:', err); // A refusal carries the stored state as it now is; show that rather than what this screen assumed. const refused = (err as { body?: Partial } | null)?.body; if (refused?.sending) publish(refused as CampaignSendingAnswer); if (kind === 'write' && outcomeUnknown(err)) { setUnconfirmed(true); setError('We couldn\'t confirm that change. Read the current status before trying again.'); } else { // Surfaced verbatim: the server's 400 explains what to type ("not a full web address"). setError(isSaveTimedOut(err) ? ownFailureSentence(err) : relayServerFailure(err, kind === 'read' ? 'Could not read the sending domain. Try again.' : 'Could not save the sending domain. Try again.')); if (kind === 'read') setUnconfirmed(true); } } finally { if (operation.belongs()) { if (kind === 'write') working.current = false; if (sequence.current === mine) { setBusy(false); setReading(false); } } operation.retire(); } }; // Keyed on the org ID, not the object - every sibling section carries this guard: saving calls // setWpOrganization with a NEW reference, so object-keying re-seeds and wipes in-flight edits. useEffect(() => { setDomain(resolveCampaignSendingIdentity(currentOrganization)?.domain ?? ''); setSending(null); setError(undefined); setUnconfirmed(false); setProviderUnavailable(false); if (currentOrganization?.id && !environment.inSandboxMode) void run('read', getCampaignSendingIdentity); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentOrganization?.id]); const typed = domain.trim().toLowerCase(); const replacing = setup && typed !== setup.domain ? setup : null; const start = () => { if (!sending) return; void run('write', (transport) => updateCampaignSendingIdentity({ domain: domain.trim(), basisRevision: sending.revision, ...(replacing ? { replaceAttemptId: replacing.attemptId } : {}), }, transport)); }; const locked = busy || environment.inSandboxMode; const chosen = sending?.selected ?? null; const selectedRecords = chosen && chosen.identity.status !== 'verified' ? chosen.dnsRecords : []; return (

Marketing Email Sending Domain

Marketing emails are sent from your practice's own domain, so patients see you as the sender. Campaigns cannot send until this is verified.

{error && ( {error} )} {unconfirmed && ( )} {providerUnavailable && (

We could not reach our email provider just now. The status below is the last one we confirmed.

)} {/* THE UNSET CASE SAYS SO. Before a practice acts it would otherwise see a heading, a field and a disabled button, which reads as an optional preference rather than the step that stops campaigns sending. It is not an error and must not look like one. */} {selected ? : ( No sending domain yet, so campaigns cannot send. Enter the domain your practice sends email from below and we will give you the records to add at your domain provider. )}
setDomain(event.target.value)} />
{!sending && reading &&

Reading the current status.

} {chosen && ( )} {chosen && selectedRecords.length > 0 && (

Add these records for {chosen.identity.domain} at your domain provider. All of them are required — {' '} {/* The MX and TXT are not optional extras: without them SPF aligns to Amazon rather than the practice, and a practice publishing a DMARC policy fails its own mail. */} the MX and TXT records are what stop your mail being marked as spam.

)} {setup && sending && (
{setup.dnsRecords.length > 0 && ( <>

Records for {setup.domain}. All of them are required once the setup finishes.

)}
)}
); }; export default CampaignSendingDomainSection;