diff --git a/multi-seal-mail-webui.tar.gz b/multi-seal-mail-webui.tar.gz new file mode 100644 index 0000000..d246448 Binary files /dev/null and b/multi-seal-mail-webui.tar.gz differ diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index bfb9656..3d35335 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -1,20 +1,18 @@ import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { createPortal } from "react-dom"; 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 ToggleSwitch from "../../components/ToggleSwitch"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, stringifyPreview, versionLockReason } from "./utils/campaignView"; +import { asArray, asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; +import { AttachmentRulesTable, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay"; import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; -const behaviorOptions = ["block", "ask", "drop", "continue", "warn"]; - -type AttachmentRule = Record; +type PathChooserState = { index: number }; export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); @@ -22,11 +20,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings const [dirty, setDirty] = useState(false); const [saveState, setSaveState] = useState("Loaded"); const [localError, setLocalError] = useState(""); - + const [pathChooser, setPathChooser] = useState(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); const attachments = asRecord(draft?.attachments); + const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); + const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); const entries = asRecord(draft?.entries); const inlineEntries = asArray(entries.inline).map(asRecord); const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) }))); @@ -46,31 +46,43 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings setLocalError(""); } - function patchGlobalAttachment(index: number, patchValue: Partial) { - const nextRules = globalRules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patchValue } : rule); - patch(["attachments", "global"], nextRules); + function patchBasePaths(paths: AttachmentBasePath[]) { + if (locked) return; + const normalized = paths.length > 0 ? paths : [createBasePath("Campaign files", ".")]; + setDraft((current) => { + const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized); + return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || "."); + }); + setDirty(true); + setLocalError(""); } - function addGlobalAttachment() { - const nextRules: AttachmentRule[] = [ + function patchBasePath(index: number, patch: Partial) { + patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); + } + + function addBasePath() { + patchBasePaths([...basePaths, createBasePath("New attachment source", ".")]); + } + + function removeBasePath(index: number) { + patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index)); + } + + function addGlobalAttachmentRule() { + if (locked) return; + const firstBasePath = basePaths[0]?.path ?? ""; + patch(["attachments", "global"], [ ...globalRules, { - id: `global-${Date.now()}`, + id: `attachment-${Date.now()}`, label: "", - base_dir: "", + base_dir: firstBasePath, file_filter: "", required: true, - include_subdirs: false, - allow_multiple: false, - missing_behavior: getText(attachments, "missing_behavior", "ask"), - ambiguous_behavior: getText(attachments, "ambiguous_behavior", "ask") + include_subdirs: false } - ]; - patch(["attachments", "global"], nextRules); - } - - function removeGlobalAttachment(index: number) { - patch(["attachments", "global"], globalRules.filter((_, currentIndex) => currentIndex !== index)); + ]); } async function saveDraft(mode: "auto" | "manual" = "manual"): Promise { @@ -126,123 +138,155 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings {draft && ( <> -
- -
-
Attachment base path
{String(attachments.base_path || ".")}
-
Global files
{globalRules.length}
-
Per-recipient patterns
{individualRules.length}
-
Upload support
Planned
-
-
- -

This section will become the Garage/S3-backed file picker for tenant, group and campaign attachment areas.

-
- Upload campaign files - Pick files for global attachment rules - Resolve missing or ambiguous individual matches -
-
-
- - Add file}> -
-
- patch(["attachments", "base_path"], event.target.value)} /> -
patch(["attachments", "allow_individual"], checked)} />
-
-
- - -
-
- - {globalRules.length === 0 ? ( -

No global files selected. Add files here only if every message should include them.

- ) : ( -
- - - - - - - - - - - - - {globalRules.map((rule, index) => ( - - - - - - - - - ))} - -
LabelBase dirSelected file / patternRequiredInclude subdirs
patchGlobalAttachment(index, { label: event.target.value })} /> patchGlobalAttachment(index, { base_dir: event.target.value })} /> patchGlobalAttachment(index, { file_filter: event.target.value })} /> patchGlobalAttachment(index, { required: checked })} /> patchGlobalAttachment(index, { include_subdirs: checked })} />
-
- )} -
- - }> -

Individual file patterns can be edited on each recipient row. They are summarized here because file matching and upload review also belong to the Attachments workflow.

-
- + Add base path} + > +
+
- - - - - + + + + - {individualRules.length === 0 && ( - - )} - {individualRules.map((rule, index) => )} + {basePaths.map((basePath, index) => ( + + + + + + + ))}
Recipient / entryLabelBase dirFilterOptionsNamePathIndividual attachments
No per-recipient file patterns are configured yet.
patchBasePath(index, { name: event.target.value })} /> +
+ + +
+
patchBasePath(index, { allow_individual: checked })} />
+ Add file} + > + patch(["attachments", "global"], rules)} + /> + + + +
+
Base paths
{basePaths.length}
+
Global attachments
direct: {globalSummary.direct} / rules: {globalSummary.rules}
+
Per-recipient patterns
{individualRules.length}
+
Upload support
Planned
+
+

The current storage browser is a mock. The next backend-alignment step can connect these base paths and file rules to campaign, group and tenant storage.

+
+
)} + + {pathChooser && ( + setPathChooser(null)} + onSelect={(path) => { + patchBasePath(pathChooser.index, path); + setPathChooser(null); + }} + /> + )} ); } -function RuleRow({ scope, rule }: { scope: string; rule: Record }) { - return ( - - {scope} - {String(rule.label || rule.id || "—")} - {String(rule.base_dir || "—")} - {String(rule.file_filter || "—")} - {stringifyPreview({ required: rule.required, allow_multiple: rule.allow_multiple, zip: rule.zip }, 120)} - +function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial) => void }) { + const paths: Array & { label: string }> = [ + { label: "Campaign attachments", path: "attachments" }, + { label: "Campaign root", path: "." }, + { label: "Shared group files", path: "group/shared" }, + { label: "Tenant templates", path: "tenant/templates" }, + { label: "Personal upload area", path: "user/uploads" } + ]; + + return createPortal( +
+
+
+

Choose attachment base path

+ +
+
+

Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.

+
+ {paths.map((path) => ( + + ))} +
+
+
+ +
+
+
, + document.body ); } +function createBasePath(name: string, path: string): AttachmentBasePath { + return { + id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`, + name, + path, + allow_individual: false + }; +} + +function normalizeBasePaths(value: unknown, attachments: Record): AttachmentBasePath[] { + if (Array.isArray(value) && value.length > 0) { + return value.filter(isRecord).map((basePath, index) => ({ + id: getText(basePath, "id", `base-path-${index + 1}`), + 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") + })); + } + + return [{ + id: "base-path-campaign", + name: "Campaign files", + path: getText(attachments, "base_path", "."), + allow_individual: getBool(attachments, "allow_individual") + }]; +} + function normalizeAttachmentRules(value: unknown): AttachmentRule[] { if (!Array.isArray(value)) return []; return value.filter(isRecord).map((rule) => ({ id: getText(rule, "id", `global-${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"), include_subdirs: getBool(rule, "include_subdirs"), required: getBool(rule, "required", true), - allow_multiple: getBool(rule, "allow_multiple"), missing_behavior: getText(rule, "missing_behavior", "ask"), ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"), ...(isRecord(rule.zip) ? { zip: rule.zip } : {}) diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx index 957b595..83df22a 100644 --- a/src/features/campaigns/CampaignFieldsPage.tsx +++ b/src/features/campaigns/CampaignFieldsPage.tsx @@ -10,6 +10,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import FieldValueInput from "./components/FieldValueInput"; const fieldTypeOptions = ["string", "integer", "double", "date", "password"]; @@ -127,7 +128,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: setLocalError(""); } - function setGlobalValue(key: string, value: string) { + function setGlobalValue(key: string, value: unknown) { patchGlobalValues({ ...globalValues, [key]: value }); } @@ -226,9 +227,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: setField(index, { required: checked })} /> - setGlobalValue(field.name, event.target.value)} /> + setGlobalValue(field.name, value)} /> setOverrideAllowed(index, checked)} /> - + ))} @@ -300,9 +301,3 @@ function humanizeFieldName(name: string): string { return name.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); } -function valueToText(value: unknown): string { - if (value === undefined || value === null) return ""; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") return String(value); - return JSON.stringify(value); -} diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index 98819e9..1700cbf 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -128,10 +128,9 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: - patch(["attachments", "allow_individual"], checked)} /> patch(["attachments", "send_without_attachments"], checked)} /> -

The actual global and per-recipient attachment rules live in Files. These settings define the campaign-wide defaults used by validation and review.

+

The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.

diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx index 471ffd8..a974760 100644 --- a/src/features/campaigns/RecipientDataPage.tsx +++ b/src/features/campaigns/RecipientDataPage.tsx @@ -1,18 +1,18 @@ import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; 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 StatusBadge from "../../components/StatusBadge"; import ToggleSwitch from "../../components/ToggleSwitch"; import EmailAddressInput from "../../components/email/EmailAddressInput"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, isAuditLockedVersion, stringifyPreview, versionLockReason } from "./utils/campaignView"; -import { ensureCampaignDraft, getBool, parseJsonTextarea, stringifyJson, updateNested } from "./utils/draftEditor"; +import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, versionLockReason } from "./utils/campaignView"; +import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; +import FieldValueInput from "./components/FieldValueInput"; +import AttachmentRulesOverlay, { type AttachmentRule } from "./components/AttachmentRulesOverlay"; import { addressesFromValue, collectCampaignAddressSuggestions, @@ -25,6 +25,13 @@ const recipientHeaderRows = [ { key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." } ]; +type FieldDefinition = { + name: string; + label: string; + type: string; + can_override: boolean; +}; + export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [draft, setDraft] = useState | null>(null); @@ -38,13 +45,15 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: const entries = asRecord(draft?.entries); const inlineEntries = asArray(entries.inline).map(asRecord); const source = asRecord(entries.source); - const fieldNames = useMemo(() => getDraftFieldNames(draft), [draft]); + const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]); const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]); const globalRecipientValues: Record = { to: addressesFromValue(recipientsSection.to), cc: addressesFromValue(recipientsSection.cc), bcc: addressesFromValue(recipientsSection.bcc) }; + const allowIndividualCc = getBool(recipientsSection, "allow_individual_cc"); + const allowIndividualBcc = getBool(recipientsSection, "allow_individual_bcc"); useEffect(() => { if (!version) return; @@ -53,7 +62,6 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded"); }, [version]); - function patch(path: string[], value: unknown) { if (locked) return; setDraft((current) => updateNested(current ?? {}, path, value)); @@ -65,17 +73,18 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: patch(["entries", "inline"], nextEntries); } - function appendRecipient(address: MailboxAddress) { - const nextEntry = { - id: `recipient-${inlineEntries.length + 1}`, + function addRecipient() { + const nextIndex = inlineEntries.length + 1; + const newEntry = { + id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`), active: true, - to: [address], - name: address.name ?? "", - email: address.email, + to: [], + name: "", + email: "", fields: {}, attachments: [] }; - replaceInlineEntries([...inlineEntries, nextEntry]); + replaceInlineEntries([...inlineEntries, newEntry]); } function updateEntry(index: number, updater: (entry: Record) => Record) { @@ -83,17 +92,22 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: replaceInlineEntries(nextEntries); } - function updateEntryRecipient(index: number, addresses: MailboxAddress[]) { - const address = addresses[0] ?? { name: "", email: "" }; - updateEntry(index, (entry) => ({ - ...entry, - to: address.email ? [address] : [], - name: address.name ?? "", - email: address.email - })); + function updateEntryAddressList(index: number, key: "to" | "cc" | "bcc", addresses: MailboxAddress[]) { + updateEntry(index, (entry) => { + const nextEntry = { ...entry, [key]: addresses }; + if (key === "to") { + const address = addresses[0] ?? { name: "", email: "" }; + return { + ...nextEntry, + name: address.name ?? "", + email: address.email + }; + } + return nextEntry; + }); } - function updateEntryField(index: number, field: string, value: string) { + function updateEntryField(index: number, field: string, value: unknown) { updateEntry(index, (entry) => ({ ...entry, fields: { @@ -103,13 +117,8 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: })); } - function updateEntryAttachments(index: number, text: string) { - const parsed = parseJsonTextarea(text, asArray(inlineEntries[index]?.attachments)); - if (parsed.error) { - setLocalError(`Invalid attachment JSON in row ${index + 1}: ${parsed.error}`); - return; - } - updateEntry(index, (entry) => ({ ...entry, attachments: parsed.value })); + function updateEntryAttachments(index: number, attachments: AttachmentRule[]) { + updateEntry(index, (entry) => ({ ...entry, attachments })); } function removeEntry(index: number) { @@ -195,80 +204,109 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: - Editable inline recipients with mail-style address chips, field values and individual attachment config.}> + Import, ]} + > {draft && inlineEntries.length === 0 && !source.type &&

No recipient data is stored in the current version yet.

} {draft && inlineEntries.length === 0 && Boolean(source.type) && (
This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.
)} - {draft && ( + {draft && inlineEntries.length > 0 && (
- - - {fieldNames.map((field) => )} - + + + {fieldDefinitions.map((field) => )} - - - - - {inlineEntries.slice(0, 100).map((entry, index) => { - const recipient = primaryRecipient(entry); + const to = addressesFromValue(entry.to); + const cc = addressesFromValue(entry.cc); + const bcc = addressesFromValue(entry.bcc); const fields = asRecord(entry.fields); - const attachments = asArray(entry.attachments); + const attachments = normalizeAttachmentRules(entry.attachments); return ( + - - {fieldNames.map((field) => ( - ))} -
#RecipientStatus{field}Individual attachmentsRecipientsAttachments{field.label || field.name}
+ - -
{index + 1} +
+
+ To + updateEntryAddressList(index, "to", addresses)} + /> +
+ {allowIndividualCc && ( +
+ CC + updateEntryAddressList(index, "cc", addresses)} + /> +
+ )} + {allowIndividualBcc && ( +
+ BCC + updateEntryAddressList(index, "bcc", addresses)} + /> +
+ )} + updateEntry(index, (current) => ({ ...current, active: checked }))} + /> +
+
- updateEntryRecipient(index, addresses)} + onChange={(rules) => updateEntryAttachments(index, rules)} /> - ( + + updateEntryField(index, field, event.target.value)} + fieldType={field.type} + value={fields[field.name]} + disabled={locked || field.can_override === false} + placeholder={field.can_override === false ? "Uses global value" : undefined} + onChange={(value) => updateEntryField(index, field.name, value)} /> -