@startsimpli/integrations 0.2.35 → 0.2.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Product update emails (A3): cadence, audience, "Send an update now" with
3
+ * optional notes, plus the pending drafts (DraftReview).
4
+ *
5
+ * Send-now is asynchronous: a 202 hands back a placeholder draft still being
6
+ * written, which is polled (drafts/{id}/) until done. It can also end with
7
+ * nothing customer-facing (200 straight away, or a draft the worker discarded),
8
+ * or point at the update already waiting for approval.
9
+ */
10
+ import { useCallback, useEffect, useState, type ReactElement } from 'react';
11
+ import { Button, Label, Textarea } from '@startsimpli/ui';
12
+ import { useQueryClient } from '@tanstack/react-query';
13
+
14
+ import type { CommsApi } from '../api/comms';
15
+ import { DEFAULT_POLL_MS, commsKeys, useDraft, usePrograms, useSendUpdateNow, useUpdateProgram } from '../hooks/index';
16
+ import {
17
+ DRAFT_PENDING,
18
+ NOTHING_CUSTOMER_FACING,
19
+ PRODUCT_UPDATE_PROGRAM_KEY,
20
+ type BroadcastAudience,
21
+ type BroadcastCadence,
22
+ type BroadcastProgram,
23
+ } from '../types/index';
24
+ import { DraftReview } from './DraftReview';
25
+ import { ErrorNotice, NativeSelect, Note, humanize, useFieldId } from './_shared';
26
+
27
+ export interface ProductUpdatesProps {
28
+ api: CommsApi;
29
+ pollIntervalMs?: number;
30
+ }
31
+
32
+ const CADENCES: { value: BroadcastCadence; label: string }[] = [
33
+ { value: 'on_deploy', label: 'Each deploy with customer-facing changes' },
34
+ { value: 'weekly', label: 'Weekly' },
35
+ { value: 'monthly', label: 'Monthly' },
36
+ { value: 'manual', label: 'Only when I click Send an update now' },
37
+ ];
38
+
39
+ const AUDIENCES: { value: BroadcastAudience; label: string }[] = [
40
+ { value: 'all', label: 'All customers' },
41
+ { value: 'active', label: 'Active customers' },
42
+ { value: 'inactive', label: 'Inactive customers' },
43
+ ];
44
+
45
+ const NOTHING = 'Nothing customer-facing has changed since the last update, so there is nothing to send.';
46
+
47
+ /** Follows one send-now draft until the worker has finished writing it. */
48
+ function SendNowProgress({
49
+ api,
50
+ id,
51
+ pollIntervalMs,
52
+ onFinished,
53
+ }: {
54
+ api: CommsApi;
55
+ id: string;
56
+ pollIntervalMs: number;
57
+ onFinished: (message: string | null, error: string | null) => void;
58
+ }) {
59
+ const qc = useQueryClient();
60
+ const draft = useDraft(api, id, pollIntervalMs);
61
+ const d = draft.data;
62
+ useEffect(() => {
63
+ if (draft.error) {
64
+ onFinished(null, draft.error.message);
65
+ return;
66
+ }
67
+ if (!d || d.generating) return;
68
+ void qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) });
69
+ if (d.error === NOTHING_CUSTOMER_FACING) onFinished(NOTHING, null);
70
+ else if (d.error) onFinished(null, `Writing this update failed: ${humanize(d.error)}`);
71
+ else onFinished(null, null);
72
+ }, [d, draft.error, qc, api.slug, onFinished]);
73
+ return <Note>Writing the update… this can take a few minutes.</Note>;
74
+ }
75
+
76
+ function Config({ api, program }: { api: CommsApi; program: BroadcastProgram }) {
77
+ const update = useUpdateProgram(api);
78
+ const cadenceId = useFieldId('comms-cadence');
79
+ const audienceId = useFieldId('comms-audience');
80
+ const set = (patch: Partial<BroadcastProgram['config']>) =>
81
+ update.mutate({ key: program.key, patch: { config: { ...program.config, ...patch } } });
82
+
83
+ return (
84
+ <div className="space-y-2">
85
+ <div className="flex flex-wrap gap-4">
86
+ <div className="space-y-1">
87
+ <Label htmlFor={cadenceId}>Cadence</Label>
88
+ <div>
89
+ <NativeSelect
90
+ id={cadenceId}
91
+ value={program.config.cadence}
92
+ disabled={update.isPending}
93
+ onChange={(e) => set({ cadence: e.target.value as BroadcastCadence })}
94
+ >
95
+ {CADENCES.map((c) => (
96
+ <option key={c.value} value={c.value}>{c.label}</option>
97
+ ))}
98
+ </NativeSelect>
99
+ </div>
100
+ </div>
101
+ <div className="space-y-1">
102
+ <Label htmlFor={audienceId}>Audience</Label>
103
+ <div>
104
+ <NativeSelect
105
+ id={audienceId}
106
+ value={program.config.audience}
107
+ disabled={update.isPending}
108
+ onChange={(e) => set({ audience: e.target.value as BroadcastAudience })}
109
+ >
110
+ {AUDIENCES.map((a) => (
111
+ <option key={a.value} value={a.value}>{a.label}</option>
112
+ ))}
113
+ </NativeSelect>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ <ErrorNotice error={update.error} />
118
+ </div>
119
+ );
120
+ }
121
+
122
+ export function ProductUpdates({ api, pollIntervalMs = DEFAULT_POLL_MS }: ProductUpdatesProps): ReactElement | null {
123
+ const { data: programs } = usePrograms(api);
124
+ const sendNow = useSendUpdateNow(api);
125
+ const notesId = useFieldId('comms-notes');
126
+ const [notes, setNotes] = useState('');
127
+ const [message, setMessage] = useState<string | null>(null);
128
+ const [failure, setFailure] = useState<string | null>(null);
129
+ const [following, setFollowing] = useState<string | null>(null);
130
+ const program = programs?.find((p) => p.key === PRODUCT_UPDATE_PROGRAM_KEY);
131
+
132
+ const onFinished = useCallback((msg: string | null, err: string | null) => {
133
+ setFollowing(null);
134
+ setMessage(msg);
135
+ setFailure(err);
136
+ }, []);
137
+
138
+ return (
139
+ <div className="space-y-4">
140
+ {program && program.kind === 'broadcast' ? <Config api={api} program={program} /> : null}
141
+ <form
142
+ className="space-y-2"
143
+ onSubmit={(e) => {
144
+ e.preventDefault();
145
+ setMessage(null);
146
+ setFailure(null);
147
+ sendNow.mutate(notes.trim() ? { notes: notes.trim() } : {}, {
148
+ onSuccess: (res) => {
149
+ if (res.reason === NOTHING_CUSTOMER_FACING) setMessage(NOTHING);
150
+ else if (res.reason === DRAFT_PENDING) setMessage('An update is already waiting for your approval below.');
151
+ else if (res.draft?.generating) setFollowing(res.draft.id);
152
+ if (res.draft) setNotes('');
153
+ },
154
+ });
155
+ }}
156
+ >
157
+ <div className="space-y-1">
158
+ <Label htmlFor={notesId}>Notes for this update (optional)</Label>
159
+ <Textarea
160
+ id={notesId}
161
+ rows={2}
162
+ value={notes}
163
+ placeholder="Anything the changelog will not show, e.g. a launch you want to mention"
164
+ onChange={(e) => setNotes(e.target.value)}
165
+ />
166
+ </div>
167
+ <Button type="submit" disabled={sendNow.isPending || !!following}>Send an update now</Button>
168
+ <Note>Writes a draft from what changed since the last update. Nothing goes out until it is approved, unless approval is set to automatic.</Note>
169
+ </form>
170
+ {following ? (
171
+ <SendNowProgress api={api} id={following} pollIntervalMs={pollIntervalMs} onFinished={onFinished} />
172
+ ) : null}
173
+ {message ? <Note>{message}</Note> : null}
174
+ <ErrorNotice error={sendNow.error ?? (failure ? new Error(failure) : null)} />
175
+ <DraftReview api={api} pollIntervalMs={pollIntervalMs} />
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The two switches (A1, A8): "Comeback sequence" and "Product update emails".
3
+ * Turning one off PATCHes enabled:false; the server cancels pending steps in
4
+ * the same transaction (and says how many), and the page says nothing further
5
+ * will go out. A refused write leaves the switch where it was.
6
+ */
7
+ import { useState, type ReactElement } from 'react';
8
+ import { Label, Switch } from '@startsimpli/ui';
9
+
10
+ import type { CommsApi } from '../api/comms';
11
+ import { usePrograms, useUpdateProgram } from '../hooks/index';
12
+ import {
13
+ COMEBACK_PROGRAM_KEY,
14
+ PRODUCT_UPDATE_PROGRAM_KEY,
15
+ type CommsProgram,
16
+ } from '../types/index';
17
+ import { ErrorNotice, Note, useFieldId } from './_shared';
18
+
19
+ export interface ProgramTogglesProps {
20
+ api: CommsApi;
21
+ }
22
+
23
+ function describe(p: CommsProgram): string {
24
+ if (p.kind === 'sequence') {
25
+ const t = p.config.trigger;
26
+ const when = t?.type === 'inactive' ? `have not logged in for ${t.days} days` : 'match the trigger';
27
+ return `Emails customers who ${when}. Stops when they come back.`;
28
+ }
29
+ return 'Tells customers what changed in the product.';
30
+ }
31
+
32
+ const LABELS: Record<string, string> = {
33
+ [COMEBACK_PROGRAM_KEY]: 'Comeback sequence',
34
+ [PRODUCT_UPDATE_PROGRAM_KEY]: 'Product update emails',
35
+ };
36
+
37
+ function Toggle({ api, program }: { api: CommsApi; program: CommsProgram }) {
38
+ const update = useUpdateProgram(api);
39
+ const [notice, setNotice] = useState<string | null>(null);
40
+ const id = useFieldId(`comms-switch-${program.key}`);
41
+ const label = LABELS[program.key] ?? program.key;
42
+
43
+ const onChange = (enabled: boolean) => {
44
+ setNotice(null);
45
+ update.mutate(
46
+ { key: program.key, patch: { enabled } },
47
+ {
48
+ onSuccess: (res) => {
49
+ if (enabled) return;
50
+ const n = res.cancelled ?? 0;
51
+ setNotice(
52
+ `${label} is off. Nothing further will be sent.` +
53
+ (n > 0 ? ` ${n} pending ${n === 1 ? 'email was' : 'emails were'} cancelled.` : ''),
54
+ );
55
+ },
56
+ },
57
+ );
58
+ };
59
+
60
+ return (
61
+ <div className="space-y-1 py-2">
62
+ <div className="flex items-start gap-3">
63
+ <Switch
64
+ id={id}
65
+ checked={program.enabled}
66
+ disabled={update.isPending}
67
+ onCheckedChange={onChange}
68
+ />
69
+ <div>
70
+ <Label htmlFor={id} className="font-medium">{label}</Label>
71
+ <Note>{describe(program)}</Note>
72
+ </div>
73
+ </div>
74
+ {notice ? <Note>{notice}</Note> : null}
75
+ <ErrorNotice error={update.error} />
76
+ </div>
77
+ );
78
+ }
79
+
80
+ export function ProgramToggles({ api }: ProgramTogglesProps): ReactElement | null {
81
+ const { data: programs } = usePrograms(api);
82
+ if (!programs) return null;
83
+ const shown = [COMEBACK_PROGRAM_KEY, PRODUCT_UPDATE_PROGRAM_KEY]
84
+ .map((k) => programs.find((p) => p.key === k))
85
+ .filter((p): p is CommsProgram => !!p);
86
+ return (
87
+ <div className="divide-y">
88
+ {shown.map((p) => (
89
+ <Toggle key={p.key} api={api} program={p} />
90
+ ))}
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Results (A7): sent, delivered, opened, clicked, came back, unsubscribed,
3
+ * bounced and complaints, over the server's default window (last 30 days).
4
+ *
5
+ * "Came back after an email" is attribution by timing: the customer logged in
6
+ * again while enrolled, after at least one email. It does not prove the email
7
+ * caused it, and the label says no more than that.
8
+ */
9
+ import type { ReactElement } from 'react';
10
+
11
+ import type { CommsApi } from '../api/comms';
12
+ import { useCommsMetrics } from '../hooks/index';
13
+ import type { CommsMetrics } from '../types/index';
14
+ import { ErrorNotice, Note } from './_shared';
15
+
16
+ export interface ResultsPanelProps {
17
+ api: CommsApi;
18
+ programKey?: string;
19
+ }
20
+
21
+ const ROWS: { key: keyof CommsMetrics; label: string }[] = [
22
+ { key: 'sent', label: 'Sent' },
23
+ { key: 'delivered', label: 'Delivered' },
24
+ { key: 'opened', label: 'Opened' },
25
+ { key: 'clicked', label: 'Clicked' },
26
+ { key: 'came_back', label: 'Came back after an email' },
27
+ { key: 'unsubscribed', label: 'Unsubscribed' },
28
+ { key: 'bounced', label: 'Bounced' },
29
+ { key: 'complained', label: 'Marked as spam' },
30
+ ];
31
+
32
+ export function ResultsPanel({ api, programKey }: ResultsPanelProps): ReactElement | null {
33
+ const metrics = useCommsMetrics(api, programKey ? { program: programKey } : {});
34
+ if (metrics.error) return <ErrorNotice error={metrics.error} />;
35
+ if (!metrics.data) return null;
36
+ const m = metrics.data;
37
+ return (
38
+ <div className="space-y-2">
39
+ <dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-4">
40
+ {ROWS.map((r) => (
41
+ <div key={r.key} className="flex justify-between gap-2 sm:block">
42
+ <dt className="text-muted-foreground">{r.label}</dt>
43
+ <dd className="font-medium tabular-nums">{m[r.key]}</dd>
44
+ </div>
45
+ ))}
46
+ </dl>
47
+ <Note>Last 30 days. "Came back" counts customers who logged in again after an email; it does not prove the email brought them back.</Note>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Send log (A7): SERVER-SIDE pagination. page/page_size go to the server; the
3
+ * total shown is the server count, never a client-side slice. Changing a filter
4
+ * goes back to page 1.
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import {
8
+ Button,
9
+ Label,
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ } from '@startsimpli/ui';
17
+
18
+ import type { CommsApi } from '../api/comms';
19
+ import { useSends } from '../hooks/index';
20
+ import { COMEBACK_PROGRAM_KEY, PRODUCT_UPDATE_PROGRAM_KEY } from '../types/index';
21
+ import { ErrorNotice, NativeSelect, formatDate, useFieldId } from './_shared';
22
+
23
+ export interface SendLogProps {
24
+ api: CommsApi;
25
+ /** Rows per server page. Default 25; the server caps at 100. */
26
+ pageSize?: number;
27
+ }
28
+
29
+ const PROGRAMS: { value: string; label: string }[] = [
30
+ { value: '', label: 'All' },
31
+ { value: COMEBACK_PROGRAM_KEY, label: 'Comeback' },
32
+ { value: PRODUCT_UPDATE_PROGRAM_KEY, label: 'Product updates' },
33
+ ];
34
+
35
+ const STATUSES = ['', 'pending', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed', 'suppressed', 'complained'];
36
+
37
+ const programLabel = (key: string) => PROGRAMS.find((p) => p.value === key)?.label ?? key;
38
+
39
+ export function SendLog({ api, pageSize = 25 }: SendLogProps): ReactElement {
40
+ const [page, setPage] = useState(1);
41
+ const [program, setProgram] = useState('');
42
+ const [status, setStatus] = useState('');
43
+ const programId = useFieldId('comms-log-program');
44
+ const statusId = useFieldId('comms-log-status');
45
+ const sends = useSends(api, { page, page_size: pageSize, program: program || undefined, status: status || undefined });
46
+ const data = sends.data;
47
+ const count = data?.count ?? 0;
48
+ const first = count ? (page - 1) * pageSize + 1 : 0;
49
+ const last = data ? first + data.results.length - (count ? 1 : 0) : 0;
50
+
51
+ return (
52
+ <div className="space-y-3">
53
+ <div className="flex flex-wrap gap-4">
54
+ <div className="space-y-1">
55
+ <Label htmlFor={programId}>Program</Label>
56
+ <div>
57
+ <NativeSelect id={programId} value={program} onChange={(e) => { setProgram(e.target.value); setPage(1); }}>
58
+ {PROGRAMS.map((p) => (
59
+ <option key={p.value} value={p.value}>{p.label}</option>
60
+ ))}
61
+ </NativeSelect>
62
+ </div>
63
+ </div>
64
+ <div className="space-y-1">
65
+ <Label htmlFor={statusId}>Status</Label>
66
+ <div>
67
+ <NativeSelect id={statusId} value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
68
+ {STATUSES.map((s) => (
69
+ <option key={s} value={s}>{s ? s.charAt(0).toUpperCase() + s.slice(1) : 'All'}</option>
70
+ ))}
71
+ </NativeSelect>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ <ErrorNotice error={sends.error} />
76
+ <Table>
77
+ <TableHeader>
78
+ <TableRow>
79
+ <TableHead>Email</TableHead>
80
+ <TableHead>Program</TableHead>
81
+ <TableHead>Step</TableHead>
82
+ <TableHead>Subject</TableHead>
83
+ <TableHead>Status</TableHead>
84
+ <TableHead>Sent</TableHead>
85
+ </TableRow>
86
+ </TableHeader>
87
+ <TableBody>
88
+ {data && data.results.length === 0 ? (
89
+ <TableRow>
90
+ <TableCell colSpan={6} className="text-muted-foreground">No emails sent yet.</TableCell>
91
+ </TableRow>
92
+ ) : null}
93
+ {data?.results.map((r) => (
94
+ <TableRow key={r.id}>
95
+ <TableCell>{r.email}</TableCell>
96
+ <TableCell>{programLabel(r.program_key)}</TableCell>
97
+ <TableCell>{r.step_index === null ? '' : r.step_index + 1}</TableCell>
98
+ <TableCell>{r.subject}</TableCell>
99
+ <TableCell title={r.error || undefined}>{r.status}</TableCell>
100
+ <TableCell>{formatDate(r.sent_at)}</TableCell>
101
+ </TableRow>
102
+ ))}
103
+ </TableBody>
104
+ </Table>
105
+ <div className="flex flex-wrap items-center justify-between gap-2 text-sm">
106
+ <span>{`${first}–${last} of ${count}`}</span>
107
+ <div className="flex gap-2">
108
+ <Button type="button" size="sm" variant="outline" disabled={!data?.previous || sends.isFetching} onClick={() => setPage(page - 1)}>
109
+ Previous
110
+ </Button>
111
+ <Button type="button" size="sm" variant="outline" disabled={!data?.next || sends.isFetching} onClick={() => setPage(page + 1)}>
112
+ Next page
113
+ </Button>
114
+ </div>
115
+ </div>
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Sending domain (A6): connect, re-check, SPF/DKIM/DMARC records with their
3
+ * status, and the fallback-stream note while unverified. The From address in
4
+ * use comes from the server (settings.from_address).
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import {
8
+ Badge,
9
+ Button,
10
+ Input,
11
+ Label,
12
+ Table,
13
+ TableBody,
14
+ TableCell,
15
+ TableHead,
16
+ TableHeader,
17
+ TableRow,
18
+ } from '@startsimpli/ui';
19
+
20
+ import type { CommsApi } from '../api/comms';
21
+ import { useCheckDomain, useCommsSettings, useConnectDomain } from '../hooks/index';
22
+ import type { DomainRecord, DomainStatus } from '../types/index';
23
+ import { ErrorNotice, Note, formatDate, useFieldId } from './_shared';
24
+
25
+ export interface SendingDomainProps {
26
+ api: CommsApi;
27
+ }
28
+
29
+ const STATUS: Record<DomainStatus, string> = {
30
+ none: 'Not connected',
31
+ pending: 'Pending',
32
+ verified: 'Verified',
33
+ failed: 'Failed',
34
+ };
35
+
36
+ /** The server says which record is which (G7); older rows fall back to the content. */
37
+ export function recordLabel(r: DomainRecord): string {
38
+ if (r.purpose) return r.purpose.toUpperCase();
39
+ const v = r.value.toLowerCase();
40
+ const h = r.host.toLowerCase();
41
+ if (v.startsWith('v=spf1')) return 'SPF';
42
+ if (h.startsWith('_dmarc') || v.startsWith('v=dmarc1')) return 'DMARC';
43
+ if (h.includes('_domainkey')) return 'DKIM';
44
+ return r.type;
45
+ }
46
+
47
+ export function SendingDomain({ api }: SendingDomainProps): ReactElement | null {
48
+ const { data: settings } = useCommsSettings(api);
49
+ const connect = useConnectDomain(api);
50
+ const check = useCheckDomain(api);
51
+ const id = useFieldId('comms-domain');
52
+ const [domain, setDomain] = useState<string | null>(null);
53
+ if (!settings) return null;
54
+
55
+ const value = domain ?? settings.sending_domain ?? '';
56
+ const verified = settings.domain_status === 'verified';
57
+
58
+ return (
59
+ <div className="space-y-3">
60
+ <form
61
+ className="flex flex-wrap items-end gap-2"
62
+ onSubmit={(e) => {
63
+ e.preventDefault();
64
+ if (value.trim()) connect.mutate(value.trim());
65
+ }}
66
+ >
67
+ <div className="space-y-1">
68
+ <Label htmlFor={id}>Domain</Label>
69
+ <Input id={id} value={value} placeholder="example.com" onChange={(e) => setDomain(e.target.value)} />
70
+ </div>
71
+ <Button type="submit" disabled={connect.isPending || !value.trim()}>Connect domain</Button>
72
+ </form>
73
+
74
+ {settings.sending_domain ? (
75
+ <div className="flex flex-wrap items-center gap-2 text-sm">
76
+ <div className="flex items-center gap-1">
77
+ Status: <Badge variant={verified ? 'default' : 'secondary'}>{STATUS[settings.domain_status]}</Badge>
78
+ </div>
79
+ {settings.domain_checked_at ? <Note>Last checked {formatDate(settings.domain_checked_at)}</Note> : null}
80
+ <Button type="button" variant="outline" size="sm" disabled={check.isPending} onClick={() => check.mutate()}>
81
+ Check now
82
+ </Button>
83
+ </div>
84
+ ) : null}
85
+
86
+ {settings.domain_records.length > 0 ? (
87
+ <Table>
88
+ <TableHeader>
89
+ <TableRow>
90
+ <TableHead>Record</TableHead>
91
+ <TableHead>Type</TableHead>
92
+ <TableHead>Host</TableHead>
93
+ <TableHead>Value</TableHead>
94
+ <TableHead>OK</TableHead>
95
+ </TableRow>
96
+ </TableHeader>
97
+ <TableBody>
98
+ {settings.domain_records.map((r, i) => (
99
+ <TableRow key={`${r.host}-${i}`}>
100
+ <TableCell>{recordLabel(r)}</TableCell>
101
+ <TableCell>{r.type}</TableCell>
102
+ <TableCell className="break-all font-mono text-xs">{r.host}</TableCell>
103
+ <TableCell className="break-all font-mono text-xs">{r.value}</TableCell>
104
+ <TableCell>{r.ok ? 'yes' : 'no'}</TableCell>
105
+ </TableRow>
106
+ ))}
107
+ </TableBody>
108
+ </Table>
109
+ ) : null}
110
+
111
+ <Note>
112
+ {verified
113
+ ? `Mail goes out from ${settings.from_address ?? settings.sending_domain}.`
114
+ : 'Until a domain is verified, mail goes out via the StartSimpli marketing stream' +
115
+ (settings.from_address ? ` as ${settings.from_address}.` : '.')}
116
+ </Note>
117
+ <ErrorNotice error={connect.error ?? check.error} />
118
+ </div>
119
+ );
120
+ }