diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 123aae8..3b76446 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -1253,3 +1253,4 @@ .dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } .dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; } .dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; } + .dash-root .settings-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; } diff --git a/src/components/dashboard/settings.tsx b/src/components/dashboard/settings.tsx index 486d245..34d44f6 100644 --- a/src/components/dashboard/settings.tsx +++ b/src/components/dashboard/settings.tsx @@ -12,9 +12,11 @@ import { useState } from "react"; import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui"; import { useSmsSettings } from "@/lib/sms-settings-api"; +import { useSmtpSettings } from "@/lib/smtp-settings-api"; const SID_RE = /^AC[0-9a-fA-F]{32}$/; const E164_RE = /^\+[1-9]\d{6,14}$/; +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; export function Settings() { return ( @@ -29,7 +31,7 @@ export function Settings() {

Integrations

- +
@@ -120,17 +122,101 @@ function TwilioCard() { ); } -function SmtpComingSoon() { +function SmtpCard() { + const toast = useToast(); + const { status, loading, live, configure } = useSmtpSettings(); + const [editing, setEditing] = useState(false); + const [host, setHost] = useState(""); + const [port, setPort] = useState("587"); + const [secure, setSecure] = useState(false); + const [user, setUser] = useState(""); + const [pass, setPass] = useState(""); + const [fromEmail, setFromEmail] = useState(""); + const [fromName, setFromName] = useState(""); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + + const showForm = editing || (!loading && !status.configured); + + function validate(): boolean { + const e: Record = {}; + if (!host.trim()) e.host = "SMTP host is required."; + const p = Number(port); + if (!Number.isInteger(p) || p < 1 || p > 65535) e.port = "Port must be 1–65535."; + if (!user.trim()) e.user = "Username is required."; + if (!pass.trim()) e.pass = "Password is required."; + if (!EMAIL_RE.test(fromEmail.trim())) e.fromEmail = "A valid from-address is required."; + setErrors(e); + return Object.keys(e).length === 0; + } + + async function save() { + if (!validate()) return; + setSaving(true); + try { + await configure({ host: host.trim(), port: Number(port), secure, user: user.trim(), pass: pass.trim(), fromEmail: fromEmail.trim(), ...(fromName.trim() ? { fromName: fromName.trim() } : {}) }); + toast.push({ tone: "success", title: "SMTP connected", desc: "Outbound email now sends from your server." }); + setHost(""); setPort("587"); setSecure(false); setUser(""); setPass(""); setFromEmail(""); setFromName(""); setErrors({}); setEditing(false); + } catch (err) { + toast.push({ tone: "error", title: "Couldn't save SMTP settings", desc: (err as Error).message }); + } finally { + setSaving(false); + } + } + return ( -
+
Email · SMTP
-
Bring your own SMTP server for outbound email.
+
Send external email from your own mail server.
- Coming soon + {status.configured ? Connected : Not connected}
+ + {status.configured && !editing ? ( +
+
+
From
{status.fromName ? `${status.fromName} · ` : ""}{status.fromEmail ?? "—"}
+
Server
{status.host ?? "—"}{status.port ? `:${status.port}` : ""}
+
Status
{status.enabled ? "Active" : "Disabled"}
+
+ setEditing(true)}>Update credentials +
+ ) : null} + + {showForm ? ( +
+ + setHost(e.target.value)} placeholder="smtp.example.com" autoComplete="off" /> + + + setPort(e.target.value)} placeholder="587" autoComplete="off" /> + + + + setUser(e.target.value)} placeholder="apikey / user@example.com" autoComplete="off" /> + + + setPass(e.target.value)} placeholder="••••••••••••" autoComplete="off" /> + + + setFromEmail(e.target.value)} placeholder="no-reply@example.com" autoComplete="off" /> + + + setFromName(e.target.value)} placeholder="Acme Roofing" autoComplete="off" /> + +
+ {saving ? "Saving…" : status.configured ? "Update" : "Connect SMTP"} + {status.configured ? { setEditing(false); setErrors({}); }}>Cancel : null} +
+
+ ) : null} + + {!live ?
Demo mode — credentials are stored locally and no email is sent.
: null}
); } diff --git a/src/lib/smtp-settings-api.ts b/src/lib/smtp-settings-api.ts new file mode 100644 index 0000000..07a4fb8 --- /dev/null +++ b/src/lib/smtp-settings-api.ts @@ -0,0 +1,89 @@ +"use client"; + +// SMTP settings data layer — a tenant's own outbound email server (BYO). Serves the local mock +// (Shell not configured) or the live be-crm data door (crm.settings.smtp.*). +// +// Live contract (be-crm → IIOS BYO credential store): +// query crm.settings.smtp.status {} -> { configured, enabled?, hints? } +// cmd crm.settings.smtp.configure { host, port, secure, user, pass, fromEmail, fromName? } -> masked status +// The password is write-only: sealed in IIOS, never returned — status carries only non-secret hints. + +import { useCallback, useState } from "react"; +import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; + +export interface SmtpCredentials { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + fromEmail: string; + fromName?: string; +} + +export interface SmtpStatus { + configured: boolean; + enabled: boolean; + host?: string; + port?: number; + user?: string; + fromEmail?: string; + fromName?: string; +} + +export interface SmtpSettingsData { + live: boolean; + loading: boolean; + error: string | null; + status: SmtpStatus; + configure: (input: SmtpCredentials) => Promise; + refetch: () => void; +} + +interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { host?: string; port?: number; user?: string; fromEmail?: string; fromName?: string } } + +function toStatus(dto?: StatusDTO | null): SmtpStatus { + return { + configured: !!dto?.configured, + enabled: dto?.enabled ?? false, + host: dto?.hints?.host, + port: dto?.hints?.port, + user: dto?.hints?.user, + fromEmail: dto?.hints?.fromEmail, + fromName: dto?.hints?.fromName, + }; +} + +/* ---- Mock (demo mode) — stores only the non-secret hints ---- */ +function useMockSmtp(): SmtpSettingsData { + const [status, setStatus] = useState({ configured: false, enabled: false }); + const configure = useCallback(async ({ host, port, user, fromEmail, fromName }: SmtpCredentials) => { + setStatus({ configured: true, enabled: true, host, port, user, fromEmail, ...(fromName ? { fromName } : {}) }); + }, []); + return { live: false, loading: false, error: null, status, configure, refetch: () => {} }; +} + +/* ---- Live (be-crm data door) ---- */ +function useLiveSmtp(): SmtpSettingsData { + const { sdk } = useAppShell(); + const q = useQuery("crm.settings.smtp.status", {}); + const configure = useCallback(async (input: SmtpCredentials) => { + await sdk.command("crm.settings.smtp.configure", { ...input }); + q.refetch(); + }, [sdk, q]); + return { + live: true, + loading: q.loading, + error: q.error ? String(q.error) : null, + status: toStatus(q.data), + configure, + refetch: q.refetch, + }; +} + +const SHELL = isShellConfigured(); + +export function useSmtpSettings(): SmtpSettingsData { + return SHELL ? useLiveSmtp() : useMockSmtp(); +}