diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..61bf205
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,18 @@
+## Project Guidelines
+
+- After completing changes, build and install the updated app for manual testing.
+- Do not call the task done until it is fully complete and tested.
+- Do not dismiss bug as a pre-existing" issue even if it was present before your change. It does not matter, it's still your responsibility to fix it. When you see a bug, fix it. Don't ignore it.
+
+## Coding Guidelines
+
+ - Keep code simple and easy to read.
+ - Avoid excessive comments. Only comment when absolutely necessary. Code should be readable and not require comments to understand it.
+
+## Git Commit Rules
+
+ - Do not make branches, commit in main only
+ - Commit message is one plain lowercase line. No type prefix, no scope, no body.
+ - Never use `git add .` or `git add -A`. Always stage specific files by name.
+ - Don't batch multiple unrelated changes into one commit.
+
diff --git a/justfile b/justfile
index 5c31a07..080a10c 100644
--- a/justfile
+++ b/justfile
@@ -53,7 +53,14 @@ build:
fi
fi
case "$(uname -s)" in
- Darwin) pnpm tauri build --bundles app ;;
+ Darwin)
+ autofill_config=$(node scripts/prepare-autofill.mjs)
+ if [ -n "$autofill_config" ]; then
+ pnpm tauri build --bundles app --config "$autofill_config"
+ else
+ pnpm tauri build --bundles app
+ fi
+ ;;
Linux) pnpm tauri build --bundles deb,appimage ;;
*) echo "just: no local build for $(uname -s); macOS and Linux are the desktop targets." >&2; exit 1 ;;
esac
diff --git a/native/autofill/CredentialProvider.swift b/native/autofill/CredentialProvider.swift
new file mode 100644
index 0000000..84a4f44
--- /dev/null
+++ b/native/autofill/CredentialProvider.swift
@@ -0,0 +1,158 @@
+import AppKit
+import AuthenticationServices
+
+private struct EmailCode: Decodable {
+ let id: String
+ let code: String
+ let domain: String
+ let label: String
+ let expiresAtMs: Double
+
+ static func available() -> [EmailCode] {
+ guard let container = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "TQV87WLXK3.studio.margin.mail"
+ ),
+ let data = try? Data(contentsOf: container.appendingPathComponent("email-otp.json")),
+ let codes = try? JSONDecoder().decode([EmailCode].self, from: data) else {
+ return []
+ }
+ let now = Date().timeIntervalSince1970 * 1000
+ return codes.filter {
+ !$0.id.isEmpty && !$0.code.isEmpty && $0.code.count <= 32
+ && !$0.domain.isEmpty && $0.expiresAtMs > now
+ && $0.expiresAtMs <= now + 180_000
+ }.sorted { $0.expiresAtMs > $1.expiresAtMs }
+ }
+
+ func matches(_ service: ASCredentialServiceIdentifier) -> Bool {
+ let host: String
+ if service.type == .URL {
+ guard let urlHost = URL(string: service.identifier)?.host else { return false }
+ host = urlHost.lowercased()
+ } else if service.type == .domain {
+ host = service.identifier.lowercased()
+ } else {
+ return false
+ }
+ let senderDomain = domain.lowercased()
+ return host == senderDomain || host.hasSuffix("." + senderDomain)
+ }
+}
+
+@available(macOS 15.0, *)
+final class CredentialProviderViewController: ASCredentialProviderViewController {
+ override func loadView() {
+ view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 340))
+ }
+
+ override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
+ provide(credentialRequest)
+ }
+
+ override func prepareInterfaceToProvideCredential(for credentialRequest: any ASCredentialRequest) {
+ provide(credentialRequest)
+ }
+
+ override func prepareOneTimeCodeCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
+ let codes = EmailCode.available()
+ let matching = codes.filter { code in serviceIdentifiers.contains { code.matches($0) } }
+ let others = codes.filter { code in !serviceIdentifiers.contains { code.matches($0) } }
+ let stack = NSStackView()
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 12
+ stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
+ stack.translatesAutoresizingMaskIntoConstraints = false
+
+ let title = NSTextField(labelWithString: "Email verification codes")
+ title.font = .boldSystemFont(ofSize: 17)
+ stack.addArrangedSubview(title)
+
+ if codes.isEmpty {
+ let empty = NSTextField(wrappingLabelWithString:
+ "No recent codes are available. Enable Email OTP AutoFill in Margin Mail and keep the app unlocked."
+ )
+ stack.addArrangedSubview(empty)
+ } else {
+ add(matching, to: stack)
+ if !others.isEmpty {
+ if !serviceIdentifiers.isEmpty {
+ let label = NSTextField(wrappingLabelWithString:
+ "Other email codes. Select only the code for the website you are using."
+ )
+ label.textColor = .secondaryLabelColor
+ stack.addArrangedSubview(label)
+ }
+ add(others, to: stack)
+ }
+ }
+
+ let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelSelection))
+ cancel.bezelStyle = .rounded
+ stack.addArrangedSubview(cancel)
+
+ let scroll = NSScrollView()
+ scroll.hasVerticalScroller = true
+ scroll.drawsBackground = false
+ scroll.translatesAutoresizingMaskIntoConstraints = false
+ scroll.documentView = stack
+ view.subviews.forEach { $0.removeFromSuperview() }
+ view.addSubview(scroll)
+ NSLayoutConstraint.activate([
+ scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ scroll.topAnchor.constraint(equalTo: view.topAnchor),
+ scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
+ stack.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
+ stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
+ ])
+ }
+
+ private func add(_ codes: [EmailCode], to stack: NSStackView) {
+ for code in codes {
+ let button = NSButton(
+ title: "\(code.code) · \(code.label)",
+ target: self,
+ action: #selector(selectCode(_:))
+ )
+ button.identifier = NSUserInterfaceItemIdentifier(code.id)
+ button.bezelStyle = .rounded
+ button.toolTip = code.domain
+ stack.addArrangedSubview(button)
+ let domain = NSTextField(labelWithString: code.domain)
+ domain.font = .systemFont(ofSize: 11)
+ domain.textColor = .secondaryLabelColor
+ stack.addArrangedSubview(domain)
+ }
+ }
+
+ private func provide(_ request: any ASCredentialRequest) {
+ guard request.type == .oneTimeCode,
+ let identity = request.credentialIdentity as? ASOneTimeCodeCredentialIdentity,
+ let id = identity.recordIdentifier,
+ let code = EmailCode.available().first(where: { $0.id == id }),
+ code.matches(identity.serviceIdentifier) else {
+ cancel(with: .credentialIdentityNotFound)
+ return
+ }
+ extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code.code))
+ }
+
+ @objc private func selectCode(_ sender: NSButton) {
+ guard let id = sender.identifier?.rawValue,
+ let code = EmailCode.available().first(where: { $0.id == id }) else {
+ cancel(with: .credentialIdentityNotFound)
+ return
+ }
+ extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code.code))
+ }
+
+ @objc private func cancelSelection() {
+ cancel(with: .userCanceled)
+ }
+
+ private func cancel(with code: ASExtensionError.Code) {
+ extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: code.rawValue))
+ }
+}
diff --git a/native/autofill/Info.plist b/native/autofill/Info.plist
new file mode 100644
index 0000000..8a98e6f
--- /dev/null
+++ b/native/autofill/Info.plist
@@ -0,0 +1,45 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Margin Mail
+ CFBundleExecutable
+ MarginMailAutoFill
+ CFBundleIdentifier
+ studio.margin.mail.autofill
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Margin Mail AutoFill
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 15.0
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.authentication-services-credential-provider-ui
+ NSExtensionPrincipalClass
+ MarginMailAutoFill.CredentialProviderViewController
+ NSExtensionAttributes
+
+ ASCredentialProviderExtensionCapabilities
+
+ ProvidesOneTimeCodes
+
+ ProvidesPasswords
+
+ ProvidesPasskeys
+
+
+
+
+
+
diff --git a/native/autofill/autofill.entitlements b/native/autofill/autofill.entitlements
new file mode 100644
index 0000000..c28e7a4
--- /dev/null
+++ b/native/autofill/autofill.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.developer.authentication-services.autofill-credential-provider
+
+ com.apple.security.application-groups
+
+ TQV87WLXK3.studio.margin.mail
+
+
+
diff --git a/native/autofill/host.entitlements b/native/autofill/host.entitlements
new file mode 100644
index 0000000..d7df789
--- /dev/null
+++ b/native/autofill/host.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.developer.authentication-services.autofill-credential-provider
+
+ com.apple.security.application-groups
+
+ TQV87WLXK3.studio.margin.mail
+
+
+
diff --git a/package.json b/package.json
index 6b38699..6c1dda9 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
+ "@tiptap/core": "^3.31.2",
+ "@tiptap/pm": "^3.31.2",
"@tiptap/react": "^3.31.2",
"@tiptap/starter-kit": "^3.31.2",
"margin-shared": "file:../../python/margin/shared",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 044a156..757d16e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,12 @@ importers:
'@tauri-apps/plugin-updater':
specifier: ^2
version: 2.11.0
+ '@tiptap/core':
+ specifier: ^3.31.2
+ version: 3.31.2(@tiptap/pm@3.31.2)
+ '@tiptap/pm':
+ specifier: ^3.31.2
+ version: 3.31.2
'@tiptap/react':
specifier: ^3.31.2
version: 3.31.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))(@tiptap/pm@3.31.2)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
diff --git a/scripts/prepare-autofill.mjs b/scripts/prepare-autofill.mjs
new file mode 100644
index 0000000..fff91a0
--- /dev/null
+++ b/scripts/prepare-autofill.mjs
@@ -0,0 +1,70 @@
+import { execFileSync } from "node:child_process";
+import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join, resolve } from "node:path";
+
+const signing = process.env.MARGIN_SIGNING_DIR || join(homedir(), ".margin-signing");
+const appProfile = join(signing, "studio.margin.mail.provisionprofile");
+const extensionProfile = join(signing, "studio.margin.mail.autofill.provisionprofile");
+const present = await Promise.all([appProfile, extensionProfile].map(async (path) => {
+ try { await access(path); return true; } catch (error) {
+ if (error.code === "ENOENT") return false;
+ throw error;
+ }
+}));
+if (!present.every(Boolean)) {
+ console.error("Email OTP AutoFill is unavailable: both Margin Mail provisioning profiles are required.");
+ process.exit(0);
+}
+const identity = process.env.APPLE_SIGNING_IDENTITY;
+if (!identity || identity === "-") throw new Error("Email OTP AutoFill requires a Developer ID signing identity.");
+const plist = (path) => JSON.parse(execFileSync("plutil", ["-convert", "json", "-o", "-", path], { encoding: "utf8" }));
+const profile = (path, identifier) => {
+ const xml = execFileSync("security", ["cms", "-D", "-i", path]);
+ const entitlements = JSON.parse(execFileSync("plutil", ["-extract", "Entitlements", "json", "-o", "-", "-"], { input: xml, encoding: "utf8" }));
+ const expiry = execFileSync("plutil", ["-extract", "ExpirationDate", "raw", "-o", "-", "-"], { input: xml, encoding: "utf8" }).trim();
+ if (entitlements["com.apple.application-identifier"] !== `TQV87WLXK3.${identifier}` ||
+ entitlements["com.apple.developer.team-identifier"] !== "TQV87WLXK3" ||
+ entitlements["com.apple.developer.authentication-services.autofill-credential-provider"] !== true ||
+ !entitlements["com.apple.security.application-groups"]?.some((group) =>
+ group === "TQV87WLXK3.*" || group === "TQV87WLXK3.studio.margin.mail") ||
+ !(new Date(expiry).getTime() > Date.now())) {
+ throw new Error(`Invalid or expired AutoFill profile for ${identifier}.`);
+ }
+ return entitlements;
+};
+const appEntitlements = profile(appProfile, "studio.margin.mail");
+const extensionEntitlements = profile(extensionProfile, "studio.margin.mail.autofill");
+const metadata = JSON.parse(execFileSync("cargo", ["metadata", "--no-deps", "--format-version", "1", "--manifest-path", "src-tauri/Cargo.toml"], { encoding: "utf8" }));
+const output = join(metadata.target_directory, ".tauri", "autofill");
+const contents = join(output, "MarginMailAutoFill.appex", "Contents");
+await mkdir(join(contents, "MacOS"), { recursive: true });
+const config = JSON.parse(await readFile("src-tauri/tauri.conf.json", "utf8"));
+const info = plist("native/autofill/Info.plist");
+info.CFBundleShortVersionString = config.version;
+info.CFBundleVersion = config.bundle.macOS.bundleVersion || config.version;
+const writePlist = async (path, value) => {
+ await writeFile(path, JSON.stringify(value));
+ execFileSync("plutil", ["-convert", "xml1", path]);
+};
+await writePlist(join(contents, "Info.plist"), info);
+await copyFile(extensionProfile, join(contents, "embedded.provisionprofile"));
+for (const [name, authorized] of [["host", appEntitlements], ["extension", extensionEntitlements]]) {
+ const value = plist(name === "host" ? "native/autofill/host.entitlements" : "native/autofill/autofill.entitlements");
+ value["com.apple.application-identifier"] = authorized["com.apple.application-identifier"];
+ value["com.apple.developer.team-identifier"] = authorized["com.apple.developer.team-identifier"];
+ await writePlist(join(output, `${name}.entitlements`), value);
+}
+const arch = process.arch === "arm64" ? "arm64" : "x86_64";
+execFileSync("xcrun", ["swiftc", "-O", "-parse-as-library", "-application-extension", "-module-name", "MarginMailAutoFill",
+ "-target", `${arch}-apple-macosx15.0`, "-Xlinker", "-e", "-Xlinker", "_NSExtensionMain",
+ "native/autofill/CredentialProvider.swift", "-o", join(contents, "MacOS", "MarginMailAutoFill")], { stdio: ["ignore", "ignore", "inherit"] });
+const extension = join(output, "MarginMailAutoFill.appex");
+execFileSync("codesign", ["--force", "--sign", identity, "--options", "runtime", "--timestamp", "--entitlements", join(output, "extension.entitlements"), extension], { stdio: ["ignore", "ignore", "inherit"] });
+execFileSync("codesign", ["--verify", "--strict", extension], { stdio: ["ignore", "ignore", "inherit"] });
+const overlay = join(output, "tauri.conf.json");
+await writeFile(overlay, JSON.stringify({ bundle: { macOS: {
+ entitlements: join(output, "host.entitlements"),
+ files: { "PlugIns/MarginMailAutoFill.appex": extension, "embedded.provisionprofile": resolve(appProfile) },
+} } }));
+console.log(overlay);
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index c8a34c3..ff0e59c 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2946,10 +2946,13 @@ dependencies = [
"mail-parser",
"mail-send",
"objc2",
+ "objc2-app-kit",
"objc2-foundation",
+ "objc2-local-authentication",
"objc2-ui-kit",
"objc2-user-notifications",
"rand 0.8.8",
+ "regex",
"reqwest 0.13.1",
"roxmltree 0.21.1",
"rusqlite",
@@ -3381,6 +3384,17 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-local-authentication"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e48e0b8b339e0d9d2ed4416b7f93f9d4daadff7d4dd797f89867cde11aeac607"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 81bb3bf..84bb629 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -29,6 +29,7 @@ base64 = "0.22"
sha2 = "0.10"
rand = "0.8"
url = "2"
+regex = "1"
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["sync", "time", "net", "io-util", "rt"] }
@@ -109,14 +110,26 @@ roxmltree = "0.21"
# build keeps a single copy of objc2.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSSpellChecker", "NSApplication", "NSMenu", "NSMenuItem"] }
objc2-foundation = { version = "0.3", default-features = false, features = [
"std",
+ "NSArray",
+ "NSRange",
+ "NSFileManager",
+ "NSURL",
+ "NSTextCheckingResult",
+ "NSValue",
"NSDictionary",
"NSError",
"NSObject",
"NSString",
] }
+objc2-local-authentication = { version = "0.3", default-features = false, features = [
+ "std",
+ "block2",
+ "LAContext",
+] }
objc2-user-notifications = { version = "0.3", default-features = false, features = [
"std",
"block2",
diff --git a/src-tauri/src/app_lock.rs b/src-tauri/src/app_lock.rs
new file mode 100644
index 0000000..e9e94df
--- /dev/null
+++ b/src-tauri/src/app_lock.rs
@@ -0,0 +1,136 @@
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use serde::Serialize;
+use tauri::Manager;
+
+pub struct AppLock {
+ enabled: AtomicBool,
+ unlocked: AtomicBool,
+}
+
+impl AppLock {
+ pub fn new(enabled: bool) -> Self {
+ Self {
+ enabled: AtomicBool::new(enabled),
+ unlocked: AtomicBool::new(!enabled),
+ }
+ }
+
+ pub fn configure(&self, enabled: bool) {
+ self.enabled.store(enabled, Ordering::Release);
+ }
+
+ pub fn is_locked(&self) -> bool {
+ self.enabled.load(Ordering::Acquire) && !self.unlocked.load(Ordering::Acquire)
+ }
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LockStatus {
+ enabled: bool,
+ locked: bool,
+ available: bool,
+ unavailable_reason: Option,
+}
+
+pub fn permits(invoke: &tauri::ipc::Invoke) -> bool {
+ matches!(invoke.message.command(), "app_lock_status" | "app_unlock")
+ || !invoke.message.state_ref().get::().is_locked()
+}
+
+pub fn is_locked(app: &tauri::AppHandle) -> bool {
+ app.try_state::()
+ .map(|lock| lock.is_locked())
+ .unwrap_or(true)
+}
+
+pub fn validate_setting_change(before: bool, after: bool) -> Result<(), String> {
+ if before != after {
+ authenticate(if after {
+ "Authenticate to require unlocking your mailbox when opening the app."
+ } else {
+ "Authenticate to turn off the mailbox opening lock."
+ })?;
+ }
+ Ok(())
+}
+
+#[tauri::command(async)]
+pub fn app_lock_status(app: tauri::AppHandle) -> LockStatus {
+ let lock = app.state::();
+ let unavailable_reason = availability().err();
+ LockStatus {
+ enabled: lock.enabled.load(Ordering::Acquire),
+ locked: lock.is_locked(),
+ available: unavailable_reason.is_none(),
+ unavailable_reason,
+ }
+}
+
+#[tauri::command(async)]
+pub fn app_unlock(app: tauri::AppHandle) -> Result<(), String> {
+ let lock = app.state::();
+ if lock.is_locked() {
+ authenticate("Unlock your mailbox to read and write email.")?;
+ lock.unlocked.store(true, Ordering::Release);
+ }
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+fn availability() -> Result<(), String> {
+ use objc2_local_authentication::{LAContext, LAPolicy};
+
+ let context = unsafe { LAContext::new() };
+ unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthentication) }
+ .map_err(|error| error.localizedDescription().to_string())
+}
+
+#[cfg(target_os = "macos")]
+fn authenticate(reason: &str) -> Result<(), String> {
+ use std::sync::mpsc;
+
+ use block2::RcBlock;
+ use objc2::runtime::Bool;
+ use objc2_foundation::{NSError, NSString};
+ use objc2_local_authentication::{LAContext, LAPolicy};
+
+ let context = unsafe { LAContext::new() };
+ unsafe { context.canEvaluatePolicy_error(LAPolicy::DeviceOwnerAuthentication) }
+ .map_err(|error| error.localizedDescription().to_string())?;
+ let (sender, receiver) = mpsc::channel();
+ let reply = RcBlock::new(move |success: Bool, error: *mut NSError| {
+ let result = if success.as_bool() {
+ Ok(())
+ } else {
+ let message = if error.is_null() {
+ "Authentication was not completed.".to_string()
+ } else {
+ unsafe { &*error }.localizedDescription().to_string()
+ };
+ Err(message)
+ };
+ let _ = sender.send(result);
+ });
+ unsafe {
+ context.evaluatePolicy_localizedReason_reply(
+ LAPolicy::DeviceOwnerAuthentication,
+ &NSString::from_str(reason),
+ &reply,
+ );
+ }
+ receiver
+ .recv()
+ .map_err(|_| "macOS did not complete authentication.".to_string())?
+}
+
+#[cfg(not(target_os = "macos"))]
+fn availability() -> Result<(), String> {
+ Err("The app opening lock is currently available on macOS.".to_string())
+}
+
+#[cfg(not(target_os = "macos"))]
+fn authenticate(_reason: &str) -> Result<(), String> {
+ availability()
+}
diff --git a/src-tauri/src/drafts.rs b/src-tauri/src/drafts.rs
index 5bd6f5b..587ddc2 100644
--- a/src-tauri/src/drafts.rs
+++ b/src-tauri/src/drafts.rs
@@ -40,6 +40,35 @@ pub const MAX_ENCODED_BYTES: u64 = 35 * 1024 * 1024;
/// arrives; this decides how often one of them leaves the machine.
pub const UPLOAD_EVERY_MS: i64 = 5_000;
+#[tauri::command(async)]
+pub fn draft_attachment_store(
+ app: tauri::AppHandle,
+ account_id: String,
+ filename: String,
+ mime_type: String,
+ data_base64: String,
+) -> Result {
+ use base64::Engine;
+ if data_base64.len() as u64 > MAX_ENCODED_BYTES {
+ return Err(format!("{filename} is too large to attach"));
+ }
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(data_base64).map_err(|e| e.to_string())?;
+ let db = db_of(&app)?;
+ db.with(&account_id, |_| Ok(()))?;
+ let directory = db.account_dir(&account_id).join("draft-files");
+ std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
+ let path = directory.join(write::fresh_id("file"));
+ std::fs::write(&path, &bytes).map_err(|e| format!("{filename} could not be stored: {e}"))?;
+ Ok(DraftAttachment {
+ path: Some(path.to_string_lossy().into_owned()),
+ attachment_id: None,
+ filename,
+ mime_type,
+ size: bytes.len() as u64,
+ })
+}
+
/// What sits in the `drafts` row's payload.
///
/// The version is bumped on every save and is what says whether the provider is behind. A
diff --git a/src-tauri/src/dto.rs b/src-tauri/src/dto.rs
index 9dd5143..649e10d 100644
--- a/src-tauri/src/dto.rs
+++ b/src-tauri/src/dto.rs
@@ -785,6 +785,16 @@ pub struct Settings {
/// because they are a decision about a person and they roam with the rest of those.
pub remote_images: String,
pub link_cleaning: bool,
+ #[serde(default)]
+ pub otp_autofill_enabled: bool,
+ #[serde(default)]
+ pub app_lock_enabled: bool,
+ #[serde(default = "on")]
+ pub spelling_enabled: bool,
+ #[serde(default)]
+ pub grammar_enabled: bool,
+ #[serde(default)]
+ pub writing_tools_enabled: bool,
pub screener_enabled: bool,
/// A reply to a thread you are in is never held. Turning this off holds it anyway.
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 42aed4b..07567a0 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -2,6 +2,7 @@
// front of it, the modules are the parts, and a contract type that nothing has consumed yet is a
// contract type rather than dead code.
pub mod accounts;
+pub mod app_lock;
pub mod attachments;
pub mod imap;
pub mod backup;
@@ -24,6 +25,8 @@ pub mod mirror;
pub mod notify;
pub mod piles;
pub mod provider;
+pub mod proofing;
+pub mod otp_autofill;
pub mod routing;
pub mod sanitize;
pub mod screener;
@@ -34,6 +37,7 @@ pub mod state;
pub mod sync;
pub mod undo;
pub mod unsubscribe;
+pub mod writingtools;
#[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
@@ -298,7 +302,9 @@ pub fn run() {
}
builder = builder.manage(google::AuthState::default()).setup(|app| {
+ app.manage(app_lock::AppLock::new(settings::load(app.handle())?.app_lock_enabled));
let handle = app.handle();
+ tauri::async_runtime::spawn_blocking(|| { let _ = otp_autofill::clear(); });
// The mirror and the state database, one connection per account, opened lazily by the
// first read. Managed here because every command that touches SQLite reaches for it.
@@ -384,7 +390,15 @@ pub fn run() {
}
let app = builder
- .invoke_handler(tauri::generate_handler![
+ .invoke_handler({
+ let handler: Box) -> bool + Send + Sync> = Box::new(tauri::generate_handler![
+ app_lock::app_lock_status,
+ app_lock::app_unlock,
+ proofing::proof_text,
+ otp_autofill::otp_autofill_status,
+ otp_autofill::otp_autofill_enable,
+ writingtools::writing_tools_available,
+ writingtools::run_writing_tool,
// Accounts and consent
accounts::accounts_list,
account_start,
@@ -451,6 +465,7 @@ pub fn run() {
contacts::contacts_suggest,
// Writing
drafts::draft_save,
+ drafts::draft_attachment_store,
drafts::draft_get,
drafts::draft_list,
drafts::draft_import,
@@ -489,7 +504,15 @@ pub fn run() {
exports::keymap_path,
exports::keymap_reset,
packaged_by
- ])
+ ]);
+ move |invoke| {
+ if !app_lock::permits(&invoke) {
+ invoke.resolver.reject("Unlock your mailbox first.");
+ return true;
+ }
+ handler(invoke)
+ }
+ })
.build(context)
.expect("error while building Margin Mail");
diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs
index 1304812..07a6700 100644
--- a/src-tauri/src/notify.rs
+++ b/src-tauri/src/notify.rs
@@ -450,6 +450,9 @@ pub fn announce(app: &tauri::AppHandle, account_id: &str, arrivals: &[Arrival])
/// Posts one notification, and writes down why when it could not.
fn post(app: &tauri::AppHandle, text: &Text) -> Result<(), String> {
+ if crate::app_lock::is_locked(app) {
+ return Ok(());
+ }
let result = platform::post(app, text);
if let Err(e) = &result {
crate::log::note("notify", &format!("could not post \"{}\": {e}", text.body));
@@ -582,4 +585,3 @@ pub fn opened(app: &tauri::AppHandle, target: Option) {
pub fn notify_take() -> Option {
OPENED.lock().ok().and_then(|mut slot| slot.take())
}
-
diff --git a/src-tauri/src/otp_autofill.rs b/src-tauri/src/otp_autofill.rs
new file mode 100644
index 0000000..2a67b1a
--- /dev/null
+++ b/src-tauri/src/otp_autofill.rs
@@ -0,0 +1,344 @@
+use serde::Serialize;
+
+#[derive(Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EmailCode {
+ id: String,
+ code: String,
+ domain: String,
+ label: String,
+ expires_at_ms: i64,
+}
+
+#[derive(Serialize)]
+pub struct AutoFillStatus {
+ available: bool,
+ enabled: bool,
+}
+
+#[tauri::command(async)]
+pub fn otp_autofill_status() -> AutoFillStatus {
+ AutoFillStatus {
+ available: native::available(),
+ enabled: native::enabled().unwrap_or(false),
+ }
+}
+
+#[tauri::command]
+pub async fn otp_autofill_enable(app: tauri::AppHandle) -> Result {
+ if !native::available() {
+ return Err("Email OTP AutoFill requires macOS 15 or later.".into());
+ }
+ let (sender, receiver) = tokio::sync::oneshot::channel();
+ app.run_on_main_thread(move || native::enable(sender))
+ .map_err(|error| error.to_string())?;
+ receiver.await.map_err(|error| error.to_string())?
+}
+
+pub fn validate_enabled() -> Result<(), String> {
+ if native::enabled()? {
+ Ok(())
+ } else {
+ Err("Enable Margin Mail in macOS AutoFill & Passwords first.".into())
+ }
+}
+
+pub fn clear() -> Result<(), String> {
+ native::publish(&[], None)
+}
+
+fn code_in(subject: &str, body: &str) -> Option {
+ use std::collections::HashSet;
+ use std::sync::LazyLock;
+ static CUE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(
+ r"(?i)\b(verification|security|authentication|confirmation|login|sign[ -]?in|one[ -]?time|otp|passcode|your\s+code|enter\s+(the\s+)?code)\b"
+ ).unwrap()
+ });
+ static TOKEN: LazyLock =
+ LazyLock::new(|| regex::Regex::new(r"\b[A-Z0-9]{4,8}\b").unwrap());
+ static DURATION: LazyLock =
+ LazyLock::new(|| regex::Regex::new(r"(?i)^\s*(seconds?|minutes?|hours?|days?)\b").unwrap());
+ let (body, _) = crate::sanitize::quoted::split_text(body);
+ let text = format!("{subject}\n{body}");
+ let mut codes = HashSet::new();
+ for token in TOKEN.find_iter(&text) {
+ let value = token.as_str();
+ if !value.bytes().any(|byte| byte.is_ascii_digit())
+ || (!value.bytes().all(|byte| byte.is_ascii_digit()) && value.len() < 6)
+ {
+ continue;
+ }
+ let before: String = text[..token.start()]
+ .chars()
+ .rev()
+ .take(90)
+ .collect::()
+ .chars()
+ .rev()
+ .collect();
+ let after: String = text[token.end()..].chars().take(60).collect();
+ if CUE.is_match(&format!("{before}{value}{after}")) && !DURATION.is_match(&after) {
+ codes.insert(value.to_string());
+ }
+ }
+ if codes.len() == 1 {
+ codes.into_iter().next()
+ } else {
+ None
+ }
+}
+
+pub async fn refresh(app: &tauri::AppHandle) -> Result<(), String> {
+ use tauri::Manager;
+ if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) {
+ return Ok(());
+ }
+ let db = app.state::();
+ let now = crate::mirror::write::now_ms();
+ let mut codes = Vec::new();
+ for account_id in crate::sync::attached() {
+ let rows = db.with(&account_id, |conn| {
+ let mut query = conn
+ .prepare(
+ "SELECT m.id, m.subject, m.from_address, m.from_name, m.date_ms, b.text
+ FROM messages m LEFT JOIN bodies b ON b.message_id = m.id
+ JOIN threads t ON t.provider_thread_id = m.provider_thread_id
+ WHERE m.date_ms > ?1 AND m.date_ms <= ?2 AND m.seen = 0 AND m.sent = 0
+ AND m.draft = 0 AND m.hydrated = 1 AND t.spam = 0 AND t.trashed = 0
+ ORDER BY m.date_ms DESC LIMIT 20",
+ )
+ .map_err(|error| error.to_string())?;
+ let rows = query
+ .query_map(rusqlite::params![now - 180_000, now], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, Option>(3)?,
+ row.get::<_, i64>(4)?,
+ row.get::<_, Option>(5)?,
+ ))
+ })
+ .map_err(|error| error.to_string())?;
+ rows.collect::, _>>()
+ .map_err(|error| error.to_string())
+ })?;
+ for (id, subject, sender, name, date, stored_body) in rows {
+ let body = if let Some(body) = stored_body {
+ body
+ } else {
+ let Some(remote) = crate::sync::remote_for(&account_id) else {
+ continue;
+ };
+ let raw = match remote.fetch_body(&id).await {
+ Ok(raw) => raw,
+ Err(_) => continue,
+ };
+ db.with(&account_id, |conn| {
+ let options = crate::sync::hydrate::render_options(conn)?;
+ crate::mirror::write::store_body(conn, &id, &raw, &options)?;
+ conn.query_row(
+ "SELECT text FROM bodies WHERE message_id = ?1",
+ [&id],
+ |row| row.get::<_, String>(0),
+ )
+ .map_err(|error| error.to_string())
+ })?
+ };
+ let Some(code) = code_in(&subject, &body) else {
+ continue;
+ };
+ let Some((_, domain)) = sender.rsplit_once('@') else {
+ continue;
+ };
+ let domain = domain.to_lowercase();
+ if !domain.contains('.')
+ || domain.chars().any(|character| {
+ !(character.is_ascii_alphanumeric() || character == '.' || character == '-')
+ })
+ {
+ continue;
+ }
+ codes.push(EmailCode {
+ id: format!("{account_id}:{id}"),
+ code,
+ domain,
+ label: name
+ .filter(|name| !name.trim().is_empty())
+ .unwrap_or(sender),
+ expires_at_ms: date + 180_000,
+ });
+ }
+ }
+ if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app) {
+ return clear();
+ }
+ codes.retain(|code| code.expires_at_ms > crate::mirror::write::now_ms());
+ native::publish(&codes, Some(app))
+}
+
+#[cfg(target_os = "macos")]
+mod native {
+ use super::EmailCode;
+ use block2::RcBlock;
+ use objc2::{
+ msg_send,
+ rc::{Allocated, Retained},
+ runtime::{AnyClass, Bool},
+ };
+ use objc2_foundation::{NSArray, NSError, NSFileManager, NSObject, NSString};
+ use std::sync::{mpsc, Mutex};
+
+ #[link(name = "AuthenticationServices", kind = "framework")]
+ unsafe extern "C" {}
+ static PUBLISH: Mutex<()> = Mutex::new(());
+
+ pub fn available() -> bool {
+ let bundled = std::env::current_exe()
+ .ok()
+ .and_then(|path| {
+ path.parent()?.parent().map(|path| {
+ path.join("PlugIns/MarginMailAutoFill.appex/Contents/embedded.provisionprofile")
+ .is_file()
+ })
+ })
+ .unwrap_or(false);
+ bundled
+ && AnyClass::get(c"ASOneTimeCodeCredentialIdentity").is_some()
+ && NSFileManager::defaultManager()
+ .containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str(
+ "TQV87WLXK3.studio.margin.mail",
+ ))
+ .is_some()
+ }
+
+ fn store() -> Result, String> {
+ let class =
+ AnyClass::get(c"ASCredentialIdentityStore").ok_or("macOS AutoFill is unavailable")?;
+ Ok(unsafe { msg_send![class, sharedStore] })
+ }
+
+ pub fn enabled() -> Result {
+ if !available() {
+ return Ok(false);
+ }
+ let (sender, receiver) = mpsc::channel();
+ let reply = RcBlock::new(move |state: *mut NSObject| {
+ let enabled: Bool = unsafe { msg_send![state, isEnabled] };
+ let _ = sender.send(enabled.as_bool());
+ });
+ unsafe {
+ let _: () =
+ msg_send![&*store()?, getCredentialIdentityStoreStateWithCompletion: &*reply];
+ }
+ receiver.recv().map_err(|error| error.to_string())
+ }
+
+ pub fn enable(sender: tokio::sync::oneshot::Sender>) {
+ let Some(class) = AnyClass::get(c"ASSettingsHelper") else {
+ let _ = sender.send(Err("macOS AutoFill is unavailable".into()));
+ return;
+ };
+ let sender = std::sync::Mutex::new(Some(sender));
+ let reply = RcBlock::new(move |enabled: Bool| {
+ if let Some(sender) = sender.lock().ok().and_then(|mut sender| sender.take()) {
+ let _ = sender.send(Ok(enabled.as_bool()));
+ }
+ });
+ unsafe {
+ let _: () = msg_send![class, requestToTurnOnCredentialProviderExtensionWithCompletionHandler: &*reply];
+ }
+ }
+
+ pub fn publish(codes: &[EmailCode], app: Option<&tauri::AppHandle>) -> Result<(), String> {
+ if !available() {
+ return Ok(());
+ }
+ let _guard = PUBLISH.lock().map_err(|error| error.to_string())?;
+ let codes = if let Some(app) = app {
+ if !crate::settings::load(app)?.otp_autofill_enabled || crate::app_lock::is_locked(app)
+ {
+ &[]
+ } else {
+ codes
+ }
+ } else {
+ codes
+ };
+ let directory = NSFileManager::defaultManager()
+ .containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str(
+ "TQV87WLXK3.studio.margin.mail",
+ ))
+ .and_then(|url| url.path())
+ .ok_or("Margin Mail AutoFill signing is not configured")?;
+ let path = std::path::PathBuf::from(directory.to_string());
+ std::fs::create_dir_all(&path).map_err(|error| error.to_string())?;
+ let temporary = path.join("email-otp.pending");
+ use std::io::Write;
+ use std::os::unix::fs::OpenOptionsExt;
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .mode(0o600)
+ .open(&temporary)
+ .map_err(|error| error.to_string())?;
+ file.write_all(&serde_json::to_vec(codes).map_err(|error| error.to_string())?)
+ .map_err(|error| error.to_string())?;
+ std::fs::rename(&temporary, path.join("email-otp.json"))
+ .map_err(|error| error.to_string())?;
+ if !enabled()? {
+ return Ok(());
+ }
+ let service_class = AnyClass::get(c"ASCredentialServiceIdentifier")
+ .ok_or("macOS AutoFill is unavailable")?;
+ let identity_class = AnyClass::get(c"ASOneTimeCodeCredentialIdentity")
+ .ok_or("Email OTP AutoFill requires macOS 15")?;
+ let mut identities: Vec> = Vec::new();
+ for code in codes {
+ let allocated: Allocated = unsafe { msg_send![service_class, alloc] };
+ let service: Retained = unsafe {
+ msg_send![allocated, initWithIdentifier: &*NSString::from_str(&code.domain), type: 0isize]
+ };
+ let allocated: Allocated = unsafe { msg_send![identity_class, alloc] };
+ let identity = unsafe {
+ msg_send![allocated, initWithServiceIdentifier: &*service, label: &*NSString::from_str(&code.label), recordIdentifier: &*NSString::from_str(&code.id)]
+ };
+ identities.push(identity);
+ }
+ let entries = NSArray::from_retained_slice(&identities);
+ let (sender, receiver) = mpsc::channel();
+ let reply = RcBlock::new(move |success: Bool, error: *mut NSError| {
+ let result = if success.as_bool() {
+ Ok(())
+ } else if error.is_null() {
+ Err("macOS could not update OTP suggestions".into())
+ } else {
+ Err(unsafe { &*error }.localizedDescription().to_string())
+ };
+ let _ = sender.send(result);
+ });
+ unsafe {
+ let _: () = msg_send![&*store()?, replaceCredentialIdentityEntries: &*entries, completion: &*reply];
+ }
+ receiver.recv().map_err(|error| error.to_string())?
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+mod native {
+ use super::EmailCode;
+ pub fn available() -> bool {
+ false
+ }
+ pub fn enabled() -> Result {
+ Ok(false)
+ }
+ pub fn publish(_: &[EmailCode], _: Option<&tauri::AppHandle>) -> Result<(), String> {
+ Ok(())
+ }
+ pub fn enable(sender: tokio::sync::oneshot::Sender>) {
+ let _ = sender.send(Err("Email OTP AutoFill requires macOS".into()));
+ }
+}
diff --git a/src-tauri/src/proofing.rs b/src-tauri/src/proofing.rs
new file mode 100644
index 0000000..f651ddb
--- /dev/null
+++ b/src-tauri/src/proofing.rs
@@ -0,0 +1,90 @@
+use serde::Serialize;
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProofIssue {
+ start: usize,
+ end: usize,
+ kind: &'static str,
+ message: String,
+ suggestions: Vec,
+}
+
+#[tauri::command]
+pub async fn proof_text(text: String, spelling: bool, grammar: bool) -> Result, String> {
+ #[cfg(target_os = "macos")]
+ {
+ tauri::async_runtime::spawn_blocking(move || check(&text, spelling, grammar))
+ .await
+ .map_err(|error| error.to_string())
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = (text, spelling, grammar);
+ Ok(Vec::new())
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn check(text: &str, spelling: bool, grammar: bool) -> Vec {
+ use objc2::rc::autoreleasepool;
+ use objc2_app_kit::NSSpellChecker;
+ use objc2_foundation::{NSArray, NSRange, NSString, NSTextCheckingType, NSValue};
+
+ autoreleasepool(|_| {
+ let checker = NSSpellChecker::sharedSpellChecker();
+ let string = NSString::from_str(text);
+ let mut types = NSTextCheckingType::empty();
+ if spelling {
+ types |= NSTextCheckingType::Spelling;
+ }
+ if grammar {
+ types |= NSTextCheckingType::Grammar;
+ }
+ let results = unsafe {
+ checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
+ &string,
+ NSRange { location: 0, length: string.length() },
+ types.bits(),
+ None,
+ 0,
+ None,
+ std::ptr::null_mut(),
+ )
+ };
+ let mut issues = Vec::new();
+ for result in results.iter() {
+ let range = result.range();
+ if result.resultType() == NSTextCheckingType::Spelling {
+ let suggestions = checker
+ .guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &string, None, 0)
+ .map(|guesses| guesses.iter().take(5).map(|guess| guess.to_string()).collect())
+ .unwrap_or_default();
+ issues.push(ProofIssue {
+ start: range.location,
+ end: range.location + range.length,
+ kind: "spelling",
+ message: "Check spelling".into(),
+ suggestions,
+ });
+ } else if result.resultType() == NSTextCheckingType::Grammar {
+ if let Some(details) = result.grammarDetails() {
+ for detail in details.iter() {
+ let relative = detail.objectForKey(&NSString::from_str("NSGrammarRange"))
+ .and_then(|value| value.downcast_ref::().map(|value| unsafe { value.rangeValue() }));
+ let start = range.location + relative.map_or(0, |value| value.location);
+ let length = relative.map_or(range.length, |value| value.length);
+ let message = detail.objectForKey(&NSString::from_str("NSGrammarUserDescription"))
+ .and_then(|value| value.downcast_ref::().map(|value| value.to_string()))
+ .unwrap_or_else(|| "Check grammar".into());
+ let suggestions = detail.objectForKey(&NSString::from_str("NSGrammarCorrections"))
+ .and_then(|value| value.downcast_ref::().map(|values| values.iter().filter_map(|value| value.downcast_ref::().map(|value| value.to_string())).take(5).collect()))
+ .unwrap_or_default();
+ issues.push(ProofIssue { start, end: start + length, kind: "grammar", message, suggestions });
+ }
+ }
+ }
+ }
+ issues
+ })
+}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 82ee981..fafbe5c 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -48,6 +48,11 @@ pub fn defaults() -> Settings {
remote_images: "ask".to_string(),
link_cleaning: true,
+ otp_autofill_enabled: false,
+ app_lock_enabled: false,
+ spelling_enabled: true,
+ grammar_enabled: false,
+ writing_tools_enabled: false,
screener_enabled: true,
hold_replies: false,
@@ -152,7 +157,15 @@ pub fn settings_set(app: tauri::AppHandle, patch: Value) -> Result().configure(after.app_lock_enabled);
for (account_id, days) in window_changes(&before, &after) {
crate::sync::window_set(&app, &account_id, days)?;
diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs
index 989a634..dc47d3d 100644
--- a/src-tauri/src/sync/engine.rs
+++ b/src-tauri/src/sync/engine.rs
@@ -294,6 +294,17 @@ impl Engine {
remote: &dyn Remote,
sink: &dyn Sink,
foreground: bool,
+ ) -> SyncStatus {
+ self.run_scheduled_pass(store, remote, sink, foreground, true).await
+ }
+
+ pub async fn run_scheduled_pass(
+ &self,
+ store: &S,
+ remote: &dyn Remote,
+ sink: &dyn Sink,
+ foreground: bool,
+ maintenance: bool,
) -> SyncStatus {
if self.running.swap(true, Ordering::AcqRel) {
return self.status();
@@ -337,8 +348,10 @@ impl Engine {
// the seed is waiting on and asking beforehand would answer "not yet" on the very pass
// that made it ready.
self.seed_when_ready(store, sink);
- self.housekeeping(store, remote, sink, &mut status, foreground)
- .await;
+ if maintenance {
+ self.housekeeping(store, remote, sink, &mut status, foreground)
+ .await;
+ }
}
status.pending_writes = store.with(write::pending_writes).unwrap_or(0);
diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs
index 5d99b18..3011bf5 100644
--- a/src-tauri/src/sync/mod.rs
+++ b/src-tauri/src/sync/mod.rs
@@ -47,6 +47,7 @@ pub struct Outcome {
pub const POLL_FOREGROUND_SECS: u64 = 12;
pub const POLL_BACKGROUND_SECS: u64 = 60;
+const RECEIVE_POLL_SECS: u64 = 5;
/// A cold start should show fresh mail rather than wait out a poll interval.
const FIRST_PASS_SECS: u64 = 2;
@@ -373,15 +374,23 @@ fn db_of(app: &tauri::AppHandle) -> Result, String> {
/// Starts the poll loop. The integrator calls this from `setup` once the database is managed.
pub fn setup(app: tauri::AppHandle) {
tauri::async_runtime::spawn(async move {
- let mut delay = Duration::from_secs(FIRST_PASS_SECS);
+ let start = tokio::time::Instant::now() + Duration::from_secs(FIRST_PASS_SECS);
+ let mut polls = tokio::time::interval_at(start, Duration::from_secs(RECEIVE_POLL_SECS));
+ polls.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ let mut maintained: Option = None;
loop {
- tokio::time::sleep(delay).await;
- tick(&app).await;
- delay = Duration::from_secs(if focused(&app) {
+ polls.tick().await;
+ let foreground = focused(&app);
+ let cadence = Duration::from_secs(if foreground {
POLL_FOREGROUND_SECS
} else {
POLL_BACKGROUND_SECS
});
+ let maintenance = maintained.map(|last| last.elapsed() >= cadence).unwrap_or(true);
+ if maintenance {
+ maintained = Some(tokio::time::Instant::now());
+ }
+ tick(&app, foreground, maintenance).await;
}
});
}
@@ -392,26 +401,34 @@ fn focused(app: &tauri::AppHandle) -> bool {
.any(|window| window.is_focused().unwrap_or(false))
}
-async fn tick(app: &tauri::AppHandle) {
+async fn tick(app: &tauri::AppHandle, foreground: bool, maintenance: bool) {
let Ok(db) = db_of(app) else { return };
let sink = AppSink { app: app.clone() };
- for account_id in attached() {
+ let db = db.inner();
+ let sink = &sink;
+ let passes = attached().into_iter().map(|account_id| async move {
let Some(remote) = remote_for(&account_id) else {
- continue;
+ return;
};
let engine = engine_for(&account_id);
let store = Scoped {
- db: db.inner(),
+ db,
account_id: &account_id,
};
engine
- .run_pass(&store, remote.as_ref(), &sink, focused(app))
+ .run_scheduled_pass(&store, remote.as_ref(), sink, foreground, maintenance)
.await;
// A draft written just before a quit has a local row and no provider copy yet. The pass is
// where it catches up, so a draft roams the way Gmail drafts always have rather than
// waiting for the composer to be opened again.
- let _ = crate::drafts::upload_pending(app, &account_id).await;
+ if maintenance {
+ let _ = crate::drafts::upload_pending(app, &account_id).await;
+ }
+ });
+ futures::future::join_all(passes).await;
+ if let Err(error) = crate::otp_autofill::refresh(app).await {
+ crate::log::note("autofill", &error);
}
}
diff --git a/src-tauri/src/writingtools.rs b/src-tauri/src/writingtools.rs
new file mode 100644
index 0000000..85f139f
--- /dev/null
+++ b/src-tauri/src/writingtools.rs
@@ -0,0 +1,77 @@
+#[cfg(target_os = "macos")]
+mod mac {
+ use objc2::rc::Retained;
+ use objc2::runtime::AnyClass;
+ use objc2::{msg_send, sel, MainThreadMarker};
+ use objc2_app_kit::{NSApplication, NSMenu};
+
+ pub fn available() -> bool {
+ let Some(class) = AnyClass::get(c"NSWritingToolsCoordinator") else { return false };
+ unsafe {
+ let responds: bool = msg_send![class, respondsToSelector: sel!(isWritingToolsAvailable)];
+ responds && msg_send![class, isWritingToolsAvailable]
+ }
+ }
+
+ fn writing_menu(mtm: MainThreadMarker) -> Option> {
+ let main = NSApplication::sharedApplication(mtm).mainMenu()?;
+ for item in main.itemArray().iter() {
+ let Some(edit) = item.submenu() else { continue };
+ if edit.title().to_string() != "Edit" && item.title().to_string() != "Edit" { continue; }
+ for child in edit.itemArray().iter() {
+ if child.title().to_string() == "Writing Tools" { return child.submenu(); }
+ }
+ }
+ None
+ }
+
+ pub fn perform(tool: &str) -> Result<(), String> {
+ let mtm = MainThreadMarker::new().ok_or("Writing Tools must run on the main thread")?;
+ if !available() {
+ return Err("Apple Writing Tools are unavailable. Check Apple Intelligence in System Settings.".into());
+ }
+ let menu = writing_menu(mtm).ok_or("Apple Writing Tools are unavailable in the app’s Edit menu")?;
+ menu.update();
+ for (index, item) in menu.itemArray().iter().enumerate() {
+ if item.title().to_string() != tool { continue; }
+ if !item.isEnabled() {
+ return Err("Select text in your message before using Apple Writing Tools.".into());
+ }
+ let action = item.action().ok_or("The Writing Tools action is unavailable")?;
+ let application = NSApplication::sharedApplication(mtm);
+ if unsafe { application.targetForAction_to_from(action, item.target().as_deref(), Some(&item)) }.is_none() {
+ return Err("Apple Writing Tools cannot edit the selected text.".into());
+ }
+ menu.performActionForItemAtIndex(index as isize);
+ return Ok(());
+ }
+ Err(format!("Apple {tool} is unavailable in the app’s Edit menu"))
+ }
+}
+
+#[tauri::command]
+pub fn writing_tools_available() -> bool {
+ #[cfg(target_os = "macos")]
+ { mac::available() }
+ #[cfg(not(target_os = "macos"))]
+ { false }
+}
+
+#[tauri::command]
+pub async fn run_writing_tool(app: tauri::AppHandle, tool: String) -> Result<(), String> {
+ if !matches!(tool.as_str(), "Proofread" | "Rewrite") {
+ return Err("Unknown Writing Tools action".into());
+ }
+ if !crate::settings::load(&app)?.writing_tools_enabled {
+ return Err("Enable Apple Writing Tools in Writing settings first".into());
+ }
+ #[cfg(target_os = "macos")]
+ {
+ let (sender, receiver) = tokio::sync::oneshot::channel();
+ app.run_on_main_thread(move || { let _ = sender.send(mac::perform(&tool)); })
+ .map_err(|error| error.to_string())?;
+ receiver.await.map_err(|error| error.to_string())?
+ }
+ #[cfg(not(target_os = "macos"))]
+ { Err("Apple Writing Tools require macOS".into()) }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 1d73e5e..1c2e832 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import { Header } from "./screens/Header";
import { ListColumn } from "./screens/ListColumn";
import { Clips } from "./screens/Clips";
import { Compose } from "./screens/Compose";
+import { AppLockGate } from "./screens/AppLock";
import { ContactCards } from "./screens/ContactCards";
import { Contacts } from "./screens/Contacts";
import { Feed } from "./screens/Feed";
@@ -447,7 +448,7 @@ function App() {
);
}
- return ;
+ return ;
}
export default App;
diff --git a/src/api/appLock.ts b/src/api/appLock.ts
new file mode 100644
index 0000000..f3a5daa
--- /dev/null
+++ b/src/api/appLock.ts
@@ -0,0 +1,15 @@
+import { call, isTauri } from "../ipc";
+
+export interface AppLockStatus {
+ enabled: boolean;
+ locked: boolean;
+ available: boolean;
+ unavailableReason: string | null;
+}
+
+export const appLockStatus = (): Promise =>
+ isTauri
+ ? call("app_lock_status")
+ : Promise.resolve({ enabled: false, locked: false, available: false, unavailableReason: null });
+
+export const appUnlock = () => call("app_unlock");
diff --git a/src/api/otpAutofill.ts b/src/api/otpAutofill.ts
new file mode 100644
index 0000000..c554f92
--- /dev/null
+++ b/src/api/otpAutofill.ts
@@ -0,0 +1,14 @@
+import { call, isTauri } from "../ipc";
+
+export interface OtpAutofillStatus {
+ available: boolean;
+ enabled: boolean;
+}
+
+export const otpAutofillStatus = (): Promise =>
+ isTauri
+ ? call("otp_autofill_status")
+ : Promise.resolve({ available: false, enabled: false });
+
+export const otpAutofillEnable = (): Promise =>
+ isTauri ? call("otp_autofill_enable") : Promise.resolve(false);
diff --git a/src/api/proofing.ts b/src/api/proofing.ts
new file mode 100644
index 0000000..b64ea1b
--- /dev/null
+++ b/src/api/proofing.ts
@@ -0,0 +1,12 @@
+import { call } from "../ipc";
+
+export interface ProofIssue {
+ start: number;
+ end: number;
+ kind: "spelling" | "grammar";
+ message: string;
+ suggestions: string[];
+}
+
+export const proofText = (text: string, spelling: boolean, grammar: boolean) =>
+ call("proof_text", { text, spelling, grammar });
diff --git a/src/api/write.ts b/src/api/write.ts
index c5bd6e6..d92cc07 100644
--- a/src/api/write.ts
+++ b/src/api/write.ts
@@ -1,12 +1,16 @@
import {
call,
type Draft,
+ type DraftAttachment,
type DraftSaved,
type InviteResponse,
type Outgoing,
type Undo,
} from "../ipc";
+export const draftAttachmentStore = (accountId: string, file: DraftAttachment, dataBase64: string) =>
+ call("draft_attachment_store", { accountId, filename: file.filename, mimeType: file.mimeType, dataBase64 });
+
/** Saves locally on every keystroke's debounce and to the provider every few seconds. */
export const draftSave = (draft: Draft) => call("draft_save", { draft });
diff --git a/src/api/writingtools.ts b/src/api/writingtools.ts
new file mode 100644
index 0000000..3d8ed39
--- /dev/null
+++ b/src/api/writingtools.ts
@@ -0,0 +1,7 @@
+import { call, isMacDesktop } from "../ipc";
+
+export const writingToolsAvailable = () =>
+ isMacDesktop ? call("writing_tools_available") : Promise.resolve(false);
+
+export const runWritingTool = (tool: "Proofread" | "Rewrite") =>
+ call("run_writing_tool", { tool });
diff --git a/src/dev/fixture.ts b/src/dev/fixture.ts
index cc8e63b..d4fc407 100644
--- a/src/dev/fixture.ts
+++ b/src/dev/fixture.ts
@@ -1268,6 +1268,11 @@ export const devSettings: Settings = {
remoteImages: "ask",
linkCleaning: true,
+ otpAutofillEnabled: false,
+ appLockEnabled: false,
+ spellingEnabled: true,
+ grammarEnabled: false,
+ writingToolsEnabled: false,
screenerEnabled: true,
holdReplies: false,
diff --git a/src/dev/mockIpc.ts b/src/dev/mockIpc.ts
index cd42daf..3dcd26e 100644
--- a/src/dev/mockIpc.ts
+++ b/src/dev/mockIpc.ts
@@ -287,15 +287,16 @@ interface UndoEntry {
token: string;
label: string;
revert: () => void;
+ outgoingId?: string;
}
const undoStack: UndoEntry[] = [];
let undoCount = 0;
-function undoable(label: string, revert: () => void, undoMs = 0): Undo {
+function undoable(label: string, revert: () => void, undoMs = 0, outgoingId?: string): Undo {
undoCount += 1;
const token = `undo-${undoCount}`;
- undoStack.push({ token, label, revert });
+ undoStack.push({ token, label, revert, outgoingId });
// Bounded in Rust for the same reason: a stack that grows for a session is a leak.
if (undoStack.length > 25) undoStack.shift();
return { token, label, undoMs };
@@ -1451,6 +1452,15 @@ export async function mockCall(command: string, args?: Record("accountId");
+ return done(
+ empty ? [] : drafts
+ .filter((draft) => !accountId || draft.accountId === accountId)
+ .map((draft) => structuredClone(draft)),
+ );
+ }
+
case "draft_save": {
const draft = arg("draft");
const id = draft.id ?? `draft-${drafts.length + 1}`;
@@ -1462,7 +1472,7 @@ export async function mockCall(command: string, args?: Record(command: string, args?: Record(command: string, args?: Record saved.id === draft.id);
+ const saved = savedIndex >= 0 ? drafts.splice(savedIndex, 1)[0] : undefined;
+ changed("outbox drafts threads");
const to = draft.to[0]?.name ?? draft.to[0]?.address ?? "nobody";
return done(
undoable(
@@ -1518,18 +1530,21 @@ export async function mockCall(command: string, args?: Record {
const index = outbox.findIndex((item) => item.id === id);
if (index >= 0) outbox.splice(index, 1);
+ if (saved) drafts.push(saved);
if (thread) {
thread.sending = false;
thread.hasDraft = true;
}
},
settings.undoDelaySecs * 1_000,
+ id,
),
);
}
case "send_now": {
- const id = arg("outgoingId");
+ const outgoingId = arg("outgoingId");
+ const id = undoStack.find((entry) => entry.token === outgoingId)?.outgoingId ?? outgoingId;
// Rust pushes the message to the provider before it answers, and the line has a busy state
// for that wait: the same beat as the bodies, so it can be looked at.
if (flagged("marginmail-dev-pending")) await beat(900);
diff --git a/src/ipc.ts b/src/ipc.ts
index b297225..497e300 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -622,6 +622,11 @@ export interface Settings {
/** The per sender allowances are not here: they live on the contact and roam with it. */
remoteImages: "never" | "ask" | "always";
linkCleaning: boolean;
+ otpAutofillEnabled: boolean;
+ appLockEnabled: boolean;
+ spellingEnabled: boolean;
+ grammarEnabled: boolean;
+ writingToolsEnabled: boolean;
screenerEnabled: boolean;
holdReplies: boolean;
diff --git a/src/screens/AppLock.tsx b/src/screens/AppLock.tsx
new file mode 100644
index 0000000..6871e23
--- /dev/null
+++ b/src/screens/AppLock.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { appLockStatus, appUnlock, type AppLockStatus } from "../api/appLock";
+import { Button } from "../ui/Button";
+import "./app-lock.css";
+
+export function AppLockGate({ children }: { children: ReactNode }) {
+ const [status, setStatus] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const readStatus = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ setStatus(await appLockStatus());
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ useEffect(() => {
+ void readStatus();
+ }, []);
+
+ const unlock = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ await appUnlock();
+ setStatus(await appLockStatus());
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (status && !status.locked) return children;
+ if (!status && !error) return ;
+
+ return (
+
+
+
Mailbox locked
+
+ Use Touch ID or your Mac login password to open your mailbox.
+
+ {error || status?.unavailableReason ? (
+
{error ?? status?.unavailableReason}
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/screens/Compose.tsx b/src/screens/Compose.tsx
index c8d9518..7fc89e7 100644
--- a/src/screens/Compose.tsx
+++ b/src/screens/Compose.tsx
@@ -15,9 +15,11 @@ import { useEscapeLayer } from "../escape";
import { registerCommands } from "../keys/commands";
import { useKeyContext } from "../keys/keymap";
import { contactsSuggest } from "../api/contacts";
+import { draftAttachmentStore } from "../api/write";
+import { notify } from "../store/useToast";
import { isTauri, type Draft, type DraftAttachment, type Person } from "../ipc";
import { useAccounts } from "../store/useAccounts";
-import { useCompose, type Composer, type ComposerAt } from "../store/useCompose";
+import { storeAttachments, useCompose, type Composer, type ComposerAt } from "../store/useCompose";
import { useMail } from "../store/useMail";
import { useSettings } from "../store/useSettings";
import { Editor } from "./Editor";
@@ -139,35 +141,63 @@ function defaultReminder(): number {
// Files
// -------------------------------------------------------------------------------------------
-/**
- * The webview's own picker, which is the only one this app has: there is no dialog plugin in
- * `src-tauri`, and adding one is a decision for whoever owns that crate.
- *
- * A `File` from a webview has a name, a size and a type and no path, and `DraftAttachment` carries
- * a path or a mirror attachment id and nothing else. So the name goes in the path field, which is
- * enough for the fixture and for the composer's own arithmetic and is not enough for Rust to read
- * the bytes. Dragging a file onto a Tauri window is the route that carries a real path.
- */
export function pickFiles(at: ComposerAt): void {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
- useCompose.getState().attach(at, [...(input.files ?? [])].map(asAttachment));
+ void attachFiles(at, [...(input.files ?? [])]);
input.remove();
});
+ input.addEventListener("cancel", () => input.remove());
document.body.append(input);
input.click();
}
export const asAttachment = (file: File): DraftAttachment => ({
- path: file.name,
filename: file.name,
mimeType: file.type || "application/octet-stream",
size: file.size,
});
+function attachFiles(at: ComposerAt, files: File[]): Promise {
+ return storeAttachments(at, persistFiles(at, files)).catch((error) => {
+ notify(`Could not attach files: ${error}`);
+ });
+}
+
+async function persistFiles(at: ComposerAt, files: File[]): Promise {
+ const composer = at === "card" ? useCompose.getState().card : useCompose.getState().reply;
+ if (!composer || files.length === 0) return;
+ const existingSize = Math.max(composer.encodedSize,
+ (composer.draft.attachments ?? []).reduce((size, file) => size + file.size * 4 / 3, 0));
+ if (existingSize + files.reduce((size, file) => size + file.size * 4 / 3, 0) > 35 * 1024 * 1024) {
+ throw new Error("These files would put this message over the 35 MB the provider takes");
+ }
+ const draftId = composer.draft.id || crypto.randomUUID();
+ if (!composer.draft.id) useCompose.getState().edit(at, { id: draftId });
+ const attachments: DraftAttachment[] = [];
+ for (const file of files) {
+ const descriptor = asAttachment(file);
+ if (!isTauri) {
+ attachments.push(descriptor);
+ continue;
+ }
+ const data = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result).split(",")[1]);
+ reader.onerror = () => reject(reader.error ?? new Error(`${file.name} could not be read`));
+ reader.readAsDataURL(file);
+ });
+ attachments.push(await draftAttachmentStore(composer.draft.accountId, descriptor, data));
+ }
+ const current = at === "card" ? useCompose.getState().card : useCompose.getState().reply;
+ if (current?.draft.id === draftId) {
+ useCompose.getState().attach(at, attachments);
+ }
+}
+
// -------------------------------------------------------------------------------------------
// The card
// -------------------------------------------------------------------------------------------
@@ -232,6 +262,7 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps
const draft = composer.draft;
const edit = useCompose((s) => s.edit);
const setShowCc = useCompose((s) => s.setShowCc);
+ const signature = useSettings((s) => s.settings?.accounts.find((account) => account.accountId === draft.accountId)?.signature);
return (
edit("card", { bodyHtml })}
/>
@@ -336,19 +368,14 @@ export function dropped(e: DragEvent, at: ComposerAt): void {
const files = [...(e.dataTransfer.files ?? [])];
if (files.length === 0) return;
e.preventDefault();
- useCompose.getState().attach(at, files.map(asAttachment));
+ void attachFiles(at, files);
}
-/**
- * A pasted image becomes an attachment rather than an inline part, because `DraftAttachment` has a
- * path or a mirror attachment id and no content id and no inline flag. That is the contract rather
- * than an oversight to route around here: an inline image needs a `cid:` on both sides.
- */
export function pasted(e: ClipboardEvent, at: ComposerAt): void {
const files = [...(e.clipboardData?.files ?? [])];
if (files.length === 0) return;
e.preventDefault();
- useCompose.getState().attach(at, files.map(asAttachment));
+ void attachFiles(at, files);
}
interface FromFieldProps {
@@ -727,6 +754,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
const [closing, setClosing] = useState(false);
const editor = useRef(null);
const draft = composer.draft;
+ const signature = useSettings((s) => s.settings?.accounts.find((account) => account.accountId === draft.accountId)?.signature);
// Escape leaves the editor and keeps the draft, which is docs/keyboard.md's own wording. The
// second Escape is not this box's: it belongs to whatever is under it.
@@ -810,6 +838,7 @@ export function ReplyBox({ to, composer }: ReplyBoxProps) {
html={draft.bodyHtml}
label="Reply"
placeholder="Write a reply"
+ signature={signature}
autoFocus={!forwarding}
onChange={(bodyHtml) => edit("reply", { bodyHtml })}
onReady={(instance) => {
diff --git a/src/screens/Editor.tsx b/src/screens/Editor.tsx
index cd60347..ebd18d2 100644
--- a/src/screens/Editor.tsx
+++ b/src/screens/Editor.tsx
@@ -1,93 +1,271 @@
-import { useEffect, useRef } from "react";
-import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { EditorContent, useEditor, useEditorState, type Editor as TiptapEditor } from "@tiptap/react";
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
import StarterKit from "@tiptap/starter-kit";
import { hasText } from "../store/useCompose";
+import { useSettings } from "../store/useSettings";
+import { isMacDesktop } from "../ipc";
+import { proofText, type ProofIssue as NativeProofIssue } from "../api/proofing";
+import { Icon, icons } from "../ui";
+import { useEscapeLayer } from "../escape";
+import { signatureHtml } from "../signature";
+import { writingToolsAvailable, runWritingTool } from "../api/writingtools";
import "./editor.css";
-/**
- * The body of a message, which is TipTap and StarterKit and nothing else.
- *
- * Paragraphs, bold, italic, links, lists, quotes and code, built the way margin builds its editor
- * in `src/editor/extensions.ts`: one configured StarterKit rather than a list of extensions
- * assembled by hand. What is deliberately absent is colour and type. A mail client that lets you
- * choose a typeface is a mail client that sends mail nobody can read, and the faces in settings are
- * the reader's choice rather than the writer's.
- *
- * There is no toolbar either. Every mark this editor can make has a key in docs/keyboard.md, the
- * keys are TipTap's own, and `src/keys/bindings.ts` declares them with a null command so the
- * dispatcher sees the frame and stands out of the way. `Cmd+K` is the one real collision in the
- * app, and inside here the link wins because the palette is one Escape away and a link is not.
- *
- * It writes HTML. Rust inlines the stylesheet and builds the plain text alternative on the way out,
- * which is why nothing here has an opinion about what the recipient's client can render.
- */
-
interface EditorProps {
html: string;
onChange: (html: string) => void;
placeholder: string;
label: string;
- /**
- * Takes the caret when it appears, which is what `r` is for.
- *
- * At the start of the document rather than the end, because a draft opens with a signature under
- * it and nobody writes underneath their own name.
- */
autoFocus?: boolean;
- /** Handed the instance so the box around it can put the caret back. */
onReady?: (editor: TiptapEditor | null) => void;
+ signature?: string;
}
-const EXTENSIONS = [
- StarterKit.configure({
- // Mail has no headings and no rules. The subject is the heading and the hairline between
- // messages is the pane's, so both would be a mark the reader meets somewhere it means nothing.
- heading: false,
- horizontalRule: false,
- link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
- }),
-];
+interface ProofIssue extends NativeProofIssue {
+ from: number;
+ to: number;
+ word: string;
+}
-export function Editor({ html, onChange, placeholder, label, autoFocus, onReady }: EditorProps) {
+const proofKey = new PluginKey("mail-proofing");
+const Proofing = Extension.create({
+ name: "mailProofing",
+ addProseMirrorPlugins() {
+ return [new Plugin({
+ key: proofKey,
+ state: {
+ init: () => DecorationSet.empty,
+ apply: (transaction, decorations) => {
+ const issues = transaction.getMeta(proofKey) as ProofIssue[] | undefined;
+ if (issues) return DecorationSet.create(transaction.doc, issues.map((issue) =>
+ Decoration.inline(issue.from, issue.to, { class: `editor-proof-${issue.kind}` })));
+ return transaction.docChanged ? DecorationSet.empty : decorations;
+ },
+ },
+ props: { decorations: (state) => proofKey.getState(state) },
+ })];
+ },
+});
+
+const EXTENSIONS = [StarterKit.configure({
+ heading: false,
+ horizontalRule: false,
+ link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
+}), Proofing];
+
+export function Editor({ html, onChange, placeholder, label, autoFocus, onReady, signature }: EditorProps) {
const emitted = useRef(html);
const ready = useRef(onReady);
ready.current = onReady;
+ const spelling = useSettings((state) => state.settings?.spellingEnabled ?? true);
+ const grammar = useSettings((state) => state.settings?.grammarEnabled ?? false);
+ const writingTools = useSettings((state) => state.settings?.writingToolsEnabled ?? false);
+ const [appleToolsAvailable, setAppleToolsAvailable] = useState(false);
+ const [writingToolsError, setWritingToolsError] = useState("");
+ const [linkOpen, setLinkOpen] = useState(false);
+ const [linkValue, setLinkValue] = useState("");
+ const [linkError, setLinkError] = useState("");
+ const [issues, setIssues] = useState([]);
+ const [proofOpen, setProofOpen] = useState(false);
+ const [proofError, setProofError] = useState("");
const editor = useEditor({
extensions: EXTENSIONS,
content: html,
autofocus: autoFocus ? "start" : false,
- editorProps: {
- attributes: { class: "editor-body", "aria-label": label },
- },
+ editorProps: { attributes: { class: "editor-body", "aria-label": label, spellcheck: String(!isMacDesktop && spelling) } },
onUpdate: ({ editor }) => {
emitted.current = editor.getHTML();
onChange(emitted.current);
},
});
+ useEditorState({ editor, selector: ({ editor }) => editor?.state });
+
+ useEffect(() => {
+ let live = true;
+ setAppleToolsAvailable(false);
+ if (writingTools && isMacDesktop) {
+ void writingToolsAvailable().then((available) => { if (live) setAppleToolsAvailable(available); })
+ .catch((error) => { if (live) setWritingToolsError(String(error)); });
+ }
+ return () => { live = false; };
+ }, [writingTools]);
+
+ useEffect(() => {
+ editor?.view.dom.setAttribute("spellcheck", String(!isMacDesktop && spelling));
+ }, [editor, spelling]);
+
useEffect(() => {
ready.current?.(editor ?? null);
return () => ready.current?.(null);
}, [editor]);
- // Written from outside: Instant intro puts a line at the top and pressing it again takes the line
- // away. Comparing against what this editor last emitted rather than against its own document is
- // what keeps that from fighting the caret on every keystroke.
useEffect(() => {
if (!editor || html === emitted.current) return;
emitted.current = html;
editor.commands.setContent(html, { emitUpdate: false });
}, [editor, html]);
+ useEffect(() => {
+ if (!editor) return;
+ let version = 0;
+ let timer: ReturnType;
+ const proof = () => {
+ const current = ++version;
+ clearTimeout(timer);
+ setIssues([]);
+ editor.view.dispatch(editor.state.tr.setMeta(proofKey, []));
+ if (!isMacDesktop || (!spelling && !grammar)) return;
+ timer = setTimeout(() => {
+ const doc = editor.state.doc;
+ const segments: { offset: number; position: number; text: string }[] = [];
+ let text = "";
+ doc.descendants((node, position) => {
+ if (!node.isTextblock) return true;
+ if (text) text += "\n\n";
+ node.forEach((child, offset) => {
+ if (child.type.name === "hardBreak") { text += "\n"; return; }
+ if (!child.isText) return;
+ segments.push({ offset: text.length, position: position + 1 + offset, text: child.text ?? "" });
+ text += child.text ?? "";
+ });
+ return false;
+ });
+ if (!text.trim()) return;
+ const positionAt = (offset: number) => {
+ const segment = segments.find((segment) => offset >= segment.offset && offset <= segment.offset + segment.text.length);
+ return segment ? segment.position + offset - segment.offset : null;
+ };
+ void proofText(text, spelling, grammar).then((found) => {
+ if (current !== version || editor.isDestroyed || !editor.state.doc.eq(doc)) return;
+ const mapped = found.flatMap((issue) => {
+ const from = positionAt(issue.start);
+ const to = positionAt(issue.end);
+ return from !== null && to !== null && to > from ? [{ ...issue, from, to, word: text.slice(issue.start, issue.end) }] : [];
+ });
+ setIssues(mapped);
+ setProofError("");
+ editor.view.dispatch(editor.state.tr.setMeta(proofKey, mapped));
+ }).catch((error) => {
+ if (current === version) setProofError(String(error));
+ });
+ }, 650);
+ };
+ proof();
+ editor.on("update", proof);
+ return () => { version++; clearTimeout(timer); editor.off("update", proof); };
+ }, [editor, spelling, grammar]);
+
+ useEscapeLayer(linkOpen || proofOpen, () => { setLinkOpen(false); setProofOpen(false); editor?.commands.focus(); });
+
+ const openLink = () => {
+ setLinkValue((editor?.getAttributes("link").href as string) ?? "");
+ setLinkError("");
+ setLinkOpen(true);
+ setProofOpen(false);
+ };
+
+ useEffect(() => {
+ if (!editor) return;
+ const key = (event: KeyboardEvent) => {
+ if (!editor.isFocused || event.key.toLowerCase() !== "k" || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
+ event.preventDefault();
+ event.stopPropagation();
+ openLink();
+ };
+ editor.view.dom.addEventListener("keydown", key);
+ return () => editor.view.dom.removeEventListener("keydown", key);
+ }, [editor]);
+
+ const applyLink = () => {
+ if (!editor) return;
+ const value = linkValue.trim();
+ if (!value) editor.chain().focus().extendMarkRange("link").unsetLink().run();
+ else {
+ const href = /^[a-z][a-z\d+.-]*:/i.test(value) ? value : `https://${value}`;
+ try {
+ const url = new URL(href);
+ if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) throw new Error();
+ } catch { setLinkError("Use a web, email, or telephone link."); return; }
+ if (editor.state.selection.empty && !editor.isActive("link")) {
+ editor.chain().focus().insertContent({ type: "text", text: value, marks: [{ type: "link", attrs: { href } }] }).run();
+ } else editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
+ }
+ setLinkOpen(false);
+ };
+
+ const tool = (title: string, content: ReactNode, action: () => void, active = false, disabled = false) => (
+
+ );
+
+ const insertSignature = () => {
+ if (!editor || !signature) return;
+ editor.chain().focus().insertContent(signatureHtml(signature)).run();
+ };
+
+ const runAppleTool = (tool: "Proofread" | "Rewrite") => {
+ if (!editor) return;
+ setWritingToolsError("");
+ if (editor.state.selection.empty) editor.chain().focus().selectAll().run();
+ else editor.commands.focus();
+ requestAnimationFrame(() => {
+ void runWritingTool(tool).catch((error) => setWritingToolsError(String(error)));
+ });
+ };
+
return (
-
- {/* The placeholder is a sibling rather than the Placeholder extension, which is a dependency
- this app does not have and would be carrying for one line of grey text. */}
-
- {placeholder}
-
-
+
+ {otpStatus?.available
+ ? "Enable Margin Mail under macOS System Settings → General → AutoFill & Passwords when prompted. Keep Margin Mail running and unlocked to receive codes."
+ : "Requires macOS 15 or later and the installed Margin Mail AutoFill extension."}
+