diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index 985ec7e..cdefd66 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -131,6 +131,16 @@ export type CampaignSendNowPayload = { enqueue_imap_task?: boolean; }; +export type CampaignMockSendPayload = { + version_id?: string | null; + send?: boolean; + include_warnings?: boolean; + include_needs_review?: boolean; + append_sent?: boolean; + clear_mailbox?: boolean; + check_files?: boolean; +}; + export async function listCampaigns(settings: ApiSettings): Promise { const response = await apiFetch(settings, "/api/v1/campaigns"); @@ -329,6 +339,18 @@ export async function sendCampaignNow( }); } + +export async function mockSendCampaign( + settings: ApiSettings, + campaignId: string, + payload: CampaignMockSendPayload = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" }); } diff --git a/src/api/mail.ts b/src/api/mail.ts index f8b0e16..c8485f8 100644 --- a/src/api/mail.ts +++ b/src/api/mail.ts @@ -73,3 +73,58 @@ export async function listImapFolders( body: JSON.stringify(payload) }); } + +export type MockMailboxMessage = { + id: string; + kind: "smtp" | "imap_append" | string; + created_at: string; + envelope_from?: string | null; + envelope_recipients?: string[]; + subject?: string | null; + from_header?: string | null; + to_header?: string | null; + cc_header?: string | null; + bcc_header?: string | null; + message_id?: string | null; + size_bytes?: number; + body_preview?: string | null; + attachment_count?: number; + folder?: string | null; + raw_eml?: string | null; + headers?: Record; + attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>; +}; + +export type MockMailboxListResponse = { + messages: MockMailboxMessage[]; +}; + +export type MockMailboxMessageResponse = { + message: MockMailboxMessage; +}; + +export type MockMailboxFailureConfig = { + fail_next_smtp?: boolean | null; + fail_next_imap?: boolean | null; + smtp_reject_recipients_containing?: string | null; +}; + +export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise { + const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; + return apiFetch(settings, `/api/v1/dev/mailbox/messages${suffix}`); +} + +export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise { + return apiFetch(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`); +} + +export async function clearMockMailboxMessages(settings: ApiSettings): Promise<{ deleted_count: number }> { + return apiFetch<{ deleted_count: number }>(settings, "/api/v1/dev/mailbox/messages", { method: "DELETE" }); +} + +export async function updateMockMailboxFailures(settings: ApiSettings, payload: MockMailboxFailureConfig): Promise<{ config: MockMailboxFailureConfig }> { + return apiFetch<{ config: MockMailboxFailureConfig }>(settings, "/api/v1/dev/mailbox/failures", { + method: "POST", + body: JSON.stringify(payload) + }); +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx index e35dae4..22fbc8c 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,19 +1,44 @@ +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; + type CardProps = { title?: React.ReactNode; children: React.ReactNode; actions?: React.ReactNode; + collapsible?: boolean; }; -export default function Card({ title, children, actions }: CardProps) { +export default function Card({ title, children, actions, collapsible = false }: CardProps) { + const [collapsed, setCollapsed] = useState(false); + const hasHeader = Boolean(title || actions || collapsible); + const body =
{children}
; + const shouldRenderBody = !collapsible || !collapsed; + return ( -
- {(title || actions) && ( +
+ {hasHeader && (
{title && (typeof title === "string" ?

{title}

:
{title}
)} - {actions &&
{actions}
} + {(actions || collapsible) && ( +
+ {actions} + {collapsible && ( + + )} +
+ )}
)} -
{children}
+ {shouldRenderBody && (collapsible ?
{body}
: body)}
); } diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index c47fa1c..84b6c20 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -101,6 +101,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings Name Path Individual attachments + Unsent warning @@ -129,6 +130,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings patchBasePath(index, { allow_individual: checked })} /> + patchBasePath(index, { unsent_warning: checked })} /> ))} diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx index 5a90e8a..5fb6980 100644 --- a/src/features/campaigns/CampaignFieldsPage.tsx +++ b/src/features/campaigns/CampaignFieldsPage.tsx @@ -86,7 +86,11 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: setDraft((current) => { const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); - return updateNested(nextDraft, ["global_values"], nextGlobalValues); + const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues); + if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) { + return nextWithGlobalValues; + } + return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName); }); markDirty(); } @@ -219,6 +223,28 @@ function normalizeFields(value: unknown): CampaignFieldDefinition[] { })); } + +function migrateEntryFieldValues(draft: Record, oldName: string, newName: string): Record { + const entries = asRecord(draft.entries); + const inlineEntries = Array.isArray(entries.inline) ? entries.inline : []; + if (inlineEntries.length === 0) return draft; + + const nextInlineEntries = inlineEntries.map((entry) => { + if (!isRecord(entry)) return entry; + const fields = asRecord(entry.fields); + if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry; + + const nextFields = { ...fields }; + if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) { + nextFields[newName] = nextFields[oldName]; + } + delete nextFields[oldName]; + return { ...entry, fields: nextFields }; + }); + + return updateNested(draft, ["entries", "inline"], nextInlineEntries); +} + function migrateFieldOverridePolicy(draft: Record, editorState: Record): Record { const overridePolicy = asRecord(editorState.field_overrides); if (Object.keys(overridePolicy).length === 0) return draft; diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index 1c5535f..5e3c6d7 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -78,6 +78,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: patch(["validation_policy", "missing_required_attachment"], value)} /> patch(["validation_policy", "missing_optional_attachment"], value)} /> patch(["validation_policy", "ambiguous_attachment_match"], value)} /> + patch(["validation_policy", "unsent_attachment_files"], value)} /> patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} /> patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} /> patch(["validation_policy", "ignore_empty_fields"], checked)} /> diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx index a2b981a..f512fd5 100644 --- a/src/features/campaigns/MailSettingsPage.tsx +++ b/src/features/campaigns/MailSettingsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -8,7 +8,7 @@ import LoadingFrame from "../../components/LoadingFrame"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; import ToggleSwitch from "../../components/ToggleSwitch"; -import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail"; +import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asRecord, isAuditLockedVersion } from "./utils/campaignView"; @@ -21,7 +21,12 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [folderResult, setFolderResult] = useState(null); - const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null); + const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null); + const [mockMessages, setMockMessages] = useState([]); + const [selectedMockMessage, setSelectedMockMessage] = useState(null); + const [mockError, setMockError] = useState(""); + const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false); + const mockSandboxSnapshot = useRef | null>(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); @@ -137,6 +142,109 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } } + function toggleMockMailSandbox(enabled: boolean) { + if (locked) return; + setMockSandboxEnabled(enabled); + + if (enabled) { + mockSandboxSnapshot.current = { + smtp: { ...smtp }, + imap: { ...imap }, + imap_append_sent: { ...imapAppend } + }; + patch(["server", "smtp", "host"], "mock.smtp.local"); + patch(["server", "smtp", "port"], 2525); + patch(["server", "smtp", "security"], "plain"); + patch(["server", "smtp", "username"], "mock"); + patch(["server", "smtp", "password"], "mock"); + patch(["server", "imap", "enabled"], true); + patch(["server", "imap", "host"], "mock.imap.local"); + patch(["server", "imap", "port"], 1143); + patch(["server", "imap", "security"], "plain"); + patch(["server", "imap", "username"], "mock"); + patch(["server", "imap", "password"], "mock"); + patch(["server", "imap", "sent_folder"], "Sent"); + patch(["delivery", "imap_append_sent", "enabled"], true); + patch(["delivery", "imap_append_sent", "folder"], "Sent"); + setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } }); + return; + } + + const snapshot = mockSandboxSnapshot.current; + if (snapshot) { + patch(["server", "smtp"], asRecord(snapshot.smtp)); + patch(["server", "imap"], asRecord(snapshot.imap)); + patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent)); + } + mockSandboxSnapshot.current = null; + setSmtpTestResult(null); + setImapTestResult(null); + } + + async function loadMockMailbox() { + setMailActionState("mock"); + setMockError(""); + try { + const response = await listMockMailboxMessages(settings); + setMockMessages(response.messages); + } catch (err) { + setMockError(err instanceof Error ? err.message : String(err)); + } finally { + setMailActionState(null); + } + } + + async function openMockMessage(id: string) { + setMailActionState("mock"); + setMockError(""); + try { + const response = await getMockMailboxMessage(settings, id); + setSelectedMockMessage(response.message); + } catch (err) { + setMockError(err instanceof Error ? err.message : String(err)); + } finally { + setMailActionState(null); + } + } + + async function clearMockMailbox() { + setMailActionState("mock"); + setMockError(""); + try { + await clearMockMailboxMessages(settings); + setMockMessages([]); + setSelectedMockMessage(null); + } catch (err) { + setMockError(err instanceof Error ? err.message : String(err)); + } finally { + setMailActionState(null); + } + } + + async function failNextMockSmtp() { + setMailActionState("mock"); + setMockError(""); + try { + await updateMockMailboxFailures(settings, { fail_next_smtp: true }); + } catch (err) { + setMockError(err instanceof Error ? err.message : String(err)); + } finally { + setMailActionState(null); + } + } + + async function failNextMockImap() { + setMailActionState("mock"); + setMockError(""); + try { + await updateMockMailboxFailures(settings, { fail_next_imap: true }); + } catch (err) { + setMockError(err instanceof Error ? err.message : String(err)); + } finally { + setMailActionState(null); + } + } + return (
@@ -161,6 +269,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A

SMTP login

+
patch(["server", "smtp", "host"], event.target.value)} /> @@ -206,6 +315,67 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
+ +
+
+

Captured messages

+

Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.

+
+
+ + + + +
+
+ {mockError &&
{mockError}
} +
+ + + + + + + + + + + + + {mockMessages.map((message) => ( + + + + + + + + + ))} + {mockMessages.length === 0 && ( + + )} + +
KindReceivedSubjectEnvelopeAttachmentsActions
{message.kind === "imap_append" ? "IMAP" : "SMTP"}{formatMockDate(message.created_at)}{message.subject || "—"}{message.envelope_from || message.folder || "—"} → {(message.envelope_recipients || []).join(", ") || message.folder || "—"}{message.attachment_count ?? 0}
No mock messages captured yet.
+
+ {selectedMockMessage && ( +
+
+

{selectedMockMessage.subject || "Mock message"}

+ +
+
+
From{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}
+
To{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}
+
Size{selectedMockMessage.size_bytes || 0} bytes
+
Folder{selectedMockMessage.folder || "—"}
+
+ {selectedMockMessage.body_preview &&
{selectedMockMessage.body_preview}
} + {selectedMockMessage.raw_eml &&
Raw MIME
{selectedMockMessage.raw_eml}
} +
+ )} +
+
@@ -216,6 +386,14 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A } +function formatMockDate(value: string): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + + function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) { if (!result) return null; const authenticated = result.details?.authenticated; diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx index 98546c9..e5e90ab 100644 --- a/src/features/campaigns/ReviewDataPage.tsx +++ b/src/features/campaigns/ReviewDataPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; @@ -10,20 +10,59 @@ import Card from "../../components/Card"; import StatusBadge from "../../components/StatusBadge"; import { buildVersion, validateVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView"; +import { + asArray, + asRecord, + formatDateTime, + isAuditLockedVersion, + isFinalLockedVersion, + isUserLockedVersion, + isVersionReadyForDelivery, + stringifyPreview, + summaryValue, + versionLockReason, +} from "./utils/campaignView"; -export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { - const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); +export default function ReviewDataPage({ + settings, + campaignId, +}: { + settings: ApiSettings; + campaignId: string; +}) { + const { data, loading, error, reload, setError } = useCampaignWorkspaceData( + settings, + campaignId, + { includeSummary: true }, + ); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const auditSafe = isUserLockedVersion(version) || isFinalLockedVersion(version); - const issues = collectIssues(data.summary?.issues); + const auditSafe = + isUserLockedVersion(version) || isFinalLockedVersion(version); const validationSummary = asRecord(version?.validation_summary); const buildSummary = asRecord(version?.build_summary); const validationOk = validationSummary.ok === true; const readyForDelivery = isVersionReadyForDelivery(version); - const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >(""); + const [actionBusy, setActionBusy] = useState<"validate" | "build" | "">(""); const [actionMessage, setActionMessage] = useState(""); + const [lastValidationResult, setLastValidationResult] = useState | null>(null); + + useEffect(() => { + setLastValidationResult(null); + }, [version?.id]); + + const issues = useMemo( + () => + collectIssues( + lastValidationResult, + validationSummary, + data.summary?.issues, + ), + [lastValidationResult, validationSummary, data.summary?.issues], + ); async function runValidate() { if (!version || actionBusy) return; @@ -32,7 +71,12 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api setError(""); try { const result = await validateVersion(settings, version.id, false); - setActionMessage(result.ok ? "Validation passed. This version is now locked but can still be unlocked before sending." : "Validation finished with issues. Fix the campaign and validate again."); + setLastValidationResult(result); + setActionMessage( + result.ok + ? "Validation passed. This version is now locked but can still be unlocked before sending." + : "Validation finished with issues. See the validation issues below, fix the campaign, and validate again.", + ); await reload(); } catch (err) { setActionMessage(""); @@ -49,7 +93,9 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api setError(""); try { const result = await buildVersion(settings, version.id, true); - setActionMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`); + setActionMessage( + `Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`, + ); await reload(); } catch (err) { setActionMessage(""); @@ -64,89 +110,247 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
Review - +
- - + + + +
{error &&
{error}
} {actionMessage &&
{actionMessage}
} - {locked && } + {locked && ( + + )} - Validation locks this version; unlocking invalidates validation before sending.}> -
- - + + Validation locks this version; unlocking invalidates validation + before sending. + + } + > +
+ + +
+
+
+
Version state
+
{version?.workflow_state ?? "—"}
+
+
+
Lock
+
{versionLockReason(version)}
+
+
+
Validation
+
+ {validationOk + ? "Passed" + : version?.validation_summary + ? "Needs attention" + : "Not validated"} +
+
+
+
Build
+
+ {String( + buildSummary.built_count ?? + buildSummary.ready_count ?? + "Not built", + )} +
+
+
+
+ +
+ +
+ + + + +
+ {!version?.validation_summary && ( +

No validation summary is stored yet.

+ )} +
+ + +
+ + + + +
+ {!version?.build_summary && ( +

No build summary is stored yet.

+ )} +
-
-
Version state
{version?.workflow_state ?? "—"}
-
Lock
{versionLockReason(version)}
-
Validation
{validationOk ? "Passed" : version?.validation_summary ? "Needs attention" : "Not validated"}
-
Build
{String(buildSummary.built_count ?? buildSummary.ready_count ?? "Not built")}
-
- -
- -
- - - - -
- {!version?.validation_summary &&

No validation summary is stored yet.

} -
- - -
- - - - -
- {!version?.build_summary &&

No build summary is stored yet.

} -
-
- - Grouped issue display will be expanded in the next review pass.}> - {issues.length === 0 &&

No stored issues were returned for this campaign summary.

} - {issues.length > 0 && ( -
- - - - - - - - - - {issues.map((issue, index) => ( - - - - + 0 ? ( + {issues.length} issue(s) + ) : undefined + } + > + {issues.length === 0 && ( +

+ No validation issues are stored for this version. Run validation + to populate this list. +

+ )} + {issues.length > 0 && ( +
+
SeveritySectionMessage
{String(issue.section || issue.field || "—")}{String(issue.message || issue.code || stringifyPreview(issue, 180))}
+ + + + + + - ))} - -
SeverityLocationCodeMessage
-
- )} -
+ + + {issues.map((issue, index) => ( + + + + + + {String( + issue.path || + issue.source || + issue.section || + issue.field || + "—", + )} + + {String(issue.code || "—")} + + {String(issue.message || stringifyPreview(issue, 220))} + + + ))} + + +
+ )} +
); } -function SummaryTile({ label, value }: { label: string; value: string | number }) { +function SummaryTile({ + label, + value, +}: { + label: string; + value: string | number; +}) { return (
{label} @@ -155,11 +359,68 @@ function SummaryTile({ label, value }: { label: string; value: string | number } ); } -function collectIssues(raw: unknown): Record[] { +function collectIssues(...sources: unknown[]): Record[] { + const byKey = new Map>(); + + for (const source of sources) { + for (const issue of collectIssueSource(source)) { + const normalized = normalizeIssue(issue); + const key = + String(normalized.severity ?? "") + + "|" + + String( + normalized.path ?? + normalized.source ?? + normalized.section ?? + normalized.field ?? + "", + ) + + "|" + + String(normalized.code ?? "") + + "|" + + String(normalized.message ?? ""); + byKey.set(key, { ...normalized, issueKey: key }); + } + } + + return Array.from(byKey.values()).sort( + (left, right) => severityRank(left.severity) - severityRank(right.severity), + ); +} + +function collectIssueSource(raw: unknown): Record[] { if (Array.isArray(raw)) return raw.map(asRecord); if (!raw || typeof raw !== "object") return []; const record = raw as Record; const direct = asArray(record.items ?? record.issues ?? record.results); if (direct.length) return direct.map(asRecord); - return Object.entries(record).flatMap(([section, value]) => asArray(value).map((item) => ({ section, ...asRecord(item) }))); + return Object.entries(record).flatMap(([section, value]) => + asArray(value).map((item) => ({ section, ...asRecord(item) })), + ); +} + +function normalizeIssue( + issue: Record, +): Record { + return { + ...issue, + severity: issue.severity ?? issue.level ?? issue.tone ?? "info", + path: issue.path ?? issue.source ?? issue.location ?? issue.field, + code: issue.code ?? issue.type, + message: issue.message ?? issue.detail ?? issue.description, + }; +} + +function severityRank(value: unknown): number { + switch (String(value || "info").toLowerCase()) { + case "error": + case "danger": + case "blocked": + return 0; + case "warning": + case "warn": + return 1; + default: + return 2; + } } diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index 0af09cd..e682bbe 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -10,7 +10,9 @@ import VersionLine from "./components/VersionLine"; import Card from "../../components/Card"; import MetricCard from "../../components/MetricCard"; import StatusBadge from "../../components/StatusBadge"; -import { sendCampaignNow } from "../../api/campaigns"; +import ToggleSwitch from "../../components/ToggleSwitch"; +import { mockSendCampaign, sendCampaignNow } from "../../api/campaigns"; +import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVersion, isVersionReadyForDelivery, stringifyPreview, versionLockReason } from "./utils/campaignView"; @@ -27,6 +29,12 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe const [sendMessage, setSendMessage] = useState(""); const [sendResult, setSendResult] = useState | null>(null); const [sendConfirmOpen, setSendConfirmOpen] = useState(false); + const [mockBusy, setMockBusy] = useState(false); + const [mockMessage, setMockMessage] = useState(""); + const [mockResult, setMockResult] = useState | null>(null); + const [mockAppendSent, setMockAppendSent] = useState(true); + const [mockClearFirst, setMockClearFirst] = useState(true); + const [selectedMockMessage, setSelectedMockMessage] = useState(null); const readyForDelivery = isVersionReadyForDelivery(version); const hasBuild = Boolean(version?.build_summary); @@ -61,6 +69,51 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe } } + async function runMockFlow(send: boolean, includeNeedsReview = false) { + if (!version || mockBusy) return; + setMockBusy(true); + setMockMessage(send ? "Running mock delivery…" : "Validating and building mock delivery plan…"); + setMockResult(null); + setSelectedMockMessage(null); + setError(""); + try { + const response = await mockSendCampaign(settings, campaignId, { + version_id: version.id, + send, + include_warnings: true, + include_needs_review: includeNeedsReview, + append_sent: mockAppendSent, + clear_mailbox: send && mockClearFirst, + check_files: false + }); + const result = asRecord(response.result ?? response); + const sendSection = asRecord(result.send); + setMockResult(result); + setMockMessage( + send + ? `Mock send finished. Captured ${String(sendSection.sent_count ?? 0)} SMTP message(s), failed ${String(sendSection.failed_count ?? 0)}, skipped ${String(sendSection.skipped_count ?? 0)}.` + : "Mock review finished. Inspect the steps and job details below before sending." + ); + } catch (err) { + setMockMessage(""); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setMockBusy(false); + } + } + + async function openMockMessage(id: string) { + setMockBusy(true); + try { + const response = await getMockMailboxMessage(settings, id); + setSelectedMockMessage(response.message); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setMockBusy(false); + } + } + const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? ""); useEffect(() => { @@ -70,22 +123,30 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe }, [queuedOrSending, loading, reload, sendBusy]); const resultRows = asArray(sendResult?.results).map(asRecord); + const mockSteps = asArray(mockResult?.steps).map(asRecord); + const mockBuild = asRecord(mockResult?.build); + const mockMessages = asArray(mockBuild.messages).map(asRecord); + const mockSend = asRecord(mockResult?.send); + const mockRows = asArray(mockSend.results).map(asRecord); + const mockMailbox = asRecord(mockResult?.mailbox); + const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord); return (
- Send + Send
- +
{error &&
{error}
} {sendMessage &&
{sendMessage}
} + {mockMessage &&
{mockMessage}
} {locked && } @@ -96,6 +157,123 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
+ Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.}> +
+ + + +
+
+ + +
+ + {mockResult && ( +
+
+ + + + {mockSteps.map((step) => ( + + + + + + ))} + +
StepStatusSummary
{String(step.label ?? step.key)}{stringifyPreview(asRecord(step.summary), 240)}
+
+ +

Built messages

+
+ + + + {mockMessages.map((message) => { + const to = asArray(message.to).map(asRecord); + const issues = asArray(message.issues).map(asRecord); + return ( + + + + + + + + + ); + })} + {mockMessages.length === 0 && } + +
#RecipientSubjectValidationAttachmentsIssues
{String(message.entry_index ?? "—")}{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}{String(message.subject ?? "—")}{String(message.attachment_count ?? 0)}{issues.length === 0 ? "—" : issues.map((issue) => String(issue.message ?? issue.code ?? "issue")).join(" · ")}
No messages were built.
+
+ + {mockRows.length > 0 && ( + <> +

Mock send results

+
+ + + + {mockRows.map((row, index) => { + const to = asArray(row.to).map(asRecord); + return ( + + + + + + + + + ); + })} + +
#StatusRecipientSMTPIMAPMessage
{String(row.entry_index ?? index + 1)}{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}{String(row.smtp_message_id ?? row.status ?? "—")}{String(row.imap_message_id ?? row.imap_status ?? "—")}{String(row.message ?? "—")}
+
+ + )} + +

Mock mailbox

+
+ + + + {mockMailboxMessages.map((message) => ( + + + + + + + + ))} + {mockMailboxMessages.length === 0 && } + +
KindSubjectEnvelope / folderAttachmentsActions
{String(message.kind ?? "mock")}{String(message.subject ?? "—")}{String(message.envelope_from ?? message.folder ?? "—")} → {asArray(message.envelope_recipients).join(", ") || String(message.folder ?? "—")}{String(message.attachment_count ?? 0)}
No mock messages captured in this run.
+
+ + {selectedMockMessage && ( +
+
+

{selectedMockMessage.subject || "Mock message"}

+ +
+
+
From{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}
+
To{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}
+
Size{selectedMockMessage.size_bytes || 0} bytes
+
Folder{selectedMockMessage.folder || "—"}
+
+ {selectedMockMessage.body_preview &&
{selectedMockMessage.body_preview}
} + {selectedMockMessage.raw_eml &&
Raw MIME
{selectedMockMessage.raw_eml}
} +
+ )} +
+ )} +
+ Requires a validated, locked and built version. Sending makes it final.}> {!readyForDelivery &&
Validate and lock this version in Review before dry-run or sending.
} {readyForDelivery && !hasBuild &&
Build the queue in Review before dry-run or sending.
} diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index d9d1a54..3833e6a 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; @@ -11,6 +12,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { cloneJson, getBool, getText } from "./utils/draftEditor"; +import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments"; import { humanizeFieldName } from "./utils/fieldDefinitions"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; @@ -61,6 +63,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields); const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields); const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields); + const previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]); useEffect(() => { @@ -239,9 +242,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A text={previewText} html={previewHtml} hasRealRecipients={inlineEntries.length > 0} + attachments={previewAttachments} onClose={() => setPreviewOpen(false)} + onFirst={() => setPreviewIndex(0)} onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))} onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))} + onLast={() => setPreviewIndex(previewEntries.length - 1)} /> )} @@ -319,9 +325,12 @@ function TemplatePreviewOverlay({ text, html, hasRealRecipients, + attachments, onClose, + onFirst, onPrevious, - onNext + onNext, + onLast }: { bodyMode: BodyMode; entry: Record; @@ -331,9 +340,12 @@ function TemplatePreviewOverlay({ text: string; html: string; hasRealRecipients: boolean; + attachments: EffectiveAttachmentPreview[]; onClose: () => void; + onFirst: () => void; onPrevious: () => void; onNext: () => void; + onLast: () => void; }) { return (
@@ -348,9 +360,12 @@ function TemplatePreviewOverlay({ {hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}

{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}

-
- - +
+ + + {index + 1} / {total} + +
@@ -361,6 +376,7 @@ function TemplatePreviewOverlay({
{text || "No plain-text body to preview."}
)}
+
@@ -368,6 +384,45 @@ function TemplatePreviewOverlay({ ); } + +function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) { + return ( +
+

Effective attachments

+ {attachments.length === 0 ? ( +

No global or recipient attachments are effective for this message.

+ ) : ( +
+ + + + + + + + + + + + + {attachments.map((attachment, index) => ( + + + + + + + + + ))} + +
ScopeLabelBase pathFile / patternOptionsPreview match
{attachment.scope === "global" ? "Global" : "Recipient"}{attachment.label}{attachment.basePathName}
{attachment.basePath}
{attachment.fileFilter || "—"}{attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""}{attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"}
+
+ )} +
+ ); +} + function recipientLabel(entry: Record, index: number): string { const name = valueToPreview(entry.name).trim(); const email = valueToPreview(entry.email).trim(); diff --git a/src/features/campaigns/utils/attachments.ts b/src/features/campaigns/utils/attachments.ts index f5bef55..1fcb427 100644 --- a/src/features/campaigns/utils/attachments.ts +++ b/src/features/campaigns/utils/attachments.ts @@ -1,5 +1,6 @@ import { asArray, asRecord, isRecord } from "./campaignView"; import { getBool, getText } from "./draftEditor"; +import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders"; export type AttachmentRule = Record; @@ -9,6 +10,7 @@ export type AttachmentBasePath = { path: string; source?: string; allow_individual?: boolean; + unsent_warning?: boolean; }; export type AttachmentSummary = { @@ -16,6 +18,17 @@ export type AttachmentSummary = { rules: number; }; +export type EffectiveAttachmentPreview = { + scope: "global" | "recipient"; + label: string; + basePathName: string; + basePath: string; + fileFilter: string; + required: boolean; + includeSubdirs: boolean; + matches: string[]; +}; + export type MockAttachmentPathOption = Partial & { label: string; }; @@ -41,7 +54,8 @@ export function createAttachmentBasePath(name = "New attachment source", path = id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`, name, path, - allow_individual: false + allow_individual: false, + unsent_warning: false }; } @@ -63,7 +77,8 @@ export function normalizeAttachmentBasePaths(value: unknown, attachments: Record name: getText(basePath, "name", `Base path ${index + 1}`), source: getText(basePath, "source"), path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."), - allow_individual: getBool(basePath, "allow_individual") + allow_individual: getBool(basePath, "allow_individual"), + unsent_warning: getBool(basePath, "unsent_warning") })); } @@ -71,7 +86,8 @@ export function normalizeAttachmentBasePaths(value: unknown, attachments: Record id: "base-path-campaign", name: "Campaign files", path: getText(attachments, "base_path", "."), - allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual) + allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual), + unsent_warning: false }]; } @@ -88,7 +104,6 @@ export function normalizeAttachmentRules(value: unknown): AttachmentRule[] { if (!Array.isArray(value)) return []; return value.filter(isRecord).map((rule) => ({ id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`), - type: getText(rule, "type"), label: getText(rule, "label"), base_dir: getText(rule, "base_dir", ""), file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"), @@ -127,3 +142,67 @@ export function isDirectAttachmentRule(rule: AttachmentRule): boolean { if (!fileFilter) return false; return !/[{}*?\[\]]/.test(fileFilter); } + + +export function buildEffectiveAttachmentPreviews(draft: Record | null, entry: Record): EffectiveAttachmentPreview[] { + const attachments = asRecord(draft?.attachments); + const basePaths = normalizeAttachmentBasePaths(attachments.base_paths, attachments); + const context = buildTemplatePreviewContext(draft, entry); + const includeGlobals = getBool(entry, "combine_attachments", true); + const globalRules = includeGlobals ? normalizeAttachmentRules(attachments.global) : []; + const entryRules = normalizeAttachmentRules(entry.attachments); + const individualPaths = basePaths.filter((basePath) => basePath.allow_individual); + const legacyIndividualAllowed = getBool(attachments, "allow_individual", individualPaths.length === 0); + + const items: EffectiveAttachmentPreview[] = []; + for (const rule of globalRules) { + items.push(ruleToAttachmentPreview("global", rule, basePaths, context)); + } + for (const rule of entryRules) { + const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "."); + const allowed = individualPaths.length > 0 + ? individualPaths.some((basePath) => basePath.path === basePathValue) + : legacyIndividualAllowed; + if (allowed) items.push(ruleToAttachmentPreview("recipient", rule, basePaths, context)); + } + return items; +} + +function ruleToAttachmentPreview( + scope: EffectiveAttachmentPreview["scope"], + rule: AttachmentRule, + basePaths: AttachmentBasePath[], + context: Record +): EffectiveAttachmentPreview { + const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "."); + const basePath = basePaths.find((item) => item.path === basePathValue); + const fileFilterTemplate = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"); + const fileFilter = renderTemplatePreviewText(fileFilterTemplate, context, true); + return { + scope, + label: getText(rule, "label") || (scope === "global" ? "Global attachment" : "Recipient attachment"), + basePathName: basePath?.name || basePathValue || "Campaign files", + basePath: basePath?.path || basePathValue || ".", + fileFilter, + required: getBool(rule, "required", true), + includeSubdirs: getBool(rule, "include_subdirs"), + matches: previewFileMatches(fileFilter) + }; +} + +function previewFileMatches(fileFilter: string): string[] { + const value = fileFilter.trim(); + if (!value) return []; + if (!/[{}*?\[\]]/.test(value)) { + return mockAttachmentFiles.filter((file) => file === value || file.endsWith(`/${value}`)); + } + const pattern = globLikePatternToRegExp(value); + return mockAttachmentFiles.filter((file) => pattern.test(file)); +} + +function globLikePatternToRegExp(value: string): RegExp { + const escaped = value.replace(/[.+^${}()|\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`); +} diff --git a/src/features/campaigns/utils/draftEditor.ts b/src/features/campaigns/utils/draftEditor.ts index 5f47e13..39fabed 100644 --- a/src/features/campaigns/utils/draftEditor.ts +++ b/src/features/campaigns/utils/draftEditor.ts @@ -32,7 +32,10 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco ...asRecord(raw.attachments) }; raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] }; - raw.validation_policy = isRecord(raw.validation_policy) ? raw.validation_policy : {}; + raw.validation_policy = { + unsent_attachment_files: "warn", + ...asRecord(raw.validation_policy) + }; raw.delivery = isRecord(raw.delivery) ? raw.delivery : {}; raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true }; return raw; diff --git a/src/features/campaigns/utils/templatePlaceholders.ts b/src/features/campaigns/utils/templatePlaceholders.ts index 9e9c940..d35a66c 100644 --- a/src/features/campaigns/utils/templatePlaceholders.ts +++ b/src/features/campaigns/utils/templatePlaceholders.ts @@ -80,6 +80,13 @@ export function buildTemplatePreviewContext(draft: Record | nul const entryFields = asRecord(entry.fields); const overridePolicy = fieldOverridePolicy(draft); + for (const field of asArray(draft?.fields).map(asRecord)) { + const name = String(field.name || field.id || "").trim(); + if (!name) continue; + addPreviewContextValue(context, name, "global", ""); + addPreviewContextValue(context, name, "local", ""); + } + for (const [key, value] of Object.entries(globalValues)) { addPreviewContextValue(context, key, "global", value); addPreviewContextValue(context, key, "local", value); diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css index a815104..63d8a87 100644 --- a/src/styles/campaign-workspace.css +++ b/src/styles/campaign-workspace.css @@ -664,6 +664,36 @@ width: min(620px, 100%); } + +.template-preview-nav { + align-items: center; + gap: 0.25rem; +} + +.template-preview-count { + color: var(--muted); + margin: 0 0.4rem; + white-space: nowrap; +} + +.template-preview-attachments { + margin-top: 16px; +} + +.template-preview-attachments h3 { + margin: 0 0 8px; +} + +.template-preview-attachments .app-table-wrap { + max-height: 220px; + overflow: scroll; + margin-top: 10px; +} + +.template-preview-attachments code { + white-space: nowrap; +} + @media (max-width: 720px) { .template-preview-toolbar { display: grid; @@ -802,7 +832,9 @@ .attachment-sources-table th:nth-child(2), .attachment-sources-table td:nth-child(2) { min-width: 200px; } .attachment-sources-table th:nth-child(3), -.attachment-sources-table td:nth-child(3) { width: 207px; } +.attachment-sources-table td:nth-child(3), +.attachment-sources-table th:nth-child(4), +.attachment-sources-table td:nth-child(4) { width: 170px; } .attachment-sources-table th:last-child, .attachment-sources-table td:last-child { width: 123px; } @@ -1112,7 +1144,7 @@ flex-wrap: nowrap; justify-content: flex-end; } -.version-line button.version-arrow { +.version-arrow:is(button) { border: 0; background: transparent; padding: 0; @@ -1120,7 +1152,37 @@ font: inherit; } +.version-arrow:is(button):disabled { + opacity: 0.24; + pointer-events: none; + cursor: default; +} + /* Overview version history refinements. */ .version-history-table .current-version-row td { font-weight: 700; } + +.mock-message-detail { + margin-top: 16px; + border-top: 1px solid var(--line-subtle); + padding-top: 16px; +} + +.mock-message-preview, +.mock-message-raw { + width: 100%; + max-height: 260px; + overflow: auto; + border: 1px solid var(--line-subtle); + border-radius: 12px; + background: rgba(255, 255, 255, 0.62); + color: var(--text-primary); + padding: 12px; + white-space: pre-wrap; +} + +.mock-message-raw { + max-height: 420px; + font-size: 0.82rem; +} diff --git a/src/styles/components.css b/src/styles/components.css index 290100b..a180dc6 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -550,3 +550,60 @@ justify-content: flex-end; margin-top: 1.1rem; } + +/* Collapsible cards */ +.card-collapsible .card-header { + gap: 12px; +} +.card-collapsible .card-actions { + align-items: center; +} +.card-collapse-toggle { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid #c9c3b9; + border-radius: 999px; + background: linear-gradient(#ffffff, #f1efeb); + color: #4f4a43; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05); + transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease; +} +.card-collapse-toggle:hover { + border-color: #aaa299; + background: linear-gradient(#ffffff, #e8e5df); + box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08); +} +.card-collapse-toggle:focus-visible { + outline: 3px solid rgba(82, 130, 177, .22); + outline-offset: 2px; +} +.card-collapse-toggle svg { + transition: transform .22s ease; +} +.card-collapsible.is-collapsed .card-collapse-toggle svg { + transform: rotate(-180deg); +} +.card-collapse-region { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows .24s ease, opacity .18s ease; +} +.card-collapsible.is-collapsed .card-collapse-region { + display: none; +} +.card-collapse-region > .card-body { + min-height: 0; + overflow: hidden; +} +@media (prefers-reduced-motion: reduce) { + .card-collapse-toggle, + .card-collapse-toggle svg, + .card-collapse-region { + transition: none; + } +}