121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
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(/^@+/, "");
|
|
}
|