Files
SRCmail/lib/validation.ts
T
dealerwebandLinus Rath 94af4725b6 Fix: send mailto unsubscribe ourselves instead of via the OS handler
The List-Unsubscribe action for mailto: links created a hidden anchor,
clicked it and reported success. That hands the mailto: URL to the OS
default mail handler - for a webmail user that opens the wrong program
or nothing at all, and the unsubscribe message is never sent, while the
banner still claims it was.

The confirm flow now parses the mailto: URL (address, subject, body -
percent-decoded manually since RFC 6068 does not use plus-encoding) and
sends the message through the account's own JMAP client, preferring the
identity that received the newsletter so the list can match the
subscriber. In unified views the send is routed to the email's owning
account. Success is only reported once the server accepted the message.

The mobile confirm dialog reused the success strings as its question
text; it gets proper confirm_message strings in all 22 locales, and
success_mailto now says what actually happened.
2026-07-08 15:41:46 +02:00

168 lines
4.8 KiB
TypeScript

/**
* RFC 5322 compliant email validation with security enhancements
*/
export function isValidEmail(email: string): boolean {
// Length check
if (!email || email.length > 254) return false;
// Security: Block control characters and header injection
if (/[\r\n\0<>]/.test(email)) return false;
// RFC 5322 compliant regex (simplified but secure)
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!emailRegex.test(email)) return false;
// Additional checks
const [localPart, domain] = email.split('@');
// Local part max 64 chars
if (localPart.length > 64) return false;
// Domain validation
if (domain.length > 255) return false;
if (domain.startsWith('.') || domain.endsWith('.')) return false;
if (domain.includes('..')) return false;
return true;
}
/**
* Validate comma-separated email list
* @returns Object with validation result and invalid emails
*/
export function validateEmailList(csv: string): {
valid: boolean;
invalidEmails: string[];
} {
if (!csv?.trim()) {
return { valid: true, invalidEmails: [] };
}
const emails = csv.split(',').map(e => e.trim()).filter(Boolean);
const invalid = emails.filter(e => !isValidEmail(e));
return {
valid: invalid.length === 0,
invalidEmails: invalid
};
}
/**
* Get user-friendly validation error message
*/
export function getEmailValidationError(email: string): string | null {
if (!email?.trim()) return 'Email address is required';
if (email.length > 254) return 'Email address is too long (max 254 characters)';
if (/[\r\n\0<>]/.test(email)) {
return 'Email address contains invalid characters';
}
if (!isValidEmail(email)) {
return 'Please enter a valid email address';
}
return null;
}
/**
* Validate unsubscribe URL (RFC 2369 List-Unsubscribe)
* Only allows safe protocols: http, https, mailto
* @param url - URL to validate
* @returns true if URL is safe to use
*/
export function isValidUnsubscribeUrl(url: string): boolean {
if (!url?.trim()) return false;
if (url.startsWith('mailto:')) {
const email = url.substring(7);
const emailPart = email.split('?')[0];
return isValidEmail(emailPart);
}
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
/**
* Parse List-Unsubscribe header and extract all valid URLs
* RFC 2369 allows multiple comma-separated URLs in <url> format
* @param header - Raw List-Unsubscribe header value
* @returns Object with http and mailto URLs, plus preferred method
*/
export function parseUnsubscribeUrls(header: string): {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
} {
if (!header?.trim()) return {};
const matches = header.match(/<([^>]+)>/g);
if (!matches) return {};
const urls = matches.map(m => m.slice(1, -1).trim());
const http = urls.find(u =>
(u.startsWith('http://') || u.startsWith('https://')) &&
isValidUnsubscribeUrl(u)
);
const mailto = urls.find(u =>
u.startsWith('mailto:') &&
isValidUnsubscribeUrl(u)
);
const preferred = http ? 'http' : (mailto ? 'mailto' : undefined);
return { http, mailto, preferred };
}
/**
* Parse a mailto: URL into its parts so the client can send the message
* itself. Query values are percent-decoded manually rather than via
* URLSearchParams because RFC 6068 uses %-encoding only - a literal "+"
* in a subject or address must stay a plus, not become a space.
* @param url - mailto: URL, e.g. "mailto:a@b.c?subject=Unsubscribe%20123"
* @returns Recipients plus optional subject/body, or null without a valid recipient
*/
export function parseMailtoUrl(url: string): { to: string[]; subject?: string; body?: string } | null {
if (!url?.startsWith('mailto:')) return null;
const rest = url.slice(7);
const queryIndex = rest.indexOf('?');
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
const decode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const to = addressPart
.split(',')
.map(a => decode(a).trim())
.filter(a => isValidEmail(a));
let subject: string | undefined;
let body: string | undefined;
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const key = pair.slice(0, eq).toLowerCase();
const value = decode(pair.slice(eq + 1));
if (key === 'subject') subject = value;
else if (key === 'body') body = value;
else if (key === 'to' && isValidEmail(value.trim())) to.push(value.trim());
}
return to.length > 0 ? { to, subject, body } : null;
}