diff --git a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx
index 1afb5eef2..831757348 100644
--- a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx
+++ b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx
@@ -1,7 +1,8 @@
// Contact ordering settings — how the campaign picks who to send to next.
-// On-theme: slate/sky, rounded-md, 12.5px base, house primitives.
+// On-theme: reuses the shared OptionSelect / Segmented / SettingRow primitives
+// so it matches the rest of the settings page.
-import { useState, useCallback, useRef } from "react";
+import { useState, useCallback } from "react";
import {
DndContext,
closestCenter,
@@ -19,21 +20,18 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import { AnimatePresence, motion } from "framer-motion";
-import { CheckIcon, ChevronDownIcon, GripVerticalIcon, InfoIcon } from "lucide-react";
+import { GripVerticalIcon } from "lucide-react";
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import { Label, TextInput } from "@/components/ui/field";
-import useClickOutside from "@/hooks/useClickOutside";
-import useFlipPlacement from "@/hooks/useFlipPlacement";
-import { Segmented, SettingRow } from "./components/CampaignPreferenceBoolBox";
+import { OptionSelect, Segmented, SettingRow } from "./components/CampaignPreferenceBoolBox";
-const ORDER_OPTIONS = [
- { value: "created_at", label: "Creation time", description: "Order by when contacts were added" },
- { value: "email", label: "Email", description: "Alphabetical by email address" },
- { value: "name", label: "Name", description: "Alphabetical by first, then last name" },
- { value: "custom_field", label: "Custom field", description: "Order by a custom contact field" },
- { value: "manual", label: "Manual", description: "Drag and drop to set custom order" },
-] as const;
+const ORDER_OPTIONS: { value: Campaign["contact_order_by"]; label: string; hint: string }[] = [
+ { value: "created_at", label: "Creation time", hint: "When the contact was added" },
+ { value: "email", label: "Email", hint: "Alphabetical by email address" },
+ { value: "name", label: "Name", hint: "Alphabetical by first, then last name" },
+ { value: "custom_field", label: "Custom field", hint: "Order by a custom contact field" },
+ { value: "manual", label: "Manual", hint: "Drag and drop to set the order" },
+];
interface ContactItem {
id: string;
@@ -70,9 +68,7 @@ function SortableContact({ contact, index }: { contact: ContactItem; index: numb
>
-
- {index + 1}
-
+ {index + 1}
{contact.firstName || contact.lastName
@@ -108,16 +104,6 @@ export default function CampaignContactOrder({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
- const selectedOption =
- ORDER_OPTIONS.find((o) => o.value === newCampaign.contact_order_by) || ORDER_OPTIONS[0];
-
- // Order-by dropdown (house framer-motion pattern + flip placement).
- const [orderOpen, setOrderOpen] = useState(false);
- const orderRef = useRef(null);
- const orderTriggerRef = useRef(null);
- useClickOutside(orderRef, () => setOrderOpen(false));
- const orderPlacement = useFlipPlacement(orderTriggerRef, orderOpen, 300);
-
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
@@ -138,64 +124,17 @@ export default function CampaignContactOrder({
);
return (
-
- {/* Order By */}
+
+ {/* Order by — same card picker as the rest of settings */}
Order contacts by
-
-
- setOrderOpen((o) => !o)}
- className="flex h-8 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-2.5 text-[12.5px] text-slate-800 transition-colors hover:border-slate-300 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
- >
- {selectedOption.label}
-
-
-
-
- {orderOpen && (
-
- {ORDER_OPTIONS.map((option) => {
- const sel = newCampaign.contact_order_by === option.value;
- return (
- {
- setNewCampaign((prev) => ({ ...prev, contact_order_by: option.value }));
- setOrderOpen(false);
- }}
- className="flex w-full items-start gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-slate-100"
- >
-
- {sel && }
-
-
- {option.label}
- {option.description}
-
-
- );
- })}
-
- )}
-
-
+
setNewCampaign((prev) => ({ ...prev, contact_order_by: v }))}
+ options={ORDER_OPTIONS}
+ />
{/* Direction (not for manual) */}
@@ -210,9 +149,7 @@ export default function CampaignContactOrder({
control={
- setNewCampaign((prev) => ({ ...prev, contact_order_dir: v }))
- }
+ onChange={(v) => setNewCampaign((prev) => ({ ...prev, contact_order_dir: v }))}
options={[
{ value: "asc", label: "Ascending" },
{ value: "desc", label: "Descending" },
@@ -229,9 +166,7 @@ export default function CampaignContactOrder({
- setNewCampaign((prev) => ({ ...prev, contact_order_field: v }))
- }
+ onChange={(v) => setNewCampaign((prev) => ({ ...prev, contact_order_field: v }))}
className="w-full max-w-[280px]"
/>
@@ -245,11 +180,7 @@ export default function CampaignContactOrder({
Drag to reorder contacts
{orderedContacts.length > 0 ? (
-
+
c.id)}
strategy={verticalListSortingStrategy}
@@ -263,25 +194,12 @@ export default function CampaignContactOrder({
) : (
-
- No contacts in this campaign
-
-
- Add contacts to enable manual ordering.
-
+
No contacts in this campaign
+
Add contacts to enable manual ordering.
)}
)}
-
- {/* Info */}
-
-
-
- When processing sends, contacts are selected in this order — it determines who
- receives emails first while the campaign is running.
-
-
);
}
diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx
index 8a5fa0ddc..fdf5a570a 100644
--- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx
+++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx
@@ -5,6 +5,7 @@
// icon and anchor. On-theme: slate/sky, rounded-md, 12.5px base, NumberInput
// for every number.
+import { useState } from "react";
import { AlertCircleIcon } from "lucide-react";
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
import { Label, NumberInput } from "@/components/ui/field";
@@ -13,7 +14,64 @@ import EspCoveragePanel from "./EspCoveragePanel";
type SetCampaign = React.Dispatch
>;
-/** Rotation — distribution mode + per-mailbox daily ramp-up. */
+/** RampPreview — a small day-by-day projection of the per-mailbox ramp curve
+ * (Day 1 = start, +increment each day, capped at the ceiling). Highlights the
+ * bar nearest today's live cap (ramp_level). */
+function RampPreview({
+ start,
+ increment,
+ ceiling,
+ level,
+}: {
+ start: number;
+ increment: number;
+ ceiling: number;
+ level: number;
+}) {
+ if (start <= 0 || ceiling <= 0 || start > ceiling) return null;
+ const days: number[] = [];
+ let v = start;
+ let guard = 0;
+ while (guard < 60) {
+ const capped = Math.min(v, ceiling);
+ days.push(capped);
+ if (capped >= ceiling) break;
+ v += Math.max(1, increment);
+ guard++;
+ }
+ const MAX_BARS = 14;
+ const shown = days.slice(0, MAX_BARS);
+ const more = days.length - shown.length;
+ const todayIdx = days.findIndex((d) => d >= level);
+
+ return (
+
+
+ {shown.map((val, i) => {
+ const today = i === todayIdx;
+ return (
+
+ );
+ })}
+
+
+
+ Day 1 · {start}/day
+
+
+ {more > 0 ? `+${more} more · ` : ""}Day {days.length} · {ceiling}/day, then steady
+
+
+
+ );
+}
+
+/** Inbox rotation — distribution mode + per-mailbox daily ramp-up. */
export function RotationRampSection({
newCampaign,
setNewCampaign,
@@ -24,32 +82,48 @@ export function RotationRampSection({
const rampInvalid = newCampaign.ramp_enabled && newCampaign.ramp_start > newCampaign.ramp_ceiling;
return (
-
setNewCampaign((bef) => ({ ...bef, rotation_mode: v }))}
- options={[
- { value: "weighted", label: "Weighted", hint: "Favor mailboxes with more headroom" },
- { value: "round_robin", label: "Round-robin", hint: "Cycle evenly through every mailbox" },
- {
- value: "least_recently_used",
- label: "Least-recently-used",
- hint: "Pick the mailbox idle the longest",
- },
- ]}
- />
- }
- />
+ {/* Inbox rotation */}
+
+
setNewCampaign((bef) => ({ ...bef, rotation_mode: v }))}
+ options={[
+ {
+ value: "least_recently_used",
+ label: "Even spacing",
+ hint: "Recommended — always picks the mailbox that's been idle longest, for the most natural send pattern.",
+ },
+ {
+ value: "round_robin",
+ label: "Round-robin",
+ hint: "Cycles through your mailboxes in order (A → B → C → A). A simple, even split.",
+ },
+ {
+ value: "weighted",
+ label: "Weighted",
+ hint: "Sends more from your healthiest, higher-limit mailboxes.",
+ },
+ ]}
+ />
+ }
+ />
+
+ Each mailbox stays within its own daily limit, and follow-ups always come from the mailbox that
+ sent the first email — so every thread stays consistent.
+
+
+ {/* Daily ramp-up */}
- Increment
+ Increase by
setNewCampaign((bef) => ({ ...bef, ramp_increment: v }))}
- suffix="+ / day"
+ suffix="/ day"
className="w-36"
/>
@@ -99,11 +173,18 @@ export function RotationRampSection({
{newCampaign.ramp_level}
- {rampInvalid && (
+ {rampInvalid ? (
Start must be less than or equal to the ceiling.
+ ) : (
+
)}
)}
@@ -161,19 +242,19 @@ export function LeadFlowSection({
return (
-
New leads per day
+
New leads contacted per day
setNewCampaign((bef) => ({ ...bef, max_new_leads_per_day: v }))}
- suffix="leads / day"
+ suffix={newCampaign.max_new_leads_per_day === 0 ? "= no limit" : "leads / day"}
className="w-48"
/>
-
- {newCampaign.max_new_leads_per_day === 0
- ? "0 = unlimited new leads per day."
- : "Caps how many fresh leads enter the campaign each day."}
+
+ Limits how many brand-new leads get their very first {" "}
+ email from this campaign each day, so a fresh list goes out as a steady trickle instead of one
+ big blast. Follow-ups to people already in the campaign still send and don't count against this.
0);
+ const [showBcc, setShowBcc] = useState(newCampaign.bcc.length > 0);
+
+ const addBtn =
+ "inline-flex items-center h-7 px-2.5 rounded-md border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-700 text-[12px] font-medium transition-colors";
+
return (
-
-
-
CC recipients
-
setNewCampaign((bef) => ({ ...bef, cc: v }))}
- />
-
- A copy of each email goes to these addresses (visible to recipients).
-
-
-
-
BCC recipients
-
setNewCampaign((bef) => ({ ...bef, bcc: v }))}
- />
-
- A blind copy goes to these addresses (hidden from recipients).
-
-
+
+ {showCc && (
+
+
+ CC
+ {
+ setShowCc(false);
+ setNewCampaign((bef) => ({ ...bef, cc: [] }));
+ }}
+ className="text-[11px] text-slate-400 hover:text-rose-500 transition-colors"
+ >
+ Remove
+
+
+
setNewCampaign((bef) => ({ ...bef, cc: v }))}
+ />
+
+ Visible to recipients. Paste or type several — separate with a comma, space, or Enter.
+
+
+ )}
+
+ {showBcc && (
+
+
+ BCC
+ {
+ setShowBcc(false);
+ setNewCampaign((bef) => ({ ...bef, bcc: [] }));
+ }}
+ className="text-[11px] text-slate-400 hover:text-rose-500 transition-colors"
+ >
+ Remove
+
+
+
setNewCampaign((bef) => ({ ...bef, bcc: v }))}
+ />
+ Hidden from recipients.
+
+ )}
+
+ {(!showCc || !showBcc) && (
+
+ {!showCc && (
+ setShowCc(true)} className={addBtn}>
+ + Add CC
+
+ )}
+ {!showBcc && (
+ setShowBcc(true)} className={addBtn}>
+ + Add BCC
+
+ )}
+ {!showCc && !showBcc && (
+
+ Optionally copy extra addresses on every email this campaign sends.
+
+ )}
+
+ )}
);
}
diff --git a/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx b/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx
index c68530819..22cca0b88 100644
--- a/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx
+++ b/web/src/components/app/campaigns/preferences/EspCoveragePanel.tsx
@@ -1,13 +1,14 @@
-// EspCoveragePanel — the visual "provider matching" map under the ESP matching
-// control. A bipartite diagram: recipient providers on the left, this
-// campaign's mailboxes on the right, with connector lines showing which mailbox
-// actually serves which recipient — mode-aware, no new API.
+// EspCoveragePanel — the "provider matching" visual under the ESP matching
+// control. For each recipient provider it shows which of this campaign's
+// mailboxes serve it, ranked so the difference between a true same-provider
+// match and an SMTP wildcard is obvious. No connector lines, no new API.
//
// Grounded in the scheduler (internal/scheduler/campaign_scheduler.go):
// • recipient provider is derived from the domain → "gmail" | "outlook" |
// unknown (everything else).
-// • a mailbox matches a recipient when: mailbox is smtp_imap (wildcard), OR
-// the recipient is unknown (wildcard), OR provider equality.
+// • a mailbox matches a recipient when: mailbox is smtp_imap (WILDCARD —
+// serves any provider, even in strict), OR the recipient is unknown
+// (wildcard), OR provider equality (gmail↔gmail, outlook↔outlook).
// • strict + no matching mailbox → the recipient is DEFERRED (held to the next
// slot), never sent cross-provider and never skipped.
// • prefer + no matching mailbox → falls back to a cross-provider mailbox.
@@ -20,6 +21,7 @@ import useEmails from "@/lib/api/hooks/app/emails/useEmails";
import ProviderLogo from "./ProviderLogo";
type Mode = "off" | "prefer" | "strict";
+type Status = "ok" | "warn" | "blocked" | "any";
const LABEL: Record
= {
gmail: "Google",
@@ -28,14 +30,6 @@ const LABEL: Record = {
other: "Other domains",
};
-// Fixed node geometry so connector coordinates are deterministic (no measuring).
-const NODE_H = 52;
-const GAP = 12;
-const CENTERS = [NODE_H / 2, NODE_H + GAP + NODE_H / 2, 2 * (NODE_H + GAP) + NODE_H / 2];
-const TOTAL_H = 3 * NODE_H + 2 * GAP;
-
-const STROKE = { match: "#10b981", fallback: "#f59e0b", neutral: "#cbd5e1" } as const;
-
export default function EspCoveragePanel({
mode,
emailTags,
@@ -90,154 +84,198 @@ export default function EspCoveragePanel({
);
}
- const mailboxes = [
- { key: "gmail", count: counts.gmail },
- { key: "outlook", count: counts.outlook },
- { key: "smtp_imap", count: counts.smtp },
- ];
- const recipients = ["gmail", "outlook", "other"];
+ const poolChips = (
+ [
+ { key: "gmail", count: counts.gmail },
+ { key: "outlook", count: counts.outlook },
+ { key: "smtp_imap", count: counts.smtp },
+ ] as const
+ ).filter((p) => p.count > 0);
- const isPrimary = (r: string, m: string) => r === "other" || m === "smtp_imap" || r === m;
-
- const edges: { ri: number; mi: number; tone: keyof typeof STROKE; dashed: boolean }[] = [];
- recipients.forEach((r, ri) => {
- mailboxes.forEach((m, mi) => {
- if (m.count === 0) return;
- const primary = isPrimary(r, m.key);
- if (mode === "off") edges.push({ ri, mi, tone: "neutral", dashed: false });
- else if (mode === "strict") {
- if (primary) edges.push({ ri, mi, tone: "match", dashed: false });
- } else {
- edges.push({ ri, mi, tone: primary ? "match" : "fallback", dashed: !primary });
- }
- });
- });
-
- const recStatus = (r: string): "ok" | "warn" | "blocked" => {
- if (r === "other" || mode === "off") return "ok";
- const samePrimary = (r === "gmail" ? counts.gmail : counts.outlook) > 0 || counts.smtp > 0;
- if (samePrimary) return "ok";
- if (mode === "strict") return "blocked";
- return counts.total > 0 ? "warn" : "blocked";
- };
- const statusLabel = { ok: "Covered", warn: "Fallback", blocked: "Deferred" } as const;
- const statusText = { ok: "text-emerald-600", warn: "text-amber-600", blocked: "text-rose-600" } as const;
- const nodeBorder = {
- ok: "border-slate-200",
- warn: "border-amber-200 bg-amber-50/40",
- blocked: "border-rose-200 bg-rose-50/40",
- } as const;
+ // Pool is all wildcard mailboxes (SMTP only) → even strict can't narrow.
+ const onlyWildcard = counts.smtp > 0 && counts.gmail === 0 && counts.outlook === 0;
return (
-
-
Recipients
-
Your mailboxes
+ {/* Pool summary + active mode */}
+
+ Your mailboxes
+
+
+
+ {poolChips.map((p) => (
+
+
+
+ {LABEL[p.key]} {p.count}
+
+
+ ))}
-
- {/* Recipients (left) */}
-
- {recipients.map((r) => {
- const st = recStatus(r);
- return (
-
-
-
-
- {LABEL[r]}
-
-
- {statusLabel[st]}
-
+ {/* Recipient → serving mailboxes, ranked (match vs wildcard vs catch-all) */}
+
+ {(["gmail", "outlook"] as const).map((r) => {
+ const sameCount = r === "gmail" ? counts.gmail : counts.outlook;
+ const hasSame = sameCount > 0;
+ // SMTP/IMAP is a wildcard for Gmail/Outlook recipients only OUTSIDE
+ // strict — under strict, "same provider" excludes unknown-ESP SMTP.
+ const wildServes = counts.smtp > 0 && mode !== "strict";
+ // prefer falls back cross-provider when no same + no wildcard.
+ const crossCount = (r === "gmail" ? counts.outlook : counts.gmail);
+
+ let status: Status;
+ if (mode === "off") status = "any";
+ else if (hasSame || wildServes) status = "ok";
+ else if (mode === "prefer" && crossCount > 0) status = "warn";
+ else status = "blocked";
+
+ const tint =
+ status === "blocked"
+ ? "border-rose-200 bg-rose-50/40"
+ : status === "warn"
+ ? "border-amber-200 bg-amber-50/40"
+ : "border-slate-200 bg-white";
+
+ return (
+
+
+
+
+ {LABEL[r]} recipients
+
+
+
via
+ {mode === "off" ? (
+
+ ) : (
+ <>
+ {hasSame && (
+
+ )}
+ {wildServes && (
+
+ )}
+ {!hasSame && !wildServes && mode === "prefer" && crossCount > 0 && (
+
+ )}
+ {status === "blocked" && (
+
+ No matching mailbox
+
+ )}
+ >
+ )}
- );
- })}
-
+
+
+ );
+ })}
- {/* Connectors */}
-
- {edges.map((e, i) => (
-
- ))}
-
-
- {/* Mailboxes (right) */}
-
- {mailboxes.map((m) => {
- const empty = m.count === 0;
- return (
-
-
-
-
- {LABEL[m.key]}
-
-
- {m.count} {m.count === 1 ? "mailbox" : "mailboxes"}
-
-
-
- );
- })}
+ {/* Unknown-domain recipients always use any mailbox (wildcard), every mode. */}
+
- {/* Legend */}
-
-
- Match
-
- {mode === "prefer" && (
-
- Fallback
-
- )}
- {mode === "off" && (
-
- Any mailbox
-
- )}
-
+ {mode === "strict" && onlyWildcard && (
+
+ All your mailboxes are SMTP/IMAP, which can send to any provider — so Strict can't narrow by
+ provider here and behaves like Off. Connect a Google or Outlook mailbox to truly restrict
+ same-provider sending.
+
+ )}
{mode === "off"
? "Provider matching is off — any recipient can be sent from any mailbox in the pool."
: mode === "strict"
- ? "Strict only ever sends from a same-provider mailbox; a recipient with no match is held (deferred) until one frees up — never sent cross-provider. SMTP/IMAP mailboxes are a universal wildcard, and non-Google/Outlook domains send from any mailbox."
- : "Prefer uses a same-provider mailbox when one has capacity, otherwise it falls back to another provider — it never holds a recipient. SMTP/IMAP mailboxes can carry any provider."}
+ ? "Strict sends Google and Outlook recipients only from a same-provider mailbox (the sky match) — a recipient with no same-provider mailbox is held (deferred) until one frees up, never sent cross-provider. SMTP/IMAP mailboxes only carry non-Google/Outlook (“other”) domains under strict."
+ : "Prefer uses a same-provider mailbox when one has capacity (the sky match), otherwise it falls back to another provider (the amber chip) — it never holds a recipient. SMTP/IMAP mailboxes can carry any provider."}
);
}
+
+function AnyMailbox() {
+ return
Any mailbox in the pool ;
+}
+
+function Chip({
+ provider,
+ count,
+ variant,
+}: {
+ provider: string;
+ count: number;
+ variant: "match" | "wildcard" | "fallback";
+}) {
+ const cls =
+ variant === "match"
+ ? "border-sky-200 bg-sky-50/60"
+ : variant === "fallback"
+ ? "border-amber-200 bg-amber-50/60 border-dashed"
+ : "border-slate-200 bg-slate-50";
+ return (
+
+
+
+ ×{count}
+
+ {variant === "wildcard" && (
+ any
+ )}
+ {variant === "fallback" && (
+ fallback
+ )}
+
+ );
+}
+
+function StatusBadge({ status }: { status: Status }) {
+ const map = {
+ ok: { text: "Covered", cls: "bg-emerald-50 text-emerald-700", dot: "bg-emerald-500" },
+ warn: { text: "Fallback", cls: "bg-amber-50 text-amber-700", dot: "bg-amber-500" },
+ blocked: { text: "Deferred", cls: "bg-rose-50 text-rose-700", dot: "bg-rose-500" },
+ any: { text: "Any", cls: "bg-slate-100 text-slate-500", dot: "bg-slate-400" },
+ }[status];
+ return (
+
+
+ {map.text}
+
+ );
+}
+
+function ModePill({ mode }: { mode: Mode }) {
+ const m = {
+ off: { t: "Matching off", c: "bg-slate-100 text-slate-500" },
+ prefer: { t: "Prefer same", c: "bg-sky-50 text-sky-700" },
+ strict: { t: "Strict same", c: "bg-sky-50 text-sky-700" },
+ }[mode];
+ return (
+
+ {m.t}
+
+ );
+}
diff --git a/web/src/components/app/campaigns/preferences/ProviderLogo.tsx b/web/src/components/app/campaigns/preferences/ProviderLogo.tsx
index fee683bdd..3041c5493 100644
--- a/web/src/components/app/campaigns/preferences/ProviderLogo.tsx
+++ b/web/src/components/app/campaigns/preferences/ProviderLogo.tsx
@@ -1,7 +1,9 @@
-// ProviderLogo — small app-icon tiles with the recognizable brand mark for each
-// email provider we match against (Gmail, Outlook, and a neutral mark for
-// SMTP/IMAP or any other domain). Brand colours are intentional inline hex —
-// logos are exempt from the slate/sky chrome palette so they stay recognizable.
+// ProviderLogo — provider brand marks for the matching visual. Reuses the real,
+// official Google + Outlook SVGs the app already ships (the same ones the Add
+// Account flow uses, @/components/svg), so the logos are genuine and consistent
+// rather than hand-drawn. SMTP/IMAP and unknown domains get a neutral mail mark.
+
+import { Google, Outlook } from "@/components/svg";
export default function ProviderLogo({
provider,
@@ -12,55 +14,35 @@ export default function ProviderLogo({
className?: string;
muted?: boolean;
}) {
- const tile = `inline-flex items-center justify-center rounded-md shrink-0 ${className} ${
- muted ? "opacity-40 grayscale" : ""
- }`;
+ const wrap = `inline-flex items-center justify-center shrink-0 ${muted ? "opacity-40 grayscale" : ""}`;
if (provider === "gmail") {
return (
-
-
-
-
-
-
-
-
+
+
);
}
if (provider === "outlook") {
return (
-
-
- {/* the signature Outlook "O" */}
-
- {/* envelope card */}
-
-
-
+
+
);
}
// smtp_imap / other / unknown — neutral mail mark.
return (
-
+
diff --git a/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx b/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx
index 500e3dd3b..30c96ca51 100644
--- a/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx
+++ b/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx
@@ -236,10 +236,12 @@ export function OptionSelect({
* address and press Enter/Tab/comma to add; backspace on an empty field
* removes the last chip.
*/
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
export function EmailListInput({
values,
onChange,
- placeholder = "name@example.com — Enter to add",
+ placeholder = "name@example.com",
}: {
values: string[];
onChange: (values: string[]) => void;
@@ -247,38 +249,65 @@ export function EmailListInput({
}) {
const [draft, setDraft] = React.useState("");
+ // Split on whitespace/comma/semicolon so a pasted or typed list of addresses
+ // becomes individual chips, de-duplicated against what's already there.
+ const addTokens = (text: string) => {
+ const tokens = text
+ .split(/[\s,;]+/)
+ .map((t) => t.trim())
+ .filter(Boolean);
+ if (tokens.length === 0) return;
+ const next = [...values];
+ for (const t of tokens) if (!next.includes(t)) next.push(t);
+ onChange(next);
+ };
+
const commit = () => {
- const v = draft.trim().replace(/,$/, "").trim();
- if (!v) return;
- if (!values.includes(v)) onChange([...values, v]);
+ if (!draft.trim()) return;
+ addTokens(draft);
setDraft("");
};
return (
- {values.map((v, i) => (
-
- {v}
- onChange(values.filter((_, idx) => idx !== i))}
- aria-label={`Remove ${v}`}
- className="opacity-70 hover:opacity-100 shrink-0"
+ {values.map((v, i) => {
+ const invalid = !EMAIL_RE.test(v);
+ return (
+
-
-
-
-
-
- ))}
+
{v}
+
onChange(values.filter((_, idx) => idx !== i))}
+ aria-label={`Remove ${v}`}
+ className="opacity-70 hover:opacity-100 shrink-0"
+ >
+
+
+
+
+
+ );
+ })}
setDraft(e.target.value)}
+ onPaste={(e) => {
+ const text = e.clipboardData.getData("text");
+ if (/[\s,;]/.test(text)) {
+ e.preventDefault();
+ addTokens(text);
+ setDraft("");
+ }
+ }}
onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
+ if (e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === ";") {
if (draft.trim()) {
e.preventDefault();
commit();