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[] }) => (
Type
Name / Host
Value
{records.map((record) => (
{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.
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.
)}
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.