first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

101
src/utils/emailAddresses.ts Normal file
View File

@@ -0,0 +1,101 @@
import { asArray, asRecord } from "../features/campaigns/utils/campaignView";
export type MailboxAddress = {
name?: string;
email: string;
};
export function normalizeEmailAddress(address: MailboxAddress): MailboxAddress {
return {
name: (address.name ?? "").trim(),
email: (address.email ?? "").trim().toLowerCase()
};
}
export function isValidEmailAddress(email: string): boolean {
const normalized = email.trim();
if (!normalized || normalized.length > 254) return false;
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(normalized);
}
export function addressFromRecord(value: unknown): MailboxAddress | null {
const record = asRecord(value);
const email = typeof record.email === "string" ? record.email.trim() : "";
if (!email) return null;
const name = typeof record.name === "string" ? record.name.trim() : "";
return normalizeEmailAddress({ name, email });
}
export function addressesFromValue(value: unknown): MailboxAddress[] {
if (Array.isArray(value)) {
return value.map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
}
const single = addressFromRecord(value);
return single ? [single] : [];
}
export function parseMailboxAddressText(input: string): MailboxAddress | null {
const text = input.trim().replace(/[;,]+$/g, "");
if (!text) return null;
const angleMatch = text.match(/^(.+?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/);
if (angleMatch) {
return normalizeEmailAddress({ name: cleanAddressName(angleMatch[1]), email: angleMatch[2] });
}
const emailMatch = text.match(/([^\s<>;,]+@[^\s<>;,]+\.[^\s<>;,]+)/);
if (!emailMatch) return null;
const email = emailMatch[1];
const name = cleanAddressName(`${text.slice(0, emailMatch.index)} ${text.slice((emailMatch.index ?? 0) + email.length)}`);
return normalizeEmailAddress({ name, email });
}
function cleanAddressName(value: string): string {
return value
.trim()
.replace(/[<>]/g, "")
.replace(/^[\s"'`]+|[\s"'`]+$/g, "")
.replace(/[;,]+$/g, "")
.trim();
}
export function collectCampaignAddressSuggestions(draft: Record<string, unknown> | null | undefined): MailboxAddress[] {
if (!draft) return [];
const suggestions: MailboxAddress[] = [];
const recipients = asRecord(draft.recipients);
const sender = addressFromRecord(recipients.from);
if (sender) suggestions.push(sender);
for (const key of ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]) {
suggestions.push(...addressesFromValue(recipients[key]));
}
const entries = asRecord(draft.entries);
for (const entryValue of asArray(entries.inline)) {
const entry = asRecord(entryValue);
const toAddresses = asArray(entry.to).map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
suggestions.push(...toAddresses);
const direct = addressFromRecord(entry.recipient) ?? addressFromRecord(entry);
if (direct) suggestions.push(direct);
}
return dedupeAddresses(suggestions);
}
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Map<string, MailboxAddress>();
addresses.map(normalizeEmailAddress).forEach((address) => {
if (!address.email) return;
const existing = seen.get(address.email);
if (!existing || (!existing.name && address.name)) {
seen.set(address.email, address);
}
});
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
}
export function addressDisplayName(address: MailboxAddress): string {
const normalized = normalizeEmailAddress(address);
return normalized.name || normalized.email;
}

86
src/utils/fieldHelp.ts Normal file
View File

@@ -0,0 +1,86 @@
const FIELD_HELP_BY_LABEL: Record<string, string> = {
"API base URL": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
"Automation API key": "Fallback API key for scripted or non-login access. Interactive browser sessions should use login tokens instead.",
"Email": "Email address used to identify the user account for login or address entry.",
"Password": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
"Name": "Human-readable display name shown in lists, reports or outgoing address headers.",
"Email address": "The mailbox address, for example office@example.org.",
"Campaign ID": "Stable technical identifier used in JSON, reports, filenames and backend references.",
"Campaign name": "Human-readable campaign name shown in lists, review screens and reports.",
"Mode": "Campaign operating mode. Use draft while editing, test for trial runs and send when preparing the real campaign.",
"Scenario": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
"Description": "Internal explanation of the campaign purpose. This is not sent to recipients.",
"Default From address": "Default sender address used for outgoing messages unless individual senders are allowed and set per recipient.",
"From name": "Display name shown with the sender address in outgoing messages.",
"From email": "Mailbox address used as the sender of outgoing messages.",
"Global Reply-To address": "Default reply destination for recipient responses. Leave empty to let replies go to the From address.",
"Reply-To": "Address recipients should reply to when it differs from the sender address.",
"To": "Global To recipients added to every message in addition to recipient-specific recipients.",
"Global recipients": "Default recipients included in every generated message.",
"CC": "Global carbon-copy recipients included in every generated message.",
"BCC": "Global blind-copy recipients included in every generated message.",
"Template source": "Where this campaign template comes from. Inline means it is stored directly in the campaign draft.",
"Library template": "Reusable template record this campaign should refer to once the template backend is available.",
"Subject": "Message subject. Placeholders can reference campaign/global/recipient fields.",
"Body": "Template body content shown for review. Placeholders are checked against campaign fields.",
"Plain text body": "Plain text version of the email body. It should remain readable without HTML rendering.",
"HTML body": "Optional HTML version of the email body for richer formatting.",
"Attachment base path": "Base folder used to resolve campaign attachment rules and relative file patterns.",
"Campaign attachment base path": "Base folder used during campaign creation to resolve attachment files and patterns.",
"Default missing behavior": "Default review behavior when an expected attachment cannot be found.",
"Default ambiguous behavior": "Default review behavior when a file pattern matches more than one attachment.",
"Base directory": "Folder below the campaign attachment base path where this rule starts looking for files.",
"File filter": "Filename or pattern used to select matching files. Field placeholders can be introduced later.",
"Include subdirectories": "Whether the rule should also search below nested folders.",
"Allow multiple matches": "Whether more than one matching file may be attached for this rule.",
"Missing behavior": "Action or review severity when this rule finds no matching file.",
"Ambiguous behavior": "Action or review severity when this rule finds multiple possible matches.",
"Host": "Mail server hostname or IP address for the selected protocol.",
"SMTP host": "SMTP server hostname or IP address used for sending messages.",
"Port": "Network port used by the selected mail protocol.",
"SMTP port": "Network port used by the SMTP server, commonly 587 for STARTTLS or 465 for TLS.",
"Username": "Login name for the selected mail server. Often the same as the mailbox address.",
"Security": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
"Timeout seconds": "Maximum time to wait for the mail server before the connection test or send attempt fails.",
"Detected/saved sent folder": "IMAP folder name used as the detected or manually configured Sent folder.",
"Append folder": "Folder where successfully sent messages should be appended via IMAP.",
"IMAP append to Sent": "Whether sent messages should also be copied into the configured Sent folder via IMAP.",
"Messages per minute": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
"Concurrency": "Number of send operations that may run in parallel. Keep low until account and provider limits are clear.",
"Max attempts": "Maximum retry attempts for a message before it remains failed for review.",
"Source type": "Recipient data source format. Inline data is edited in the campaign; external sources will be parsed later.",
"Source path": "Path or identifier for an external recipient source such as a CSV file.",
"Remember previously used addresses": "Planned opt-in collection for autocomplete across campaigns. Currently autocomplete is campaign-local only.",
"External address-book sync": "Placeholder for CardDAV, Google, LDAP or similar address sources.",
"Allow individual senders": "Permit recipient rows to override the campaign's default sender address.",
"Allow individual From": "Permit per-recipient sender overrides when building messages.",
"Allow individual Reply-To": "Permit recipient rows to override the global Reply-To address.",
"Allow individual To": "Permit recipient rows to define their own To recipients in addition to global headers.",
"Allow individual CC": "Permit recipient rows to define their own CC recipients.",
"Allow individual BCC": "Permit recipient rows to define their own BCC recipients.",
"Allow individual attachments": "Permit recipient-specific attachment rules in addition to global files.",
"Send without attachments": "Allow messages to be sent even when no attachment is resolved. Disable to force review or blocking.",
"Status tracking": "Store per-message build, validation, queue, send and IMAP status for review and reporting.",
"Suggest addresses from this campaign": "Use addresses already present in this campaign as autocomplete suggestions.",
"Remember newly used addresses": "Prepare newly entered addresses for a future user-level address memory.",
"Show guided warnings while editing": "Show inline guidance and warnings while campaign data is being edited.",
"Required": "Mark this item as mandatory. Missing required data should become a validation or review issue.",
"Subdirs": "Search nested folders below the configured base directory.",
"Can override": "Allow recipient-specific values to override the global value for this field.",
"Enable IMAP": "Enable IMAP settings so the server can test login, list folders and optionally append sent copies.",
"Append successfully sent messages to Sent": "After SMTP send succeeds, append a copy of the message to the selected IMAP Sent folder.",
"Append successful messages to Sent via IMAP": "After SMTP send succeeds, append a copy of the message to the configured IMAP Sent folder."
};
export function helpForFieldLabel(label: unknown): string | undefined {
if (typeof label !== "string") return undefined;
return FIELD_HELP_BY_LABEL[label];
}

64
src/utils/helpContext.ts Normal file
View File

@@ -0,0 +1,64 @@
export type HelpContext = {
id: string;
title: string;
route: string;
};
const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
data: { id: "campaign.settings", title: "Campaign settings" },
campaign: { id: "campaign.settings", title: "Campaign settings" },
fields: { id: "campaign.fields", title: "Campaign fields" },
template: { id: "campaign.template", title: "Template" },
files: { id: "campaign.attachments", title: "Attachments" },
attachments: { id: "campaign.attachments", title: "Attachments" },
recipients: { id: "campaign.recipients", title: "Recipients" },
"mail-settings": { id: "campaign.server-settings", title: "Server settings" },
"server-settings": { id: "campaign.server-settings", title: "Server settings" },
mail: { id: "campaign.server-settings", title: "Server settings" },
"global-settings": { id: "campaign.global-settings", title: "Global settings" },
settings: { id: "campaign.global-settings", title: "Global settings" },
review: { id: "campaign.review", title: "Review" },
send: { id: "campaign.send", title: "Send" },
report: { id: "campaign.report", title: "Report" },
reports: { id: "campaign.report", title: "Report" },
audit: { id: "campaign.audit", title: "Audit log" },
json: { id: "campaign.json", title: "JSON" }
};
const topLevelContexts: Record<string, Omit<HelpContext, "route">> = {
dashboard: { id: "app.dashboard", title: "Dashboard" },
campaigns: { id: "campaigns.list", title: "Campaigns" },
templates: { id: "templates.list", title: "Templates" },
files: { id: "files.list", title: "Files" },
reports: { id: "reports.list", title: "Reports" },
settings: { id: "app.settings", title: "Settings" },
admin: { id: "app.admin", title: "Admin" }
};
export function helpContextForPathname(pathname: string): HelpContext {
const route = pathname || "/";
const segments = route.split("/").filter(Boolean);
if (segments[0] === "campaigns" && segments[1]) {
if (!segments[2]) return { id: "campaign.overview", title: "Campaign overview", route };
if (segments[2] === "wizard") {
const step = segments[3] || "create";
return { id: `campaign.wizard.${step}`, title: `${capitalize(step)} wizard`, route };
}
const context = campaignSectionContexts[segments[2]];
if (context) return { ...context, route };
return { id: "campaign.workspace", title: "Campaign workspace", route };
}
const context = topLevelContexts[segments[0] || "campaigns"];
if (context) return { ...context, route };
return { id: "app.general", title: "Application", route };
}
export function helpQueryForContext(context: HelpContext): string {
return `context=${encodeURIComponent(context.id)}`;
}
function capitalize(value: string): string {
return value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}