first wokring prototype
This commit is contained in:
101
src/utils/emailAddresses.ts
Normal file
101
src/utils/emailAddresses.ts
Normal 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
86
src/utils/fieldHelp.ts
Normal 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
64
src/utils/helpContext.ts
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user