Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 3cd8c45c42
commit 3d82d7b32d
40 changed files with 1877 additions and 520 deletions

View 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(/^@+/, "");
}