Release v0.1.2
This commit is contained in:
120
webui/src/features/mail/mailPolicyValidation.ts
Normal file
120
webui/src/features/mail/mailPolicyValidation.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationInput = {
|
||||
smtpHost?: string | null;
|
||||
imapHost?: string | null;
|
||||
envelopeSender?: string | null;
|
||||
fromHeader?: string | null;
|
||||
recipientDomains?: Array<string | null | undefined> | null;
|
||||
};
|
||||
|
||||
export type MailPolicyValidationMessage = {
|
||||
key: MailProfilePatternKey;
|
||||
label: string;
|
||||
value: string;
|
||||
message: string;
|
||||
severity?: "warning" | "danger";
|
||||
};
|
||||
|
||||
const patternLabels: Record<MailProfilePatternKey, string> = {
|
||||
smtp_hosts: "SMTP host",
|
||||
imap_hosts: "IMAP host",
|
||||
envelope_senders: "Envelope sender",
|
||||
from_headers: "From header",
|
||||
recipient_domains: "Recipient domain"
|
||||
};
|
||||
|
||||
type ValueCheck = {
|
||||
key: MailProfilePatternKey;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function validateMailPolicy(
|
||||
policy: MailProfilePolicy | null | undefined,
|
||||
input: MailPolicyValidationInput,
|
||||
): MailPolicyValidationMessage[] {
|
||||
if (!policy) return [];
|
||||
|
||||
const checks: ValueCheck[] = [
|
||||
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
|
||||
{ key: "imap_hosts", value: input.imapHost ?? "" },
|
||||
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
|
||||
{ key: "from_headers", value: input.fromHeader ?? "" },
|
||||
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean)))
|
||||
.map((value) => ({ key: "recipient_domains" as const, value }))
|
||||
];
|
||||
|
||||
return checks.flatMap(({ key, value }) => {
|
||||
const result = mailPolicyValueAllowed(policy, key, value);
|
||||
if (result.allowed) return [];
|
||||
const label = patternLabels[key];
|
||||
return [{
|
||||
key,
|
||||
label,
|
||||
value: result.value,
|
||||
severity: "danger" as const,
|
||||
message: result.reason === "blacklist"
|
||||
? `${label} ${result.value} is denied by pattern ${result.pattern}.`
|
||||
: `${label} ${result.value} is outside the effective allow-list.`
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function mailPolicyValueAllowed(
|
||||
policy: MailProfilePolicy | null | undefined,
|
||||
key: MailProfilePatternKey,
|
||||
rawValue: string | null | undefined,
|
||||
): { allowed: true; value: string } | { allowed: false; value: string; reason: "blacklist" | "whitelist"; pattern?: string } {
|
||||
const value = normalizePolicyValue(key, rawValue);
|
||||
if (!value || !policy) return { allowed: true, value };
|
||||
|
||||
const deniedBy = patternsFor(policy.blacklist, key).find((pattern) => wildcardPatternMatches(pattern, value));
|
||||
if (deniedBy) return { allowed: false, value, reason: "blacklist", pattern: deniedBy };
|
||||
|
||||
const allowPatterns = meaningfulAllowPatterns(patternsFor(policy.whitelist, key));
|
||||
if (allowPatterns.length > 0 && !allowPatterns.some((pattern) => wildcardPatternMatches(pattern, value))) {
|
||||
return { allowed: false, value, reason: "whitelist" };
|
||||
}
|
||||
|
||||
return { allowed: true, value };
|
||||
}
|
||||
|
||||
export function wildcardPatternMatches(pattern: string, rawValue: string): boolean {
|
||||
const normalizedPattern = pattern.trim();
|
||||
const value = rawValue.trim();
|
||||
if (!normalizedPattern || !value) return false;
|
||||
if (normalizedPattern === "*") return true;
|
||||
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`;
|
||||
return new RegExp(regex, "i").test(value);
|
||||
}
|
||||
|
||||
function patternsFor(
|
||||
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
|
||||
key: MailProfilePatternKey,
|
||||
): string[] {
|
||||
return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function meaningfulAllowPatterns(patterns: string[]): string[] {
|
||||
return patterns.filter((pattern) => pattern !== "*");
|
||||
}
|
||||
|
||||
function normalizePolicyValue(key: MailProfilePatternKey, value: string | null | undefined): string {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
if (key === "recipient_domains") return normalizeDomain(trimmed);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeDomain(value: string | null | undefined): string {
|
||||
const trimmed = String(value ?? "").trim().toLowerCase();
|
||||
if (!trimmed) return "";
|
||||
const emailDomain = trimmed.includes("@") ? trimmed.split("@").pop() ?? "" : trimmed;
|
||||
return emailDomain.replace(/^@+/, "");
|
||||
}
|
||||
Reference in New Issue
Block a user