mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 00:01:24 +00:00
916 lines
40 KiB
TypeScript
916 lines
40 KiB
TypeScript
// Connect-mailbox modal — themed, single column, three providers.
|
|
//
|
|
// Same chrome as every other dialog in the app: 48px header band with
|
|
// eyebrow + subtitle + close, hairline rows, slate-900 primary,
|
|
// 28px buttons. Replaces the old indigo-gradient "feature billboard"
|
|
// triptych.
|
|
//
|
|
// Flow:
|
|
// provider picker ──► gmail OAuth popup ─┐
|
|
// ──► outlook OAuth popup ─┼─► /emails/onboarding/oauth/finish
|
|
// ──► smtp/imap form ──────────► /emails/onboarding/smtp-imap
|
|
//
|
|
// OAuth popup posts {type:"email_oauth_callback", code, state} back here
|
|
// via window.postMessage; we then call OAuth-finish with the user's bearer.
|
|
//
|
|
// Linked to Warmbly Cloud: the consent runs on the cloud's app, the popup
|
|
// returns to /cloud-oauth/done, and we redeem its session via /cloud-link/oauth/finish.
|
|
|
|
import React from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import {
|
|
ArrowLeftIcon,
|
|
CheckIcon,
|
|
ChevronRightIcon,
|
|
CloudIcon,
|
|
InboxIcon,
|
|
KeyRoundIcon,
|
|
Loader2Icon,
|
|
MailIcon,
|
|
ExternalLinkIcon,
|
|
SendIcon,
|
|
SettingsIcon,
|
|
ShieldCheckIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { Logo } from "@/components/svg";
|
|
import { TextInput } from "@/components/ui/field";
|
|
import { useUserProfile } from "@/hooks/context/user";
|
|
import { API_URL, APP_URL } from "@/lib/information";
|
|
import type { AppError } from "@/lib/api/client/normalizeError";
|
|
import buildError from "@/lib/helper/buildError";
|
|
import addEmail from "@/lib/api/client/app/emails/addEmail";
|
|
import onboardOAuthStart from "@/lib/api/client/app/emails/onboardOAuthStart";
|
|
import onboardOAuthFinish from "@/lib/api/client/app/emails/onboardOAuthFinish";
|
|
import { finishCloudOAuth, startCloudOAuth } from "@/lib/api/client/app/cloudlink/cloudLink";
|
|
import { useAdoptCloudMailbox, useCloudWorkspaceMailboxes } from "@/lib/api/hooks/app/cloudlink/useCloudLink";
|
|
import useCloudPool from "@/hooks/useCloudPool";
|
|
import type { CloudOAuthDoneMessage } from "@/app/cloud-oauth/done/page";
|
|
import { Google, Outlook } from "@/components/svg";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type View = "pick" | "gmail" | "outlook" | "smtp_imap";
|
|
type OAuthProvider = "gmail" | "outlook";
|
|
|
|
interface OAuthCallbackMessage {
|
|
type: "email_oauth_callback";
|
|
provider: OAuthProvider;
|
|
code: string;
|
|
state: string;
|
|
error: string;
|
|
}
|
|
|
|
// originOf normalises a configured base URL to a bare origin. APP_URL and
|
|
// API_URL may carry a trailing slash or a path; event.origin never does.
|
|
function originOf(value: string | undefined): string | null {
|
|
if (!value) return null;
|
|
try {
|
|
return new URL(value, window.location.href).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// The origins we accept an OAuth callback message from.
|
|
//
|
|
// The bridge page is served by the API, not the dashboard, so that the
|
|
// redirect_uri registered with Google and Microsoft stays stable across
|
|
// front-end changes. On a split-domain deployment (dashboard on one host, API
|
|
// on another, which is what the self-hosting guide sets up) event.origin is
|
|
// therefore API_URL's origin and never APP_URL's. Accepting only APP_URL
|
|
// silently dropped every callback and left the modal waiting forever.
|
|
//
|
|
// This is a coarse gate: the real replay protection is the single-use state
|
|
// match below, which the message still has to satisfy.
|
|
function allowedCallbackOrigins(): string[] {
|
|
return [originOf(APP_URL), originOf(API_URL), window.location.origin].filter(
|
|
(o): o is string => Boolean(o),
|
|
);
|
|
}
|
|
|
|
function openCentered(url: string, name: string): Window | null {
|
|
const w = 520;
|
|
const h = 640;
|
|
const sx = window.screenLeft ?? window.screenX;
|
|
const sy = window.screenTop ?? window.screenY;
|
|
const sw = window.innerWidth ?? document.documentElement.clientWidth ?? screen.width;
|
|
const sh = window.innerHeight ?? document.documentElement.clientHeight ?? screen.height;
|
|
const left = sx + (sw - w) / 2;
|
|
const top = sy + (sh - h) / 2;
|
|
const popup = window.open(
|
|
url,
|
|
name,
|
|
`width=${w},height=${h},left=${left},top=${top}`,
|
|
);
|
|
popup?.focus();
|
|
return popup;
|
|
}
|
|
|
|
export default function AddEmailModal() {
|
|
const user = useUserProfile();
|
|
const qc = useQueryClient();
|
|
|
|
const [view, setView] = React.useState<View>("pick");
|
|
const [oauthBusy, setOauthBusy] = React.useState<OAuthProvider | null>(null);
|
|
// Set when the deployment has no OAuth client for the provider the user
|
|
// picked. Rendered inline rather than as a toast: it is a setup instruction
|
|
// with a link, not a transient failure.
|
|
const [notConfigured, setNotConfigured] = React.useState<OAuthProvider | null>(null);
|
|
const pendingState = React.useRef<{ provider: OAuthProvider; state: string } | null>(null);
|
|
// A consent running on Warmbly Cloud's app; redeemed by session, not code.
|
|
const pendingCloud = React.useRef<{ provider: OAuthProvider; session: string } | null>(null);
|
|
const pool = useCloudPool();
|
|
const viaCloud = pool.connected;
|
|
|
|
// Reset when the modal closes.
|
|
React.useEffect(() => {
|
|
if (!user.addEmail) {
|
|
setView("pick");
|
|
setOauthBusy(null);
|
|
setNotConfigured(null);
|
|
pendingState.current = null;
|
|
pendingCloud.current = null;
|
|
}
|
|
}, [user.addEmail]);
|
|
|
|
// The cloud-brokered popup lands on our own origin (/cloud-oauth/done).
|
|
React.useEffect(() => {
|
|
function onMessage(event: MessageEvent) {
|
|
if (event.origin !== window.location.origin) return;
|
|
const data = event.data as CloudOAuthDoneMessage | undefined;
|
|
if (!data || data.type !== "cloud_oauth_callback") return;
|
|
const expected = pendingCloud.current;
|
|
if (!expected || expected.session !== data.session) return;
|
|
pendingCloud.current = null;
|
|
if (data.status !== "ok") {
|
|
setOauthBusy(null);
|
|
if (data.error !== "access_denied") {
|
|
toast.error(data.message || (data.error ? `Provider error: ${data.error}` : "Connection was cancelled."));
|
|
}
|
|
return;
|
|
}
|
|
void toast.promise(
|
|
finishCloudOAuth(data.session).then((inbox) => {
|
|
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
|
qc.invalidateQueries({ queryKey: ["cloud-link"] });
|
|
user.setAddEmail(false);
|
|
return inbox;
|
|
}),
|
|
{
|
|
loading: "Adding the mailbox…",
|
|
success: "Mailbox connected. Warmbly Cloud warms it from now on.",
|
|
error: (e: AppError) => buildError(e),
|
|
},
|
|
).finally(() => setOauthBusy(null));
|
|
}
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [qc, user]);
|
|
|
|
// Listen for the OAuth popup's postMessage. We only honour messages from an
|
|
// origin we own and whose state matches the one we issued, which is what
|
|
// protects against replay and stray posts.
|
|
React.useEffect(() => {
|
|
function onMessage(event: MessageEvent) {
|
|
if (event.origin && !allowedCallbackOrigins().includes(event.origin)) {
|
|
return;
|
|
}
|
|
const data = event.data as OAuthCallbackMessage | undefined;
|
|
if (!data || data.type !== "email_oauth_callback") return;
|
|
|
|
const expected = pendingState.current;
|
|
if (!expected || expected.state !== data.state) return;
|
|
pendingState.current = null;
|
|
|
|
if (data.error || !data.code) {
|
|
setOauthBusy(null);
|
|
if (data.error !== "access_denied") {
|
|
toast.error(data.error ? `Provider error: ${data.error}` : "Connection was cancelled.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
void toast.promise(
|
|
onboardOAuthFinish(data.code, data.state).then((inbox) => {
|
|
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
|
user.setAddEmail(false);
|
|
return inbox;
|
|
}),
|
|
{
|
|
loading: "Connecting…",
|
|
success: "Mailbox connected",
|
|
error: (e: AppError) => buildError(e),
|
|
},
|
|
).finally(() => setOauthBusy(null));
|
|
}
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, [qc, user]);
|
|
|
|
async function startOAuth(provider: OAuthProvider) {
|
|
if (oauthBusy) return;
|
|
setOauthBusy(provider);
|
|
setNotConfigured(null);
|
|
if (viaCloud) {
|
|
try {
|
|
const { url, session } = await startCloudOAuth(provider);
|
|
pendingCloud.current = { provider, session };
|
|
const popup = openCentered(url, `connect-${provider}`);
|
|
if (!popup) {
|
|
pendingCloud.current = null;
|
|
setOauthBusy(null);
|
|
toast.error("Could not open the authorization window. Please allow popups and try again.");
|
|
}
|
|
} catch (err) {
|
|
pendingCloud.current = null;
|
|
setOauthBusy(null);
|
|
toast.error(buildError(err as AppError));
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const { url, state } = await onboardOAuthStart(provider);
|
|
pendingState.current = { provider, state };
|
|
const popup = openCentered(url, `connect-${provider}`);
|
|
if (!popup) {
|
|
pendingState.current = null;
|
|
setOauthBusy(null);
|
|
toast.error("Could not open the authorization window. Please allow popups and try again.");
|
|
}
|
|
} catch (err) {
|
|
pendingState.current = null;
|
|
setOauthBusy(null);
|
|
const e = err as AppError;
|
|
if (e.code === "mailbox_provider_not_configured") {
|
|
setNotConfigured(provider);
|
|
return;
|
|
}
|
|
toast.error(buildError(e));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{user.addEmail && (
|
|
<motion.div
|
|
key="overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
onClick={() => user.setAddEmail(false)}
|
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
|
|
>
|
|
<motion.div
|
|
key="card"
|
|
initial={{ y: 8, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: 8, opacity: 0 }}
|
|
transition={{ duration: 0.16 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="w-full max-w-[560px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[88dvh]"
|
|
>
|
|
<Header
|
|
view={view}
|
|
onBack={() => {
|
|
setNotConfigured(null);
|
|
setView("pick");
|
|
}}
|
|
onClose={() => user.setAddEmail(false)}
|
|
/>
|
|
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden relative">
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.div
|
|
key={view}
|
|
initial={{ opacity: 0, x: view === "pick" ? -12 : 12 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: view === "pick" ? 12 : -12 }}
|
|
transition={{ duration: 0.18, ease: [0.32, 0.72, 0, 1] }}
|
|
>
|
|
{view === "pick" && (
|
|
<PickProvider
|
|
onPick={setView}
|
|
viaCloud={viaCloud}
|
|
onAdopted={() => {
|
|
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
|
user.setAddEmail(false);
|
|
}}
|
|
/>
|
|
)}
|
|
{view === "gmail" && (
|
|
notConfigured === "gmail" ? (
|
|
<ProviderNotConfigured provider="gmail" selfHosted={pool.selfHosted} />
|
|
) : (
|
|
<OAuthPanel
|
|
provider="gmail"
|
|
busy={oauthBusy === "gmail"}
|
|
viaCloud={viaCloud}
|
|
onConnect={() => startOAuth("gmail")}
|
|
/>
|
|
)
|
|
)}
|
|
{view === "outlook" && (
|
|
notConfigured === "outlook" ? (
|
|
<ProviderNotConfigured provider="outlook" selfHosted={pool.selfHosted} />
|
|
) : (
|
|
<OAuthPanel
|
|
provider="outlook"
|
|
busy={oauthBusy === "outlook"}
|
|
viaCloud={viaCloud}
|
|
onConnect={() => startOAuth("outlook")}
|
|
/>
|
|
)
|
|
)}
|
|
{view === "smtp_imap" && (
|
|
<SmtpImapPanel
|
|
onDone={() => {
|
|
qc.invalidateQueries({ queryKey: ["emails", "list"] });
|
|
user.setAddEmail(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
function Header({
|
|
view,
|
|
onBack,
|
|
onClose,
|
|
}: {
|
|
view: View;
|
|
onBack: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const sub: Record<View, string> = {
|
|
pick: "Connect a sending account",
|
|
gmail: "Gmail or Google Workspace",
|
|
outlook: "Outlook or Microsoft 365",
|
|
smtp_imap: "Any provider via SMTP / IMAP",
|
|
};
|
|
return (
|
|
<div className="h-12 px-3 border-b border-slate-200 flex items-center gap-2.5 shrink-0">
|
|
{view !== "pick" && (
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
aria-label="Back"
|
|
className="size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
|
>
|
|
<ArrowLeftIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
|
Mailbox
|
|
</span>
|
|
<div className="h-4 w-px bg-slate-200" />
|
|
<span className="text-[12px] text-slate-600 truncate">{sub[view]}</span>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
|
>
|
|
<XIcon className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Shown when the API reports this deployment has no OAuth client for the chosen
|
|
// provider. Self-host only, and the person seeing it can usually fix it, so it
|
|
// names the exact variables and links the setup guide instead of just failing.
|
|
const PROVIDER_SETUP: Record<OAuthProvider, { label: string; vars: string[] }> = {
|
|
gmail: {
|
|
label: "Gmail and Google Workspace",
|
|
vars: ["BOX_GOOGLE_CLIENT_ID", "BOX_GOOGLE_CLIENT_SECRET"],
|
|
},
|
|
outlook: {
|
|
label: "Outlook and Microsoft 365",
|
|
vars: ["BOX_OUTLOOK_CLIENT_ID", "BOX_OUTLOOK_CLIENT_SECRET"],
|
|
},
|
|
};
|
|
|
|
function ProviderNotConfigured({ provider, selfHosted }: { provider: OAuthProvider; selfHosted: boolean }) {
|
|
const { label, vars } = PROVIDER_SETUP[provider];
|
|
return (
|
|
<div className="p-4">
|
|
{selfHosted && (
|
|
<div className="mb-3 rounded-md border border-sky-200 bg-sky-50 p-3 flex items-start gap-2.5">
|
|
<CloudIcon className="w-4 h-4 text-sky-600 mt-0.5 shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-[12.5px] font-medium text-sky-900">Skip the OAuth setup: connect Warmbly Cloud</p>
|
|
<p className="text-[12.5px] text-sky-800 mt-1">
|
|
Linked instances sign mailboxes in through Warmbly's own Google and Microsoft apps, and the cloud warms them. Free for 10 mailboxes.
|
|
</p>
|
|
<a href="/app/settings/warmbly-cloud" className="mt-2 inline-flex h-7 px-2.5 items-center gap-1.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium transition-colors">
|
|
Connect Warmbly Cloud
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
|
|
<div className="flex items-start gap-2.5">
|
|
<SettingsIcon className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-[12.5px] font-medium text-amber-900">
|
|
{label} is not configured on this deployment
|
|
</p>
|
|
<p className="text-[12.5px] text-amber-800 mt-1">
|
|
Connecting these mailboxes needs an OAuth client. Add both values to
|
|
the <code className="bg-white/70 px-1 rounded">.env</code> at the root of
|
|
your Warmbly install, then restart with{" "}
|
|
<code className="bg-white/70 px-1 rounded">make up</code>.
|
|
</p>
|
|
<ul className="mt-2 space-y-1">
|
|
{vars.map((v) => (
|
|
<li
|
|
key={v}
|
|
className="text-[12px] font-mono text-amber-900 bg-white/70 rounded px-1.5 py-1"
|
|
>
|
|
{v}=
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<a
|
|
href="https://docs.warmbly.com/development/deployment-guide/#connect-mailboxes"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="mt-3 h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md border border-slate-200 text-[12.5px] text-slate-700 hover:bg-slate-50 transition-colors"
|
|
>
|
|
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
|
Full environment setup guide
|
|
</a>
|
|
|
|
<p className="mt-3 text-[12px] text-slate-500">
|
|
No setup needed for any other provider: connect it over SMTP and IMAP instead.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PickProvider({ onPick, viaCloud, onAdopted }: { onPick: (v: View) => void; viaCloud: boolean; onAdopted: () => void }) {
|
|
const rows: Array<{
|
|
key: View;
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
sub: string;
|
|
tone: "primary" | "neutral";
|
|
}> = [
|
|
{
|
|
key: "gmail",
|
|
icon: <Google className="w-5 h-5" />,
|
|
title: "Gmail / Google Workspace",
|
|
sub: viaCloud ? "Sign in through Warmbly Cloud. Warmup included, no OAuth app needed." : "OAuth via Google. Best deliverability for Gmail.",
|
|
tone: "primary",
|
|
},
|
|
{
|
|
key: "outlook",
|
|
icon: <Outlook className="w-5 h-5" />,
|
|
title: "Outlook / Microsoft 365",
|
|
sub: viaCloud ? "Sign in through Warmbly Cloud. Warmup included, no OAuth app needed." : "OAuth via Microsoft. Native sync for Outlook accounts.",
|
|
tone: "primary",
|
|
},
|
|
{
|
|
key: "smtp_imap",
|
|
icon: <Logo className="w-4 h-5 text-slate-700" />,
|
|
title: "Other (SMTP / IMAP)",
|
|
sub: "Any provider with manual host, port, and app password.",
|
|
tone: "neutral",
|
|
},
|
|
];
|
|
return (
|
|
<div className="divide-y divide-slate-200/60">
|
|
{rows.map((r, i) => (
|
|
<motion.button
|
|
key={r.key}
|
|
type="button"
|
|
onClick={() => onPick(r.key)}
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.04 + i * 0.04, duration: 0.18, ease: "easeOut" }}
|
|
className="w-full px-4 py-3.5 flex items-center gap-3 text-left group hover:bg-slate-50 transition-colors"
|
|
>
|
|
<div className="size-9 rounded-md border border-slate-200 bg-white flex items-center justify-center shrink-0 transition-colors group-hover:border-slate-300">
|
|
{r.icon}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[13px] font-medium text-slate-900 truncate">{r.title}</div>
|
|
<div className="text-[11.5px] text-slate-500 truncate">{r.sub}</div>
|
|
</div>
|
|
<ChevronRightIcon className="w-4 h-4 text-slate-300 shrink-0 group-hover:text-slate-500 group-hover:translate-x-0.5 transition-all" />
|
|
</motion.button>
|
|
))}
|
|
{viaCloud && <WorkspaceMailboxes onAdopted={onAdopted} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Mailboxes connected directly on the linked Warmbly Cloud workspace: one
|
|
// click brings each one here, sending with tokens the cloud brokers.
|
|
function WorkspaceMailboxes({ onAdopted }: { onAdopted: () => void }) {
|
|
const list = useCloudWorkspaceMailboxes();
|
|
const adopt = useAdoptCloudMailbox();
|
|
const [busy, setBusy] = React.useState<string | null>(null);
|
|
const items = list.data ?? [];
|
|
if (list.isLoading || items.length === 0) return null;
|
|
|
|
const run = async (id: string, email: string) => {
|
|
setBusy(id);
|
|
try {
|
|
await adopt.mutateAsync(id);
|
|
toast.success(`${email} connected`);
|
|
onAdopted();
|
|
} catch (e) {
|
|
toast.error(buildError(e as AppError));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="px-4 py-3 bg-sky-50/40">
|
|
<div className="flex items-center gap-1.5 mb-2">
|
|
<CloudIcon className="w-3.5 h-3.5 text-sky-600" />
|
|
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">In your Warmbly Cloud workspace</span>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
{items.map((m) => (
|
|
<div key={m.id} className="flex items-center gap-2.5 rounded-md border border-slate-200 bg-white px-2.5 py-2">
|
|
<div className="size-7 rounded-md border border-slate-200 bg-white flex items-center justify-center shrink-0">
|
|
{m.provider === "gmail" ? <Google className="w-4 h-4" /> : <Outlook className="w-4 h-4" />}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[12.5px] text-slate-900 truncate">{m.email}</div>
|
|
<div className="text-[11px] text-slate-500 truncate">Connected on the cloud. Add it here to send campaigns from it.</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={busy === m.id}
|
|
onClick={() => void run(m.id, m.email)}
|
|
className="shrink-0 h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
|
|
>
|
|
{busy === m.id ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <CheckIcon className="w-3 h-3" />}
|
|
Connect
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OAuthPanel({
|
|
provider,
|
|
busy,
|
|
viaCloud,
|
|
onConnect,
|
|
}: {
|
|
provider: OAuthProvider;
|
|
busy: boolean;
|
|
viaCloud: boolean;
|
|
onConnect: () => void;
|
|
}) {
|
|
const label = provider === "gmail" ? "Google" : "Microsoft";
|
|
const Icon = provider === "gmail" ? Google : Outlook;
|
|
return (
|
|
<div className="px-5 py-6 space-y-5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="size-11 rounded-md border border-slate-200 bg-white flex items-center justify-center shrink-0">
|
|
<Icon className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[13.5px] font-medium text-slate-900">
|
|
Connect with {label}
|
|
</div>
|
|
<div className="text-[11.5px] text-slate-500">
|
|
{viaCloud
|
|
? `Warmbly Cloud opens the ${label} window on its own app. Approve and you're done.`
|
|
: `We'll open a ${label} window. Approve the scopes and you're done.`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{viaCloud ? (
|
|
<ul className="text-[11.5px] text-slate-600 space-y-1.5 px-1">
|
|
<Scope>Sends campaigns and syncs replies from this server, as usual</Scope>
|
|
<Scope>Warmbly Cloud keeps the sign-in and warms the mailbox in its pool</Scope>
|
|
<Scope>The mailbox also appears in your cloud workspace; remove it from either side</Scope>
|
|
</ul>
|
|
) : (
|
|
<ul className="text-[11.5px] text-slate-600 space-y-1.5 px-1">
|
|
<Scope>Send and read mail on your behalf</Scope>
|
|
<Scope>Track replies and deliveries</Scope>
|
|
<Scope>Refresh tokens are stored encrypted; revoke any time</Scope>
|
|
</ul>
|
|
)}
|
|
|
|
<motion.button
|
|
type="button"
|
|
onClick={onConnect}
|
|
disabled={busy}
|
|
whileTap={busy ? undefined : { scale: 0.985 }}
|
|
className="w-full h-9 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12.5px] font-medium inline-flex items-center justify-center gap-2 transition-colors disabled:opacity-60"
|
|
>
|
|
{busy ? (
|
|
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<ShieldCheckIcon className="w-3.5 h-3.5" />
|
|
)}
|
|
{busy ? "Waiting for authorization…" : `Continue with ${label}`}
|
|
</motion.button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Scope({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<li className="flex items-start gap-2">
|
|
<CheckIcon className="w-3 h-3 text-slate-400 mt-1 shrink-0" />
|
|
<span>{children}</span>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function SmtpImapPanel({ onDone }: { onDone: () => void }) {
|
|
const [name, setName] = React.useState("");
|
|
const [email, setEmail] = React.useState("");
|
|
|
|
const [imapHost, setImapHost] = React.useState("");
|
|
const [imapPort, setImapPort] = React.useState("993");
|
|
const [imapUser, setImapUser] = React.useState("");
|
|
const [imapPass, setImapPass] = React.useState("");
|
|
|
|
const [smtpHost, setSmtpHost] = React.useState("");
|
|
const [smtpPort, setSmtpPort] = React.useState("587");
|
|
const [smtpUser, setSmtpUser] = React.useState("");
|
|
const [smtpPass, setSmtpPass] = React.useState("");
|
|
|
|
// Single-credentials toggle — covers the 90% case where IMAP and SMTP
|
|
// share the same login. The user can flip it off and supply distinct
|
|
// SMTP creds for legacy setups.
|
|
const [sameCreds, setSameCreds] = React.useState(true);
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
|
|
// Auto-fill the username fields from the email address so the user
|
|
// doesn't have to re-type it. Cleared if they touched the user field.
|
|
const imapUserTouched = React.useRef(false);
|
|
const smtpUserTouched = React.useRef(false);
|
|
React.useEffect(() => {
|
|
if (!imapUserTouched.current) setImapUser(email);
|
|
if (!smtpUserTouched.current && !sameCreds) setSmtpUser(email);
|
|
}, [email, sameCreds]);
|
|
|
|
function effectiveSmtp() {
|
|
return sameCreds
|
|
? { user: imapUser, pass: imapPass, host: smtpHost, port: Number(smtpPort) }
|
|
: { user: smtpUser, pass: smtpPass, host: smtpHost, port: Number(smtpPort) };
|
|
}
|
|
|
|
function valid() {
|
|
if (!name.trim() || !email.trim()) return false;
|
|
if (!imapHost.trim() || !imapPort.trim() || !imapUser.trim() || !imapPass) return false;
|
|
if (!smtpHost.trim() || !smtpPort.trim()) return false;
|
|
if (!sameCreds && (!smtpUser.trim() || !smtpPass)) return false;
|
|
const p = Number(smtpPort);
|
|
if (p !== 465 && p !== 587) return false;
|
|
return true;
|
|
}
|
|
|
|
async function submit() {
|
|
if (submitting || !valid()) return;
|
|
setSubmitting(true);
|
|
const eff = effectiveSmtp();
|
|
try {
|
|
await toast.promise(
|
|
addEmail({
|
|
name: name.trim(),
|
|
email: email.trim(),
|
|
imap: {
|
|
username: imapUser.trim(),
|
|
password: imapPass,
|
|
host: imapHost.trim(),
|
|
port: Number(imapPort),
|
|
},
|
|
smtp: {
|
|
username: eff.user.trim(),
|
|
password: eff.pass,
|
|
host: eff.host.trim(),
|
|
port: eff.port,
|
|
},
|
|
}),
|
|
{
|
|
loading: "Verifying credentials…",
|
|
success: "Mailbox connected",
|
|
error: (e: AppError) => buildError(e),
|
|
},
|
|
);
|
|
onDone();
|
|
} catch {
|
|
/* surfaced by toast */
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Section title="Account" sub="Name and address you send from" icon={<MailIcon className="w-3.5 h-3.5" />}>
|
|
<Field label="Name">
|
|
<TextInput value={name} onChange={setName} placeholder="Alex Rivera" />
|
|
</Field>
|
|
<Field label="Email">
|
|
<TextInput value={email} onChange={setEmail} placeholder="alex@company.com" />
|
|
</Field>
|
|
</Section>
|
|
|
|
<Section title="IMAP" sub="Incoming, usually 993" icon={<InboxIcon className="w-3.5 h-3.5" />}>
|
|
<Field label="Server">
|
|
<HostPortInput
|
|
host={imapHost}
|
|
onHost={setImapHost}
|
|
hostPlaceholder="imap.example.com"
|
|
port={imapPort}
|
|
onPort={setImapPort}
|
|
portPlaceholder="993"
|
|
/>
|
|
</Field>
|
|
<Field label="Username">
|
|
<TextInput
|
|
value={imapUser}
|
|
onChange={(v) => {
|
|
imapUserTouched.current = true;
|
|
setImapUser(v);
|
|
}}
|
|
placeholder={email || "alex@company.com"}
|
|
/>
|
|
</Field>
|
|
<Field label="Password">
|
|
<TextInput value={imapPass} onChange={setImapPass} placeholder="App password" type="password" />
|
|
</Field>
|
|
</Section>
|
|
|
|
<Section title="SMTP" sub="Outgoing, 465 or 587" icon={<SendIcon className="w-3.5 h-3.5" />}>
|
|
<Field label="Server">
|
|
<HostPortInput
|
|
host={smtpHost}
|
|
onHost={setSmtpHost}
|
|
hostPlaceholder="smtp.example.com"
|
|
port={smtpPort}
|
|
onPort={setSmtpPort}
|
|
portPlaceholder="587"
|
|
/>
|
|
</Field>
|
|
<label className="flex items-center gap-2 pl-[76px] pt-0.5 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={sameCreds}
|
|
onChange={(e) => setSameCreds(e.target.checked)}
|
|
className="size-3.5 rounded border-slate-300 accent-slate-900"
|
|
/>
|
|
<span className="text-[11.5px] text-slate-600">
|
|
Use the same login as IMAP
|
|
</span>
|
|
</label>
|
|
<AnimatePresence initial={false}>
|
|
{!sameCreds && (
|
|
<motion.div
|
|
key="smtp-creds"
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="space-y-2 pt-2">
|
|
<Field label="Username">
|
|
<TextInput
|
|
value={smtpUser}
|
|
onChange={(v) => {
|
|
smtpUserTouched.current = true;
|
|
setSmtpUser(v);
|
|
}}
|
|
placeholder={email || "alex@company.com"}
|
|
/>
|
|
</Field>
|
|
<Field label="Password">
|
|
<TextInput value={smtpPass} onChange={setSmtpPass} placeholder="App password" type="password" />
|
|
</Field>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</Section>
|
|
|
|
<div className="px-4 py-2.5 border-t border-slate-200 bg-slate-50/60 flex items-center gap-2 min-w-0 sticky bottom-0">
|
|
<div className="flex items-center gap-1.5 text-[11px] text-slate-500 min-w-0 flex-1">
|
|
<KeyRoundIcon className="w-3 h-3 shrink-0" />
|
|
<span className="truncate">Verified against your server before saving.</span>
|
|
</div>
|
|
<motion.button
|
|
type="button"
|
|
onClick={submit}
|
|
disabled={!valid() || submitting}
|
|
whileTap={valid() && !submitting ? { scale: 0.97 } : undefined}
|
|
className={cn(
|
|
"shrink-0 h-7 px-3 rounded-md text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors",
|
|
"bg-slate-900 hover:bg-slate-800 text-white disabled:opacity-50 disabled:cursor-not-allowed",
|
|
)}
|
|
>
|
|
{submitting ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <CheckIcon className="w-3 h-3" />}
|
|
Connect
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
title,
|
|
sub,
|
|
icon,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
sub: string;
|
|
icon: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="px-4 py-3 border-b border-slate-200/60 last:border-b-0 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-2 min-w-0">
|
|
<span className="text-slate-500 shrink-0">{icon}</span>
|
|
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium shrink-0">
|
|
{title}
|
|
</span>
|
|
<div className="h-3 w-px bg-slate-200 shrink-0" />
|
|
<span className="text-[11.5px] text-slate-500 truncate min-w-0">{sub}</span>
|
|
</div>
|
|
<div className="space-y-2 min-w-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium w-16 shrink-0">
|
|
{label}
|
|
</span>
|
|
<div className="flex-1 min-w-0">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// HostPortInput — one bordered field that holds host (flex) and port
|
|
// (fixed 56px) with a hairline divider between them. Treating it as a
|
|
// single visual input avoids the old "Port label + tiny input" pinch
|
|
// that was overflowing on narrow modal widths.
|
|
function HostPortInput({
|
|
host,
|
|
onHost,
|
|
hostPlaceholder,
|
|
port,
|
|
onPort,
|
|
portPlaceholder,
|
|
}: {
|
|
host: string;
|
|
onHost: (v: string) => void;
|
|
hostPlaceholder: string;
|
|
port: string;
|
|
onPort: (v: string) => void;
|
|
portPlaceholder: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-stretch h-7 rounded-md border border-slate-200 bg-white focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100 transition-colors min-w-0 overflow-hidden">
|
|
<input
|
|
value={host}
|
|
onChange={(e) => onHost(e.target.value)}
|
|
placeholder={hostPlaceholder}
|
|
className="flex-1 min-w-0 px-2.5 bg-transparent outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400"
|
|
/>
|
|
<div className="w-px bg-slate-200 shrink-0" />
|
|
<input
|
|
value={port}
|
|
onChange={(e) => onPort(e.target.value)}
|
|
placeholder={portPlaceholder}
|
|
inputMode="numeric"
|
|
className="w-14 shrink-0 px-2 bg-slate-50/60 outline-none text-[12.5px] text-slate-900 placeholder:text-slate-400 tabular-nums text-center"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|