some more fixes

This commit is contained in:
pj committed 2026-10-03 23:24:24 +05:30
1 parent 7274a54a6e
commit 9fccd563f6
40 files changed
+1690 -104

No files matched your search

+18
View File
@@ -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.
+8 -1
View File
@@ -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
+158
View File
@@ -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))
}
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Margin Mail</string>
<key>CFBundleExecutable</key>
<string>MarginMailAutoFill</string>
<key>CFBundleIdentifier</key>
<string>studio.margin.mail.autofill</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Margin Mail AutoFill</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.authentication-services-credential-provider-ui</string>
<key>NSExtensionPrincipalClass</key>
<string>MarginMailAutoFill.CredentialProviderViewController</string>
<key>NSExtensionAttributes</key>
<dict>
<key>ASCredentialProviderExtensionCapabilities</key>
<dict>
<key>ProvidesOneTimeCodes</key>
<true/>
<key>ProvidesPasswords</key>
<false/>
<key>ProvidesPasskeys</key>
<false/>
</dict>
</dict>
</dict>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>TQV87WLXK3.studio.margin.mail</string>
</array>
</dict>
</plist>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>TQV87WLXK3.studio.margin.mail</string>
</array>
</dict>
</plist>
+2
View File
@@ -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",
+6
View File
@@ -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/[email protected])
'@tiptap/pm':
specifier: ^3.31.2
version: 3.31.2
'@tiptap/react':
specifier: ^3.31.2
version: 3.31.2(@floating-ui/[email protected])(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
+70
View File
@@ -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);
+14
View File
@@ -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"
+13
View File
@@ -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",
+136
View File
@@ -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<String>,
}
pub fn permits<R: tauri::Runtime>(invoke: &tauri::ipc::Invoke<R>) -> bool {
matches!(invoke.message.command(), "app_lock_status" | "app_unlock")
|| !invoke.message.state_ref().get::<AppLock>().is_locked()
}
pub fn is_locked(app: &tauri::AppHandle) -> bool {
app.try_state::<AppLock>()
.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::<AppLock>();
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::<AppLock>();
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()
}
+29
View File
@@ -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<DraftAttachment, String> {
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
+10
View File
@@ -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.
+25 -2
View File
@@ -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<dyn Fn(tauri::ipc::Invoke<tauri::Wry>) -> 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");
+3 -1
View File
@@ -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<NotifyTarget>) {
pub fn notify_take() -> Option<NotifyTarget> {
OPENED.lock().ok().and_then(|mut slot| slot.take())
}
+344
View File
@@ -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<bool, String> {
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<String> {
use std::collections::HashSet;
use std::sync::LazyLock;
static CUE: LazyLock<regex::Regex> = 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<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\b[A-Z0-9]{4,8}\b").unwrap());
static DURATION: LazyLock<regex::Regex> =
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::<String>()
.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::<crate::db::Db>();
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<String>>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, Option<String>>(5)?,
))
})
.map_err(|error| error.to_string())?;
rows.collect::<Result<Vec<_>, _>>()
.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<Retained<NSObject>, String> {
let class =
AnyClass::get(c"ASCredentialIdentityStore").ok_or("macOS AutoFill is unavailable")?;
Ok(unsafe { msg_send![class, sharedStore] })
}
pub fn enabled() -> Result<bool, String> {
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<Result<bool, String>>) {
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<Retained<NSObject>> = Vec::new();
for code in codes {
let allocated: Allocated<NSObject> = unsafe { msg_send![service_class, alloc] };
let service: Retained<NSObject> = unsafe {
msg_send![allocated, initWithIdentifier: &*NSString::from_str(&code.domain), type: 0isize]
};
let allocated: Allocated<NSObject> = 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<bool, String> {
Ok(false)
}
pub fn publish(_: &[EmailCode], _: Option<&tauri::AppHandle>) -> Result<(), String> {
Ok(())
}
pub fn enable(sender: tokio::sync::oneshot::Sender<Result<bool, String>>) {
let _ = sender.send(Err("Email OTP AutoFill requires macOS".into()));
}
}
+90
View File
@@ -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<String>,
}
#[tauri::command]
pub async fn proof_text(text: String, spelling: bool, grammar: bool) -> Result<Vec<ProofIssue>, 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<ProofIssue> {
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::<NSValue>().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::<NSString>().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::<NSArray>().map(|values| values.iter().filter_map(|value| value.downcast_ref::<NSString>().map(|value| value.to_string())).take(5).collect()))
.unwrap_or_default();
issues.push(ProofIssue { start, end: start + length, kind: "grammar", message, suggestions });
}
}
}
}
issues
})
}
+13
View File
@@ -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<Settings, Str
let path = path(&app)?;
let before = load(&app)?;
let after = merge(&before, patch)?;
crate::app_lock::validate_setting_change(before.app_lock_enabled, after.app_lock_enabled)?;
if !before.otp_autofill_enabled && after.otp_autofill_enabled {
crate::otp_autofill::validate_enabled()?;
}
write(&path, &after)?;
if before.otp_autofill_enabled && !after.otp_autofill_enabled {
crate::otp_autofill::clear()?;
}
app.state::<crate::app_lock::AppLock>().configure(after.app_lock_enabled);
for (account_id, days) in window_changes(&before, &after) {
crate::sync::window_set(&app, &account_id, days)?;
+13
View File
@@ -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<S: Store>(
&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,9 +348,11 @@ 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);
if maintenance {
self.housekeeping(store, remote, sink, &mut status, foreground)
.await;
}
}
status.pending_writes = store.with(write::pending_writes).unwrap_or(0);
status.oldest_ms = store
+26 -9
View File
@@ -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<tauri::State<'_, Db>, 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<tokio::time::Instant> = 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,27 +401,35 @@ 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.
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);
}
}
// ---------------------------------------------------------------------------------------------
+77
View File
@@ -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<Retained<NSMenu>> {
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()) }
}
+2 -1
View File
@@ -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 <Shell />;
return <AppLockGate><Shell /></AppLockGate>;
}
export default App;
+15
View File
@@ -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<AppLockStatus> =>
isTauri
? call<AppLockStatus>("app_lock_status")
: Promise.resolve({ enabled: false, locked: false, available: false, unavailableReason: null });
export const appUnlock = () => call<void>("app_unlock");
+14
View File
@@ -0,0 +1,14 @@
import { call, isTauri } from "../ipc";
export interface OtpAutofillStatus {
available: boolean;
enabled: boolean;
}
export const otpAutofillStatus = (): Promise<OtpAutofillStatus> =>
isTauri
? call<OtpAutofillStatus>("otp_autofill_status")
: Promise.resolve({ available: false, enabled: false });
export const otpAutofillEnable = (): Promise<boolean> =>
isTauri ? call<boolean>("otp_autofill_enable") : Promise.resolve(false);
+12
View File
@@ -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<ProofIssue[]>("proof_text", { text, spelling, grammar });
+4
View File
@@ -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<DraftAttachment>("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<DraftSaved>("draft_save", { draft });
+7
View File
@@ -0,0 +1,7 @@
import { call, isMacDesktop } from "../ipc";
export const writingToolsAvailable = () =>
isMacDesktop ? call<boolean>("writing_tools_available") : Promise.resolve(false);
export const runWritingTool = (tool: "Proofread" | "Rewrite") =>
call<void>("run_writing_tool", { tool });
+5
View File
@@ -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,
+21 -6
View File
@@ -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<T>(command: string, args?: Record<string, unknown
// ---------------------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------------------
case "draft_list": {
const accountId = arg<string | null>("accountId");
return done(
empty ? [] : drafts
.filter((draft) => !accountId || draft.accountId === accountId)
.map((draft) => structuredClone(draft)),
);
}
case "draft_save": {
const draft = arg<Draft>("draft");
const id = draft.id ?? `draft-${drafts.length + 1}`;
@@ -1462,7 +1472,7 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
const thread = byKey(draft.threadKey);
if (thread) thread.hasDraft = true;
}
changed("threads");
changed("drafts threads");
const encodedSize = Math.round(draft.bodyHtml.length * 1.37) + 2_048;
return done({
id,
@@ -1486,7 +1496,7 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
const thread = gone?.threadKey ? byKey(gone.threadKey) : undefined;
if (thread) thread.hasDraft = false;
}
changed("threads");
changed("drafts threads");
return done(undefined);
}
@@ -1510,7 +1520,9 @@ export async function mockCall<T>(command: string, args?: Record<string, unknown
thread.hasDraft = false;
thread.pile = null;
}
changed("outbox threads");
const savedIndex = drafts.findIndex((saved) => 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<T>(command: string, args?: Record<string, unknown
() => {
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<string>("outgoingId");
const outgoingId = arg<string>("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);
+5
View File
@@ -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;
+63
View File
@@ -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<AppLockStatus | null>(null);
const [error, setError] = useState<string | null>(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 <div className="app" aria-busy="true" />;
return (
<div className="app app-lock">
<main className="app-lock-content">
<h1 className="app-lock-title">Mailbox locked</h1>
<p className="app-lock-description">
Use Touch ID or your Mac login password to open your mailbox.
</p>
{error || status?.unavailableReason ? (
<p className="app-lock-error" role="alert">{error ?? status?.unavailableReason}</p>
) : null}
<Button
variant="primary"
onClick={() => void (status?.available ? unlock() : readStatus())}
disabled={busy}
>
{busy ? "Waiting for macOS…" : status?.available ? "Unlock mailbox" : "Try again"}
</Button>
</main>
</div>
);
}
+48 -19
View File
@@ -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<void> {
return storeAttachments(at, persistFiles(at, files)).catch((error) => {
notify(`Could not attach files: ${error}`);
});
}
async function persistFiles(at: ComposerAt, files: File[]): Promise<void> {
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<string>((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 (
<div
@@ -314,6 +345,7 @@ function ComposeCard({ composer, expanded, onClose, onExpand }: ComposeCardProps
html={draft.bodyHtml}
label="Message"
placeholder="Write your message"
signature={signature}
onChange={(bodyHtml) => edit("card", { bodyHtml })}
/>
<Attachments at="card" files={draft.attachments ?? []} />
@@ -336,19 +368,14 @@ export function dropped(e: DragEvent<HTMLElement>, 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<HTMLElement>, 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<TiptapEditor | null>(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) => {
+224 -46
View File
@@ -1,94 +1,272 @@
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.
interface ProofIssue extends NativeProofIssue {
from: number;
to: number;
word: string;
}
const proofKey = new PluginKey<DecorationSet>("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 }: EditorProps) {
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<ProofIssue[]>([]);
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<typeof setTimeout>;
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) => (
<button type="button" className="editor-tool" title={title} aria-label={title} aria-pressed={active}
disabled={disabled} onMouseDown={(event) => event.preventDefault()} onClick={action}>{content}</button>
);
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 (
<div className="editor" data-empty={hasText(html) ? undefined : ""}>
{/* 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. */}
<span className="editor-placeholder" aria-hidden="true">
{placeholder}
</span>
<div className="editor">
{editor ? <>
<div className="editor-toolbar" role="toolbar" aria-label="Writing tools">
{tool("Bold (⌘B)", <b>B</b>, () => editor.chain().focus().toggleBold().run(), editor.isActive("bold"))}
{tool("Italic (⌘I)", <i>I</i>, () => editor.chain().focus().toggleItalic().run(), editor.isActive("italic"))}
{tool("Underline (⌘U)", <u>U</u>, () => editor.chain().focus().toggleUnderline().run(), editor.isActive("underline"))}
{tool("Strikethrough", <s>S</s>, () => editor.chain().focus().toggleStrike().run(), editor.isActive("strike"))}
<span className="editor-tool-separator" />
{tool("Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3 6h.01M3 12h.01M3 18h.01" />, () => editor.chain().focus().toggleBulletList().run(), editor.isActive("bulletList"))}
{tool("Numbered list", <Icon d="M9 6h12M9 12h12M9 18h12M3 4h1v4M3 11c3-2 3 1 0 3h3M3 17h3l-2 2 2 1H3" />, () => editor.chain().focus().toggleOrderedList().run(), editor.isActive("orderedList"))}
{tool("Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />, () => editor.chain().focus().toggleBlockquote().run(), editor.isActive("blockquote"))}
{tool("Link (⌘K)", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />, openLink, editor.isActive("link") || linkOpen)}
{tool("Clear formatting", <Icon d="M4 4h14M11 4l-4 15M14 14l6 6M20 14l-6 6" />, () => editor.chain().focus().unsetAllMarks().clearNodes().run())}
<span className="editor-tool-separator" />
{tool("Undo (⌘Z)", <Icon d={icons.REPLY} />, () => editor.chain().focus().undo().run(), false, !editor.can().undo())}
{tool("Redo (⌘⇧Z)", <Icon d={icons.FORWARD} />, () => editor.chain().focus().redo().run(), false, !editor.can().redo())}
{signature?.trim() ? tool("Insert signature", <Icon d={icons.PEN} />, insertSignature) : null}
{writingTools && appleToolsAvailable ? <>
{tool("Proofread with Apple Writing Tools", "Proofread", () => runAppleTool("Proofread"), false, !hasText(html))}
{tool("Rewrite with Apple Writing Tools", "Rewrite", () => runAppleTool("Rewrite"), false, !hasText(html))}
</> : null}
{isMacDesktop && (spelling || grammar) ? tool(`Spelling and grammar${issues.length ? ` (${issues.length})` : ""}`, <><Icon d="M3 17 8 5l5 12M5 12h6M14 14l3 3 5-6" />{issues.length ? <span>{issues.length}</span> : null}</>, () => { setProofOpen(!proofOpen); setLinkOpen(false); }, proofOpen) : null}
</div>
{writingTools && writingToolsError ? <p className="editor-writing-error" role="alert">{writingToolsError}</p> : null}
{linkOpen ? <form className="editor-link" onSubmit={(event) => { event.preventDefault(); applyLink(); }}>
<input aria-label="Link URL" value={linkValue} placeholder="https://" autoFocus spellCheck={false} onChange={(event) => setLinkValue(event.target.value)} />
<button type="submit">Apply</button>
{editor.isActive("link") ? <button type="button" onClick={() => { editor.chain().focus().extendMarkRange("link").unsetLink().run(); setLinkOpen(false); }}>Remove</button> : null}
<button type="button" aria-label="Close link editor" onClick={() => { setLinkOpen(false); editor.commands.focus(); }}><Icon d={icons.CLOSE} /></button>
{linkError ? <span role="alert">{linkError}</span> : null}
</form> : null}
{proofOpen ? <div className="editor-proof-panel" aria-label="Spelling and grammar suggestions">
{proofError ? <p role="alert">Writing check unavailable: {proofError}</p> : issues.length ? issues.map((issue, index) => <div className="editor-proof-issue" key={`${issue.from}:${issue.to}:${index}`}>
<button type="button" className="editor-proof-word" onClick={() => editor.chain().focus().setTextSelection({ from: issue.from, to: issue.to }).run()}>{issue.word}</button>
<span>{issue.message}</span>
{issue.suggestions.map((suggestion, suggestionIndex) => <button type="button" key={suggestionIndex} onClick={() => {
const range = { from: issue.from, to: issue.to };
if (suggestion) editor.chain().focus().insertContentAt(range, { type: "text", text: suggestion }).run();
else editor.chain().focus().deleteRange(range).run();
}}>{suggestion || "Delete"}</button>)}
</div>) : <p>No suggestions. Checks use your Mac’s available languages.</p>}
</div> : null}
</> : null}
<div className="editor-content" data-empty={hasText(html) ? undefined : ""}>
<span className="editor-placeholder" aria-hidden="true">{placeholder}</span>
<EditorContent editor={editor} />
</div>
</div>
);
}
+3 -2
View File
@@ -92,7 +92,7 @@ export function FocusReply() {
setAt((was) => Math.min(Math.max(was + delta, 0), Math.max(items.length - 1, 0)));
/** One item's reply, down the pipeline the other two composers use. */
const post = (thread: ThreadSummary, now: boolean) => {
const post = async (thread: ThreadSummary, now: boolean) => {
const view = views[thread.key];
const last = view?.messages.at(-1);
const body = (drafts[thread.key] ?? "").trim();
@@ -108,7 +108,8 @@ export function FocusReply() {
.join(""),
});
const to = useCompose.getState().reply?.draft.to[0];
void compose.post("reply", now);
await compose.post("reply", now);
if (useCompose.getState().reply) return;
setSent((was) => ({ ...was, [thread.key]: to ? displayName(to) : displayName(thread.from) }));
step(1);
};
+73
View File
@@ -16,6 +16,7 @@ import { accountRemove, accountSetColor, accountSetName } from "../api/accounts"
import { backupConfigure, backupNow, backupPhrase, backupRestore } from "../api/backup";
import { contactUpdate, contactsList } from "../api/contacts";
import { imapServers } from "../api/imap";
import { otpAutofillEnable, otpAutofillStatus, type OtpAutofillStatus } from "../api/otpAutofill";
import {
askForNotifications,
notifyPermission,
@@ -41,6 +42,7 @@ import {
REQUIRED_SCOPE,
SCOPES,
isDesktop,
isMacDesktop,
isTauri,
type Account,
type BackupSettings,
@@ -1401,6 +1403,20 @@ function PrivacySection() {
const save = useSettings((s) => s.save);
const [allowed, setAllowed] = useState<ContactCard[]>([]);
const [phase, setPhase] = useState<"loading" | "idle" | "error">("loading");
const [otpStatus, setOtpStatus] = useState<OtpAutofillStatus | null>(null);
const [otpBusy, setOtpBusy] = useState(false);
useEffect(() => {
let alive = true;
void otpAutofillStatus().then((status) => {
if (alive) setOtpStatus(status);
}).catch((error) => {
if (!alive) return;
setOtpStatus({ available: false, enabled: false });
notify(`Could not check Email OTP AutoFill: ${error}`);
});
return () => { alive = false; };
}, []);
useEffect(() => {
let alive = true;
@@ -1420,6 +1436,25 @@ function PrivacySection() {
if (!settings) return <h2 className="settings-title">Privacy</h2>;
const changeOtpAutofill = async (enabled: boolean) => {
setOtpBusy(true);
try {
if (enabled && !otpStatus?.enabled) {
const activated = await otpAutofillEnable();
if (!activated) {
notify("Email OTP AutoFill was not enabled in macOS.");
return;
}
setOtpStatus({ available: true, enabled: true });
}
await save({ otpAutofillEnabled: enabled });
} catch (error) {
notify(`Could not enable Email OTP AutoFill: ${error}`);
} finally {
setOtpBusy(false);
}
};
const forget = (card: ContactCard) => {
setAllowed((were) => were.filter((one) => one !== card));
void contactUpdate(card.accountId, card.person.address, { allowRemoteImages: false }).catch(
@@ -1438,6 +1473,26 @@ function PrivacySection() {
decided somewhere else.
</p>
<div className="set-field">
<Toggle checked={settings.otpAutofillEnabled && (otpStatus?.enabled ?? false)}
label="Email OTP AutoFill"
disabled={!otpStatus?.available || otpBusy}
note="Suggest recent email verification codes in compatible website and app fields through macOS AutoFill. Codes expire after three minutes. Detection stays on this device."
onChange={(enabled) => void changeOtpAutofill(enabled)} />
<p className="settings-note">
{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."}
</p>
</div>
<div className="set-field">
<Toggle checked={settings.appLockEnabled} label="Require authentication when opening"
disabled={!isMacDesktop}
note="On macOS, unlock with Touch ID or your Mac login password when starting the app."
onChange={(appLockEnabled) => void save({ appLockEnabled })} />
</div>
<SettingRow
label="Remote images"
note="Loading a remote image tells the server that hosts it that you opened the message, from your IP address, at that moment. Nothing else in the app reveals that."
@@ -1709,6 +1764,24 @@ function WritingSection() {
What happens when you send, and what goes out under your name.
</p>
<div className="set-field">
<Toggle checked={settings.spellingEnabled} label="Check spelling"
note="Use local spelling suggestions while writing."
onChange={(spellingEnabled) => void save({ spellingEnabled })} />
</div>
<div className="set-field">
<Toggle checked={settings.grammarEnabled} label="Check grammar"
disabled={!isMacDesktop}
note="Use macOS grammar suggestions while writing."
onChange={(grammarEnabled) => void save({ grammarEnabled })} />
</div>
<div className="set-field">
<Toggle checked={settings.writingToolsEnabled} label="Apple Writing Tools"
disabled={!isMacDesktop}
note="Show Proofread and Rewrite actions. Requires Apple Intelligence enabled on a supported Mac."
onChange={(writingToolsEnabled) => void save({ writingToolsEnabled })} />
</div>
<SettingRow
label="Undo delay"
note="How long a send waits with its toast up before it actually goes. z takes it back."
+27
View File
@@ -0,0 +1,27 @@
.app-lock {
align-items: center;
justify-content: center;
}
.app-lock-content {
padding: 24px;
max-width: 30em;
text-align: center;
}
.app-lock-title {
margin: 0;
font-family: var(--font-heading);
font-size: var(--t-5);
font-weight: 500;
}
.app-lock-description {
margin: 12px 0 24px;
color: var(--ink-soft);
}
.app-lock-error {
margin: 0 0 20px;
color: var(--ink-soft);
}
+22 -1
View File
@@ -78,6 +78,27 @@
pointer-events: none;
}
.editor:not([data-empty]) .editor-placeholder {
.editor-content:not([data-empty]) .editor-placeholder {
display: none;
}
.editor-content { position: relative; min-height: 90px; }
.editor-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; margin: 0 0 12px; padding: 0 0 8px; border-bottom: 1px solid var(--line); }
.editor-tool { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 28px; height: 28px; padding: 4px; border-radius: var(--r-sm); color: var(--ink-soft); font-family: var(--font-ui); font-size: var(--t-3); }
.editor-tool:hover, .editor-tool[aria-pressed="true"] { background: var(--accent-wash); color: var(--ink); }
.editor-tool:focus-visible { outline: 2px solid var(--ink-soft); outline-offset: 2px; }
.editor-tool:disabled { opacity: 0.35; cursor: default; }
.editor-tool-separator { width: 1px; height: 16px; background: var(--line); margin: 0 4px; }
.editor-link { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 8px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: var(--r-sm); }
.editor-link input { flex: 1; min-width: 120px; outline: none; font-size: var(--t-3); }
.editor-link button, .editor-proof-issue button { padding: 4px 6px; border-radius: var(--r-sm); font-size: var(--t-2); color: var(--ink); }
.editor-link button:hover, .editor-proof-issue button:hover { background: var(--accent-wash); }
.editor-link [role="alert"] { flex-basis: 100%; color: var(--ink-soft); font-size: var(--t-2); }
.editor-proof-spelling { text-decoration: underline wavy #c96c62; text-underline-offset: 3px; }
.editor-proof-grammar { text-decoration: underline wavy #699cc6; text-underline-offset: 3px; }
.editor-proof-panel { max-height: 180px; overflow-y: auto; padding: 8px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: var(--r-sm); color: var(--ink-soft); font-size: var(--t-2); }
.editor-proof-panel p { margin: 0; }
.editor-proof-issue { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 4px 0; }
.editor-proof-issue > span { flex-basis: 100%; }
.editor-proof-word { font-weight: 600; }
.editor-writing-error { margin: 0 0 12px; color: var(--ink-soft); font-size: var(--t-2); }
+5
View File
@@ -0,0 +1,5 @@
export function signatureHtml(signature: string): string {
if (/<\/?[a-z][^>]*>/i.test(signature)) return signature;
return signature.split("\n").map((line) =>
`<p>${line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</p>`).join("");
}
+14 -16
View File
@@ -14,6 +14,7 @@ import { useAccounts } from "./useAccounts";
import { useMail } from "./useMail";
import { useSettings } from "./useSettings";
import { notify, useToast } from "./useToast";
import { signatureHtml } from "../signature";
/**
* Everything being written, and the one send that has not gone yet.
@@ -195,7 +196,7 @@ function sendingAccount(): string | null {
function signatureFor(accountId: string): string {
const settings = useSettings.getState().settings;
const signature = settings?.accounts.find((a) => a.accountId === accountId)?.signature ?? "";
return signature ? `<p></p><p>${signature}</p>` : "<p></p>";
return signature ? `<p></p>${signatureHtml(signature)}` : "<p></p>";
}
/** The people a reply goes to, with you and the sender taken out of the extra ones. */
@@ -224,6 +225,14 @@ const escapeHtml = (text: string): string =>
const saveTimers: Record<ComposerAt, number> = { card: 0, reply: 0 };
const saveRequests: Partial<Record<ComposerAt, Promise<void>>> = {};
const attachmentRequests: Record<ComposerAt, Set<Promise<void>>> = { card: new Set(), reply: new Set() };
export function storeAttachments(at: ComposerAt, work: Promise<void>): Promise<void> {
attachmentRequests[at].add(work);
const clear = () => attachmentRequests[at].delete(work);
void work.then(clear, clear);
return work;
}
function scheduleSave(at: ComposerAt): void {
if (typeof window === "undefined") return;
@@ -277,19 +286,6 @@ function startBeat(): void {
beat = window.setInterval(tick, 1000);
}
/**
* The row this send just went out on, found in the outbox.
*
* `send` answers with an `Undo` and not with the `Outgoing` it queued, so the only handle on the
* queued message is the outbox itself. The one that is holding the longest is the one that was
* queued last, because every hold is the same length from the moment it was made.
*/
async function newestOutgoing(): Promise<string | null> {
const outbox = await outboxList();
if (outbox.length === 0) return null;
return outbox.reduce((latest, item) => (item.holdUntilMs > latest.holdUntilMs ? item : latest)).id;
}
export const useCompose = create<ComposeState>((set, get) => ({
drafts: [],
loadDrafts: async () => {
@@ -366,6 +362,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
closeCard: async () => {
try { await Promise.all(attachmentRequests.card); } catch { return; }
await get().save("card");
const card = get().card;
if (!card || card.phase === "error") return;
@@ -466,6 +463,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
}),
closeReply: async () => {
try { await Promise.all(attachmentRequests.reply); } catch { return; }
await get().save("reply");
const reply = get().reply;
if (!reply || reply.phase === "error") return;
@@ -601,6 +599,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
post: async (at, now) => {
try { await Promise.all(attachmentRequests[at]); } catch { return; }
cancelSave(at);
await saveRequests[at];
const composer = composerAt(get(), at);
@@ -647,8 +646,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
// from here until the provider answers, and that can be twenty seconds.
notify(`Sending to ${named(draft.to[0])}`);
try {
const id = await newestOutgoing();
if (id) await sendNow(id);
await sendNow(undo.token);
} catch (e) {
notify(`Could not skip the wait: ${e}`);
void useMail.getState().load();