150 lines
5.0 KiB
TypeScript
150 lines
5.0 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: "i18n:govoplan-mail.smtp_host.2d4a434b",
|
|
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
|
|
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
|
|
from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
|
|
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
|
|
};
|
|
|
|
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;
|
|
return wildcardMatch(normalizedPattern.toLowerCase(), value.toLowerCase());
|
|
}
|
|
|
|
function wildcardMatch(pattern: string, value: string): boolean {
|
|
let patternIndex = 0;
|
|
let valueIndex = 0;
|
|
let starIndex = -1;
|
|
let valueRetryIndex = 0;
|
|
|
|
while (valueIndex < value.length) {
|
|
const token = pattern[patternIndex];
|
|
if (token === "?" || token === value[valueIndex]) {
|
|
patternIndex += 1;
|
|
valueIndex += 1;
|
|
continue;
|
|
}
|
|
if (token === "*") {
|
|
starIndex = patternIndex;
|
|
valueRetryIndex = valueIndex;
|
|
patternIndex += 1;
|
|
continue;
|
|
}
|
|
if (starIndex !== -1) {
|
|
patternIndex = starIndex + 1;
|
|
valueRetryIndex += 1;
|
|
valueIndex = valueRetryIndex;
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
while (pattern[patternIndex] === "*") patternIndex += 1;
|
|
return patternIndex === pattern.length;
|
|
}
|
|
|
|
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(/^@+/, "");
|
|
}
|