initial commit after split
This commit is contained in:
627
webui/src/features/campaigns/MailSettingsPage.tsx
Normal file
627
webui/src/features/campaigns/MailSettingsPage.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import LoadingFrame from "../../components/LoadingFrame";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
|
||||
import {
|
||||
clearMockMailboxMessages,
|
||||
createMailServerProfile,
|
||||
getMailProfilePolicy,
|
||||
getMockMailboxMessage,
|
||||
listImapFolders,
|
||||
listMailProfileImapFolders,
|
||||
listMailServerProfiles,
|
||||
listMockMailboxMessages,
|
||||
testImapSettings,
|
||||
testMailProfileImap,
|
||||
testMailProfileSmtp,
|
||||
testSmtpSettings,
|
||||
updateMockMailboxFailures,
|
||||
type MailConnectionTestResponse,
|
||||
type MailImapFolderListResponse,
|
||||
type MailProfilePolicy,
|
||||
type MailSecurity,
|
||||
type MailServerProfile,
|
||||
type MockMailboxMessage
|
||||
} from "../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { getBool, getNumber, getText } from "./utils/draftEditor";
|
||||
|
||||
const securityOptions = ["plain", "tls", "starttls"];
|
||||
|
||||
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
|
||||
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
|
||||
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
|
||||
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
|
||||
const [mockError, setMockError] = useState("");
|
||||
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
|
||||
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(null);
|
||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [profileMessage, setProfileMessage] = useState("");
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
locked,
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "mail-settings",
|
||||
unsavedTitle: "Unsaved server settings",
|
||||
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
});
|
||||
const server = asRecord(displayDraft.server);
|
||||
const smtp = asRecord(server.smtp);
|
||||
const imap = asRecord(server.imap);
|
||||
const delivery = asRecord(displayDraft.delivery);
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
const imapEnabled = getBool(imap, "enabled");
|
||||
const selectedProfileId = getText(server, "mail_profile_id");
|
||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const usingMailProfile = Boolean(selectedProfileId);
|
||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
||||
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
|
||||
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
|
||||
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
|
||||
const smtpDisabled = locked || usingMailProfile;
|
||||
const smtpCredentialDisabled = locked || (usingMailProfile && smtpCredentialsInherited);
|
||||
const imapServerDisabled = locked || usingMailProfile || !imapEnabled;
|
||||
const imapCredentialDisabled = locked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
|
||||
const imapDisabled = locked || !effectiveImapAvailable;
|
||||
|
||||
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
|
||||
|
||||
async function refreshMailProfiles() {
|
||||
setProfilesLoading(true);
|
||||
setProfileError("");
|
||||
try {
|
||||
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
|
||||
listMailServerProfiles(settings, false, campaignId),
|
||||
listMailServerProfiles(settings, true),
|
||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
|
||||
]);
|
||||
setMailProfiles(allowedProfiles);
|
||||
setPolicyProfiles(visibleProfiles);
|
||||
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
|
||||
} catch (err) {
|
||||
setMailProfiles([]);
|
||||
setPolicyProfiles([]);
|
||||
setEffectiveMailPolicy(null);
|
||||
setProfileError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setProfilesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectMailProfile(profileId: string) {
|
||||
if (locked) return;
|
||||
patch(["server", "mail_profile_id"], profileId);
|
||||
setProfileMessage("");
|
||||
setProfileError("");
|
||||
if (profileId) {
|
||||
patch(["server", "smtp"], {});
|
||||
patch(["server", "imap"], {});
|
||||
setMockSandboxEnabled(false);
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentSettingsAsProfile() {
|
||||
if (locked || usingMailProfile) return;
|
||||
setProfilesLoading(true);
|
||||
setProfileMessage("");
|
||||
setProfileError("");
|
||||
try {
|
||||
const created = await createMailServerProfile(settings, {
|
||||
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
|
||||
scope_type: "campaign",
|
||||
scope_id: campaignId,
|
||||
smtp: smtpPayload(),
|
||||
imap: imapEnabled ? imapPayload() : null,
|
||||
is_active: true
|
||||
});
|
||||
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
patch(["server", "mail_profile_id"], created.id);
|
||||
patch(["server", "smtp"], {});
|
||||
patch(["server", "imap"], {});
|
||||
setProfileName("");
|
||||
setProfileMessage(`Saved profile ${created.name}.`);
|
||||
} catch (err) {
|
||||
setProfileError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setProfilesLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImap(enabled: boolean) {
|
||||
patch(["server", "imap", "enabled"], enabled);
|
||||
if (!enabled) {
|
||||
patch(["delivery", "imap_append_sent", "enabled"], false);
|
||||
}
|
||||
}
|
||||
|
||||
function profileScopeLabel(profile: MailServerProfile): string {
|
||||
if (profile.scope_type === "system") return "system";
|
||||
if (profile.scope_type === "tenant") return "tenant";
|
||||
if (profile.scope_type === "user") return "user";
|
||||
if (profile.scope_type === "group") return "group";
|
||||
return "campaign";
|
||||
}
|
||||
|
||||
|
||||
function emptyToNull(value: string, trim = true): string | null {
|
||||
const normalized = trim ? value.trim() : value;
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
|
||||
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
|
||||
}
|
||||
|
||||
|
||||
function smtpPayload() {
|
||||
return {
|
||||
host: emptyToNull(getText(smtp, "host")),
|
||||
port: getNumber(smtp, "port", 587),
|
||||
username: emptyToNull(getText(smtp, "username")),
|
||||
password: emptyToNull(getText(smtp, "password"), false),
|
||||
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
|
||||
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
|
||||
};
|
||||
}
|
||||
|
||||
function imapPayload() {
|
||||
return {
|
||||
enabled: true,
|
||||
host: emptyToNull(getText(imap, "host")),
|
||||
port: getNumber(imap, "port", 993),
|
||||
username: emptyToNull(getText(imap, "username")),
|
||||
password: emptyToNull(getText(imap, "password"), false),
|
||||
security: readSecurity(getText(imap, "security", "tls"), "tls"),
|
||||
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
|
||||
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveSmtpPayload() {
|
||||
if (selectedProfile && !smtpCredentialsInherited) {
|
||||
return {
|
||||
...selectedProfile.smtp,
|
||||
username: emptyToNull(getText(smtp, "username")),
|
||||
password: emptyToNull(getText(smtp, "password"), false)
|
||||
};
|
||||
}
|
||||
return smtpPayload();
|
||||
}
|
||||
|
||||
function effectiveImapPayload() {
|
||||
if (selectedProfile?.imap && !imapCredentialsInherited) {
|
||||
return {
|
||||
...selectedProfile.imap,
|
||||
enabled: true,
|
||||
username: emptyToNull(getText(imap, "username")),
|
||||
password: emptyToNull(getText(imap, "password"), false)
|
||||
};
|
||||
}
|
||||
return imapPayload();
|
||||
}
|
||||
|
||||
async function runSmtpTest() {
|
||||
if (locked) return;
|
||||
setMailActionState("smtp");
|
||||
setLocalError("");
|
||||
try {
|
||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
|
||||
? await testMailProfileSmtp(settings, selectedProfileId)
|
||||
: await testSmtpSettings(settings, effectiveSmtpPayload()));
|
||||
} catch (err) {
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runImapTest() {
|
||||
if (imapDisabled) return;
|
||||
setMailActionState("imap");
|
||||
setLocalError("");
|
||||
try {
|
||||
setImapTestResult(selectedProfileId && imapCredentialsInherited
|
||||
? await testMailProfileImap(settings, selectedProfileId)
|
||||
: await testImapSettings(settings, effectiveImapPayload()));
|
||||
} catch (err) {
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runFolderLookup() {
|
||||
if (imapDisabled) return;
|
||||
setMailActionState("folders");
|
||||
setLocalError("");
|
||||
try {
|
||||
setFolderResult(selectedProfileId && imapCredentialsInherited
|
||||
? await listMailProfileImapFolders(settings, selectedProfileId)
|
||||
: await listImapFolders(settings, effectiveImapPayload()));
|
||||
} catch (err) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
function useDetectedSentFolder() {
|
||||
const folder = folderResult?.detected_sent_folder;
|
||||
if (!folder || imapDisabled) return;
|
||||
if (!usingMailProfile) {
|
||||
patch(["server", "imap", "sent_folder"], folder);
|
||||
}
|
||||
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
|
||||
patch(["delivery", "imap_append_sent", "folder"], folder);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMockMailSandbox(enabled: boolean) {
|
||||
if (locked || usingMailProfile) 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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Server settings</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
|
||||
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
|
||||
<>
|
||||
<MailProfilePolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
campaignId={campaignId}
|
||||
profiles={policyProfiles}
|
||||
ownerUserId={data.campaign?.owner_user_id}
|
||||
ownerGroupId={data.campaign?.owner_group_id}
|
||||
canWrite={!locked}
|
||||
locked={locked}
|
||||
title="Campaign mail profile policy"
|
||||
description="Campaign-level allow-list and wildcard limits applied after system, tenant and owner policies."
|
||||
onSaved={refreshMailProfiles}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="Reusable mail profile"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Profile">
|
||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||
<option value="">Inline SMTP/IMAP settings</option>
|
||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="New profile name">
|
||||
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} mail profile` : "Campaign mail profile"} />
|
||||
</FormField>
|
||||
</div>
|
||||
{selectedProfile && (
|
||||
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
|
||||
)}
|
||||
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>
|
||||
|
||||
<Card title="Mail server settings">
|
||||
<div className="mail-server-settings-grid">
|
||||
<section className="form-subsection mail-server-subsection">
|
||||
<div className="subsection-heading split">
|
||||
<h3>SMTP login</h3>
|
||||
<ToggleSwitch label="Mock server settings" checked={mockSandboxEnabled} disabled={locked || usingMailProfile} onChange={toggleMockMailSandbox} />
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="Host"><input value={getText(smtp, "host")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
|
||||
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
|
||||
<FormField label="Username"><input value={getText(smtp, "username")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
|
||||
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
|
||||
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
|
||||
</div>
|
||||
<div className="button-row compact-actions subsection-bottom-actions">
|
||||
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : (usingMailProfile ? "Test profile SMTP" : "Test SMTP login")}</Button>
|
||||
</div>
|
||||
<MailActionResult result={smtpTestResult} />
|
||||
</section>
|
||||
|
||||
<section className="form-subsection mail-server-subsection">
|
||||
<div className="subsection-heading split">
|
||||
<h3>IMAP sent-folder append</h3>
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<div className="form-span-full toggle-span-full">
|
||||
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked || usingMailProfile} onChange={toggleImap} />
|
||||
</div>
|
||||
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
|
||||
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
|
||||
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
|
||||
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
|
||||
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
|
||||
<div className="form-span-full toggle-span-full">
|
||||
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||
</div>
|
||||
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
|
||||
</div>
|
||||
<div className="button-row compact-actions subsection-bottom-actions">
|
||||
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : (usingMailProfile ? "Test profile IMAP" : "Test IMAP login")}</Button>
|
||||
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
|
||||
</div>
|
||||
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
|
||||
<MailActionResult result={imapTestResult} />
|
||||
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Mock mail sandbox">
|
||||
<div className="subsection-heading split">
|
||||
<div>
|
||||
<h3>Captured messages</h3>
|
||||
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
|
||||
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
|
||||
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
|
||||
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mockError && <DismissibleAlert tone="danger" resetKey={mockError} floating>{mockError}</DismissibleAlert>}
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-server-mock-mailbox`}
|
||||
rows={mockMessages}
|
||||
columns={mockMailboxColumns(openMockMessage)}
|
||||
getRowKey={(message) => message.id}
|
||||
emptyText="No mock messages captured yet."
|
||||
className="data-table compact-table"
|
||||
/>
|
||||
|
||||
</Card>
|
||||
|
||||
<div className="button-row page-bottom-actions">
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
{selectedMockMessage && (
|
||||
<MessagePreviewOverlay
|
||||
title="Captured mock mail"
|
||||
subject={selectedMockMessage.subject || "Mock message"}
|
||||
bodyMode="text"
|
||||
text={selectedMockMessage.body_preview || ""}
|
||||
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
|
||||
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
|
||||
metaItems={mockMessageMetaItems(selectedMockMessage)}
|
||||
attachments={mockMessageAttachments(selectedMockMessage)}
|
||||
raw={selectedMockMessage.raw_eml}
|
||||
rawLabel="Raw MIME"
|
||||
onClose={() => setSelectedMockMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function mockMessageMetaItems(message: MockMailboxMessage) {
|
||||
return [
|
||||
{ label: "From", value: message.from_header || message.envelope_from || "—" },
|
||||
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
|
||||
{ label: "Kind", value: message.kind || "—" },
|
||||
{ label: "Folder", value: message.folder || "—" },
|
||||
{ label: "Message-ID", value: message.message_id || "—" },
|
||||
{ label: "Size", value: `${message.size_bytes || 0} bytes` }
|
||||
];
|
||||
}
|
||||
|
||||
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
|
||||
return (message.attachments ?? []).map((attachment, index) => ({
|
||||
filename: attachment.filename || `Attachment ${index + 1}`,
|
||||
contentType: attachment.content_type || undefined,
|
||||
sizeBytes: attachment.size_bytes ?? undefined
|
||||
}));
|
||||
}
|
||||
|
||||
function formatMockDate(value: string): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
|
||||
function mockMailboxColumns(openMockMessage: (id: string) => Promise<void>): DataGridColumn<MockMailboxMessage>[] {
|
||||
return [
|
||||
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: [{ value: "SMTP", label: "SMTP" }, { value: "IMAP", label: "IMAP" }] }, render: (message) => <span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span>, value: (message) => message.kind === "imap_append" ? "IMAP" : "SMTP" },
|
||||
{ id: "received", header: "Received", width: 180, sortable: true, filterable: true, filterType: "date", value: (message) => formatMockDate(message.created_at), sortValue: (message) => message.created_at ?? "" },
|
||||
{ id: "subject", header: "Subject", width: "minmax(240px, 1fr)", sortable: true, filterable: true, value: (message) => message.subject || "—" },
|
||||
{ id: "envelope", header: "Envelope", width: 300, resizable: true, filterable: true, value: (message) => `${message.envelope_from || message.folder || "—"} → ${(message.envelope_recipients || []).join(", ") || message.folder || "—"}` },
|
||||
{ id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, filterType: "integer", value: (message) => message.attachment_count ?? 0 },
|
||||
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => <Button onClick={() => openMockMessage(message.id)}>Open</Button> }
|
||||
];
|
||||
}
|
||||
|
||||
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
|
||||
if (!result) return null;
|
||||
const authenticated = result.details?.authenticated;
|
||||
return (
|
||||
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating>
|
||||
{result.message}
|
||||
{result.ok && typeof authenticated === "boolean" && (
|
||||
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
|
||||
<p>{result.message}</p>
|
||||
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
|
||||
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
|
||||
{result.folders.length > 0 && (
|
||||
<div className="field-chip-list">
|
||||
{result.folders.slice(0, 12).map((folder) => (
|
||||
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
|
||||
))}
|
||||
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
|
||||
</div>
|
||||
)}
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user