Feature/protocol handlers
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links * Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab * Added webcal: protocol handling for calendar links * Added account selection for webcal: links when multiple calendar-capable accounts are connected * Added an import-or-subscribe choice for detected webcal calendars * Added protocol handler settings for registering mail and calendar handlers and choosing the open mode * Added service worker/session coordination for passing protocol requests between browser/PWA contexts * Added tests and translations for the new protocol handler flows
This commit is contained in:
committed by
Linus Rath
parent
8b0e2052cf
commit
3f444a8912
@@ -0,0 +1,115 @@
|
||||
export interface ParsedMailto {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
const MAX_SUBJECT_LENGTH = 998;
|
||||
const MAX_BODY_LENGTH = 64 * 1024;
|
||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
||||
const CONTROL_CHARS_EXCEPT_LINE_BREAKS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
||||
|
||||
function stripControlChars(value: string): string {
|
||||
return value.replace(CONTROL_CHARS, "");
|
||||
}
|
||||
|
||||
function stripBodyControlChars(value: string): string {
|
||||
return value
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
return stripControlChars(value)
|
||||
.split(",")
|
||||
.map((recipient) => recipient.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
type QueryParam = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function getQueryValue(searchParams: QueryParam[], key: string): string {
|
||||
const values: string[] = [];
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
for (const { key: paramKey, value } of searchParams) {
|
||||
if (paramKey.toLowerCase() === lowerKey) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(",");
|
||||
}
|
||||
|
||||
function decodePathname(pathname: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(pathname || "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeQueryPart(value: string): string | null {
|
||||
try {
|
||||
// RFC 6068 uses percent-encoding for mailto query fields; unlike form
|
||||
// encoding, a literal '+' is part of the value and must not become space.
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseQuery(query: string): QueryParam[] | null {
|
||||
if (!query) return [];
|
||||
|
||||
const params: QueryParam[] = [];
|
||||
for (const part of query.split("&")) {
|
||||
if (!part) continue;
|
||||
const separatorIndex = part.indexOf("=");
|
||||
const rawKey = separatorIndex >= 0 ? part.slice(0, separatorIndex) : part;
|
||||
const rawValue = separatorIndex >= 0 ? part.slice(separatorIndex + 1) : "";
|
||||
const key = decodeQueryPart(rawKey);
|
||||
const value = decodeQueryPart(rawValue);
|
||||
if (key === null || value === null) return null;
|
||||
params.push({ key, value });
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function parseMailto(raw: string): ParsedMailto | null {
|
||||
if (!raw.toLowerCase().startsWith("mailto:")) return null;
|
||||
|
||||
const addressAndQuery = raw.slice("mailto:".length);
|
||||
const queryIndex = addressAndQuery.indexOf("?");
|
||||
const rawPathname = queryIndex >= 0 ? addressAndQuery.slice(0, queryIndex) : addressAndQuery;
|
||||
const rawQuery = queryIndex >= 0 ? addressAndQuery.slice(queryIndex + 1) : "";
|
||||
|
||||
const decodedPathname = decodePathname(rawPathname);
|
||||
if (decodedPathname === null) return null;
|
||||
const searchParams = parseQuery(rawQuery);
|
||||
if (searchParams === null) return null;
|
||||
|
||||
const to = [
|
||||
...splitRecipients(decodedPathname),
|
||||
...splitRecipients(getQueryValue(searchParams, "to")),
|
||||
].slice(0, MAX_RECIPIENTS);
|
||||
const remainingAfterTo = Math.max(0, MAX_RECIPIENTS - to.length);
|
||||
const cc = splitRecipients(getQueryValue(searchParams, "cc")).slice(0, remainingAfterTo);
|
||||
const remainingAfterCc = Math.max(0, MAX_RECIPIENTS - to.length - cc.length);
|
||||
const bcc = splitRecipients(getQueryValue(searchParams, "bcc")).slice(0, remainingAfterCc);
|
||||
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user