Merge pull request #6 from LINUXexpert-org/default-mail-handler
Offer ihasmail as the browser's mailto: handler
This commit is contained in:
@@ -55,6 +55,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
|
|||||||
|
|
||||||
**Platform**
|
**Platform**
|
||||||
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
||||||
|
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
|
||||||
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
@@ -4,18 +4,47 @@
|
|||||||
"description": "Fast, friendly JMAP webmail for Stalwart",
|
"description": "Fast, friendly JMAP webmail for Stalwart",
|
||||||
"start_url": "/mail",
|
"start_url": "/mail",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
"protocol_handlers": [
|
||||||
|
{
|
||||||
|
"protocol": "mailto",
|
||||||
|
"url": "/mail?mailto=%s"
|
||||||
|
}
|
||||||
|
],
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#0f766e",
|
"theme_color": "#0f766e",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/img/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
{
|
||||||
{ "src": "/img/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
"src": "/img/icon-192.png",
|
||||||
{ "src": "/img/icon-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icon-maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{ "name": "Compose", "url": "/mail?compose=new", "description": "Write a new message" },
|
{
|
||||||
{ "name": "Calendar", "url": "/calendar" },
|
"name": "Compose",
|
||||||
{ "name": "Contacts", "url": "/contacts" }
|
"url": "/mail?compose=new",
|
||||||
|
"description": "Write a new message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Calendar",
|
||||||
|
"url": "/calendar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Contacts",
|
||||||
|
"url": "/contacts"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatAddress, initials, isValidEmail, parseAddressList } from "../address";
|
import { formatAddress, initials, isValidEmail, parseAddressList, parseMailto } from "../address";
|
||||||
|
|
||||||
describe("address parsing", () => {
|
describe("address parsing", () => {
|
||||||
it("parses mixed lists", () => {
|
it("parses mixed lists", () => {
|
||||||
@@ -21,3 +21,37 @@ describe("address parsing", () => {
|
|||||||
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
|
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mailto URLs", () => {
|
||||||
|
it("takes recipients from the path, the to header, or both", () => {
|
||||||
|
expect(parseMailto("mailto:[email protected]")).toMatchObject({ to: [{ name: null, email: "[email protected]" }] });
|
||||||
|
expect(parseMailto("mailto:[email protected]").to).toEqual([{ name: null, email: "[email protected]" }]);
|
||||||
|
expect(parseMailto("mailto:[email protected][email protected]").to).toHaveLength(2);
|
||||||
|
expect(parseMailto("mailto:[email protected],[email protected]").to).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads cc, bcc, subject and body", () => {
|
||||||
|
const m = parseMailto("mailto:[email protected][email protected]&[email protected]&subject=Hello%20there&body=Line%20one");
|
||||||
|
expect(m.cc).toEqual([{ name: null, email: "[email protected]" }]);
|
||||||
|
expect(m.bcc).toEqual([{ name: null, email: "[email protected]" }]);
|
||||||
|
expect(m.subject).toBe("Hello there");
|
||||||
|
expect(m.body).toBe("Line one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive about headers and decodes plus as space", () => {
|
||||||
|
const m = parseMailto("MAILTO:[email protected]?SUBJECT=Re:+lunch&Body=see+you");
|
||||||
|
expect(m.subject).toBe("Re: lunch");
|
||||||
|
expect(m.body).toBe("see you");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps display names and survives malformed escapes", () => {
|
||||||
|
expect(parseMailto('mailto:%22Smith%2C%20John%22%20%[email protected]%3E').to).toEqual([{ name: "Smith, John", email: "[email protected]" }]);
|
||||||
|
expect(parseMailto("mailto:[email protected]?subject=100%").subject).toBe("100%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores headers it does not understand", () => {
|
||||||
|
const m = parseMailto("mailto:[email protected]?x-random=1&subject=Hi");
|
||||||
|
expect(m.subject).toBe("Hi");
|
||||||
|
expect(m.to).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { draftFromMailto } from "@/store/compose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mailto: URLs arrive from anywhere — a web page, a document, another app —
|
||||||
|
* so the body must reach the composer as text, never as markup.
|
||||||
|
*/
|
||||||
|
describe("draftFromMailto", () => {
|
||||||
|
it("fills recipients, subject and body", () => {
|
||||||
|
const d = draftFromMailto("mailto:[email protected][email protected]&[email protected]&subject=Q3%20plan&body=Hi%20Ann");
|
||||||
|
expect(d.to).toEqual([{ name: null, email: "[email protected]" }]);
|
||||||
|
expect(d.cc).toEqual([{ name: null, email: "[email protected]" }]);
|
||||||
|
expect(d.bcc).toEqual([{ name: null, email: "[email protected]" }]);
|
||||||
|
expect(d.showCc).toBe(true);
|
||||||
|
expect(d.showBcc).toBe(true);
|
||||||
|
expect(d.subject).toBe("Q3 plan");
|
||||||
|
expect(d.text).toBe("Hi Ann");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes markup in the body and keeps line breaks", () => {
|
||||||
|
const d = draftFromMailto("mailto:[email protected]?body=%3Cimg%20src%3Dx%20onerror%3Dboom%3E%20%26%20plain%0Asecond%20line");
|
||||||
|
expect(d.html).not.toContain("<img");
|
||||||
|
expect(d.html).toContain("<img");
|
||||||
|
expect(d.html).toContain("&");
|
||||||
|
expect(d.html).toContain("<br>");
|
||||||
|
expect(d.text).toContain("<img");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the body alone when the URL has none", () => {
|
||||||
|
const d = draftFromMailto("mailto:[email protected]");
|
||||||
|
expect(d.html).toBeUndefined();
|
||||||
|
expect(d.text).toBeUndefined();
|
||||||
|
expect(d.showCc).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,3 +114,45 @@ export function domainOf(email: string): string {
|
|||||||
const i = email.lastIndexOf("@");
|
const i = email.lastIndexOf("@");
|
||||||
return i >= 0 ? email.slice(i + 1).toLowerCase() : "";
|
return i >= 0 ? email.slice(i + 1).toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MailtoFields {
|
||||||
|
to: EmailAddress[];
|
||||||
|
cc: EmailAddress[];
|
||||||
|
bcc: EmailAddress[];
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `mailto:` URL (RFC 6068) into composer fields.
|
||||||
|
*
|
||||||
|
* Recipients may sit in the path, in `to=`, or both; headers other than
|
||||||
|
* to/cc/bcc/subject/body are ignored. Percent-encoding is undone leniently —
|
||||||
|
* a malformed escape yields the raw text rather than throwing.
|
||||||
|
*/
|
||||||
|
export function parseMailto(url: string): MailtoFields {
|
||||||
|
const withoutScheme = url.replace(/^mailto:/i, "");
|
||||||
|
const q = withoutScheme.indexOf("?");
|
||||||
|
const path = q === -1 ? withoutScheme : withoutScheme.slice(0, q);
|
||||||
|
const params = new URLSearchParams(q === -1 ? "" : withoutScheme.slice(q + 1));
|
||||||
|
const header = (name: string) => {
|
||||||
|
for (const [k, v] of params) if (k.toLowerCase() === name) return v;
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
const addresses = (raw: string) => (raw.trim() ? parseAddressList(decode(raw)) : []);
|
||||||
|
return {
|
||||||
|
to: [...addresses(path), ...addresses(header("to"))],
|
||||||
|
cc: addresses(header("cc")),
|
||||||
|
bcc: addresses(header("bcc")),
|
||||||
|
subject: decode(header("subject")),
|
||||||
|
body: decode(header("body")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(s: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(s.replace(/\+/g, " "));
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { loadRaw, saveJson } from "./storage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registering ihasmail as the browser's `mailto:` handler.
|
||||||
|
*
|
||||||
|
* `registerProtocolHandler` is the only web API for this. It needs a secure
|
||||||
|
* context, a same-origin URL containing `%s`, and a user gesture; the browser
|
||||||
|
* then asks the user. There is no way to read back whether a handler is
|
||||||
|
* registered, so we remember that we asked and keep the wording honest about
|
||||||
|
* it. Installed PWAs get a second route via the manifest's `protocol_handlers`,
|
||||||
|
* which is what lets the operating system itself offer ihasmail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = "mailtoHandler";
|
||||||
|
const SCHEME = "mailto";
|
||||||
|
|
||||||
|
export type HandlerSupport = "ok" | "insecure" | "unsupported";
|
||||||
|
|
||||||
|
export function handlerUrl(): string {
|
||||||
|
return `${window.location.origin}/mail?mailto=%s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailtoHandlerSupport(): HandlerSupport {
|
||||||
|
if (typeof navigator === "undefined" || typeof navigator.registerProtocolHandler !== "function") return "unsupported";
|
||||||
|
if (!window.isSecureContext) return "insecure";
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUnregisterMailtoHandler(): boolean {
|
||||||
|
return typeof navigator !== "undefined" && typeof (navigator as Navigator & { unregisterProtocolHandler?: unknown }).unregisterProtocolHandler === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether we have asked this browser — not whether the user accepted. */
|
||||||
|
export function mailtoHandlerRequested(): boolean {
|
||||||
|
return loadRaw<boolean>(KEY, false) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMailtoHandlerRequested(v: boolean): void {
|
||||||
|
saveJson(KEY, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Must be called from a user gesture. Throws if the browser refuses. */
|
||||||
|
export function registerMailtoHandler(): void {
|
||||||
|
navigator.registerProtocolHandler(SCHEME, handlerUrl());
|
||||||
|
setMailtoHandlerRequested(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterMailtoHandler(): void {
|
||||||
|
const nav = navigator as Navigator & { unregisterProtocolHandler?: (scheme: string, url: string) => void };
|
||||||
|
nav.unregisterProtocolHandler?.(SCHEME, handlerUrl());
|
||||||
|
setMailtoHandlerRequested(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the app is running as an installed PWA. */
|
||||||
|
export function isInstalledApp(): boolean {
|
||||||
|
try {
|
||||||
|
return window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
|||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||||
import { formatFullDate, uid } from "@/lib/format";
|
import { formatFullDate, uid } from "@/lib/format";
|
||||||
import { formatAddress, sameAddress, uniqueAddresses } from "@/lib/address";
|
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
|
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
|
||||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
|
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
@@ -676,3 +676,18 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { FULL_PROPS, BODY_PROPS };
|
export { FULL_PROPS, BODY_PROPS };
|
||||||
|
|
||||||
|
/** Composer fields for a `mailto:` URL, including cc/bcc and a quoted body. */
|
||||||
|
export function draftFromMailto(url: string): Partial<Draft> {
|
||||||
|
const m = parseMailto(url);
|
||||||
|
const body = m.body ? `<div>${escapeHtml(m.body).replace(/\n/g, "<br>")}</div>` : "";
|
||||||
|
return {
|
||||||
|
to: m.to,
|
||||||
|
cc: m.cc,
|
||||||
|
bcc: m.bcc,
|
||||||
|
showCc: m.cc.length > 0,
|
||||||
|
showBcc: m.bcc.length > 0,
|
||||||
|
subject: m.subject,
|
||||||
|
...(body ? { html: body, text: m.body } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIco
|
|||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { useCompose } from "@/store/compose";
|
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||||
import { SearchBar } from "./SearchBar";
|
import { SearchBar } from "./SearchBar";
|
||||||
@@ -42,9 +42,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
const mailto = params.get("mailto");
|
const mailto = params.get("mailto");
|
||||||
if (mailto) {
|
if (mailto) {
|
||||||
const [addr, qs] = mailto.replace(/^mailto:/, "").split("?");
|
openCompose(draftFromMailto(mailto));
|
||||||
const q = new URLSearchParams(qs ?? "");
|
|
||||||
openCompose({ to: addr ? addr.split(",").map((e) => ({ name: null, email: e.trim() })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
|
||||||
navigate("/mail", { replace: true });
|
navigate("/mail", { replace: true });
|
||||||
}
|
}
|
||||||
}, [openCompose, navigate]);
|
}, [openCompose, navigate]);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FilterFromMessageDialog } from "./FilterFromMessage";
|
|||||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { useCompose } from "@/store/compose";
|
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||||
import { useContacts } from "@/store/contacts";
|
import { useContacts } from "@/store/contacts";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||||
@@ -103,9 +103,8 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
|||||||
const mailto = urls.find((u) => u.startsWith("mailto:"));
|
const mailto = urls.find((u) => u.startsWith("mailto:"));
|
||||||
const http = urls.find((u) => /^https?:/i.test(u));
|
const http = urls.find((u) => /^https?:/i.test(u));
|
||||||
if (mailto) {
|
if (mailto) {
|
||||||
const [addr, qs] = mailto.slice(7).split("?");
|
const fields = draftFromMailto(mailto);
|
||||||
const q = new URLSearchParams(qs ?? "");
|
useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "<div>unsubscribe</div>", text: fields.text ?? "unsubscribe" });
|
||||||
useCompose.getState().open({ to: [{ name: null, email: addr ?? "" }], subject: q.get("subject") ?? "unsubscribe", html: `<div>${q.get("body") ?? "unsubscribe"}</div>`, text: q.get("body") ?? "unsubscribe" });
|
|
||||||
toast.show("Unsubscribe message prepared — just hit Send");
|
toast.show("Unsubscribe message prepared — just hit Send");
|
||||||
} else if (http) {
|
} else if (http) {
|
||||||
window.open(http, "_blank", "noopener,noreferrer");
|
window.open(http, "_blank", "noopener,noreferrer");
|
||||||
@@ -272,9 +271,7 @@ function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle:
|
|||||||
const href = a.getAttribute("href") ?? "";
|
const href = a.getAttribute("href") ?? "";
|
||||||
if (href.startsWith("mailto:")) {
|
if (href.startsWith("mailto:")) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const [addr, qs] = href.slice(7).split("?");
|
openCompose(draftFromMailto(href));
|
||||||
const q = new URLSearchParams(qs ?? "");
|
|
||||||
openCompose({ to: addr ? addr.split(",").map((x) => ({ name: null, email: decodeURIComponent(x.trim()) })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/^(javascript|data|vbscript):/i.test(href)) {
|
if (/^(javascript|data|vbscript):/i.test(href)) {
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
canUnregisterMailtoHandler,
|
||||||
|
isInstalledApp,
|
||||||
|
mailtoHandlerRequested,
|
||||||
|
mailtoHandlerSupport,
|
||||||
|
registerMailtoHandler,
|
||||||
|
unregisterMailtoHandler,
|
||||||
|
} from "@/lib/mailhandler";
|
||||||
import {
|
import {
|
||||||
browserLocale,
|
browserLocale,
|
||||||
formatClock,
|
formatClock,
|
||||||
@@ -154,6 +163,9 @@ export function GeneralSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
|
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
|
||||||
|
|
||||||
|
<h2>Default mail app</h2>
|
||||||
|
<MailHandlerSettings />
|
||||||
|
|
||||||
<h2>Backup</h2>
|
<h2>Backup</h2>
|
||||||
<div className="row wrap">
|
<div className="row wrap">
|
||||||
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
|
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>Export settings</button>
|
||||||
@@ -167,3 +179,54 @@ export function GeneralSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer ihasmail as the browser's handler for `mailto:` links. The browser
|
||||||
|
* owns the decision, and nothing can read the answer back, so this states what
|
||||||
|
* it can and points at the browser's own settings for the rest.
|
||||||
|
*/
|
||||||
|
function MailHandlerSettings() {
|
||||||
|
const support = mailtoHandlerSupport();
|
||||||
|
const [requested, setRequested] = useState(mailtoHandlerRequested);
|
||||||
|
|
||||||
|
const ask = () => {
|
||||||
|
try {
|
||||||
|
registerMailtoHandler();
|
||||||
|
setRequested(true);
|
||||||
|
toast.success("Your browser will ask whether to open mail links in ihasmail");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Your browser refused the request: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
unregisterMailtoHandler();
|
||||||
|
setRequested(false);
|
||||||
|
toast.show("Removed. Mail links will open in whatever your browser falls back to.");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (support === "unsupported") {
|
||||||
|
return <p className="hint">This browser cannot register apps for <code>mailto:</code> links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.</p>;
|
||||||
|
}
|
||||||
|
if (support === "insecure") {
|
||||||
|
return <p className="hint">Registering for <code>mailto:</code> links requires a secure (HTTPS) connection.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="hint">
|
||||||
|
Open <code>mailto:</code> links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client.
|
||||||
|
Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings › Privacy and security › Site settings › Protocol handlers; Firefox: Settings › General › Applications).
|
||||||
|
</p>
|
||||||
|
<div className="row wrap">
|
||||||
|
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
|
||||||
|
{requested && canUnregisterMailtoHandler() && <button className="btn btn-ghost" onClick={remove}>Remove</button>}
|
||||||
|
</div>
|
||||||
|
{requested && <p className="hint mt-8">Requested in this browser. Whether it took effect is up to the browser — check its settings if mail links still open elsewhere.</p>}
|
||||||
|
{!isInstalledApp() && (
|
||||||
|
<p className="hint mt-8">
|
||||||
|
For a system-wide default, install ihasmail as an app first (in Chrome: the install icon in the address bar). Your operating system can then offer ihasmail directly wherever it asks which mail app to use.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user