mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-02 11:07:06 +00:00
build the app, and ship it on Linux and Windows as well as macOS
The client itself: the sync engine and mirror, the Gmail and IMAP providers, the screener, the three boxes and two piles, compose and send, the sanitiser and tracker stripping, contacts, clips, backup, notifications and the guide. The pipeline that ships it. Three runners now build in parallel and the publish gate wants all four platform keys in latest.json before a release leaves draft. Linux gets two more packages Tauri does not build: a flatpak repackaged from the deb against GNOME 48, and a Nix package that relinks the deb against nixpkgs so it runs as a native Wayland client rather than through Xwayland. CI runs the Rust suite on all three desktops rather than on Linux alone, and rebuilds the flatpak manifest on every push to main. Two things that were only ever exercised on macOS and were wrong everywhere else. The menu bar was built by adjusting the submenus macOS is given, so on a platform whose default menu has no File or View the new File menu landed after Edit, Check for Updates landed nowhere, and the three places and the reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri already emits. The updater key exists now, so releases can sign their artifacts.
This commit is contained in:
1 parent
088ec9c6e4
commit
eb59cb5f8d
423 files changed
+93217
-251
No files matched your search
Generated
+547
-15
@@ -102,18 +102,68 @@ version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.2",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener 2.5.3",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -152,6 +202,29 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-imap"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a6728e0f7931b36d725ac234fcb02539e9f7888dbeaaa8a18d9ea5792181570"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-compression",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"imap-proto",
|
||||
"log",
|
||||
"nom",
|
||||
"pin-project",
|
||||
"pin-utils",
|
||||
"self_cell",
|
||||
"stop-token",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -176,7 +249,7 @@ version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.2",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -187,14 +260,14 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-channel 2.5.0",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"event-listener 5.4.2",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
@@ -315,6 +388,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -415,7 +494,7 @@ version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-channel 2.5.0",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
@@ -804,6 +883,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.16"
|
||||
@@ -989,6 +1074,12 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
@@ -1031,6 +1122,20 @@ dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1324,6 +1429,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.2"
|
||||
@@ -1340,7 +1451,7 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.2",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -1426,7 +1537,7 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
"roxmltree 0.20.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1484,6 +1595,21 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -1559,6 +1685,7 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1980,6 +2107,76 @@ dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-net"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"futures-channel",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hickory-proto",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni 0.22.4",
|
||||
"rand 0.10.2",
|
||||
"thiserror 2.0.20",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni 0.22.4",
|
||||
"once_cell",
|
||||
"prefix-trie",
|
||||
"rand 0.10.2",
|
||||
"ring",
|
||||
"thiserror 2.0.20",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"hickory-net",
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"ipnet",
|
||||
"jni 0.22.4",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.10.2",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"system-configuration",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2254,6 +2451,15 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imap-proto"
|
||||
version = "0.16.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f6af35c6a517aea5c72314abe90134980d2ae6a763809b50c208b3e429d71f"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2295,11 +2501,27 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2",
|
||||
"widestring",
|
||||
"windows-registry 0.6.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
@@ -2418,6 +2640,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2500,6 +2752,12 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -2644,6 +2902,24 @@ dependencies = [
|
||||
"hashify",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-send"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8355326d14f08c9de5e9badfe606d9ba8fea2febb60fe4318ecbac33cfb7b41e"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"gethostname",
|
||||
"md5",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier 0.7.0",
|
||||
"smtp-proto",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
@@ -2656,6 +2932,7 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"argon2",
|
||||
"async-imap",
|
||||
"base64 0.22.1",
|
||||
"bip39",
|
||||
"block2",
|
||||
@@ -2663,14 +2940,21 @@ dependencies = [
|
||||
"chrono",
|
||||
"css-inline",
|
||||
"fontdb",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"mail-builder",
|
||||
"mail-parser",
|
||||
"mail-send",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"objc2-user-notifications",
|
||||
"rand 0.8.8",
|
||||
"reqwest 0.13.1",
|
||||
"roxmltree 0.21.1",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -2684,7 +2968,11 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"webpki-roots",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2709,6 +2997,12 @@ dependencies = [
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -2739,6 +3033,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
@@ -2776,6 +3076,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"equivalent",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.3"
|
||||
@@ -2812,6 +3129,12 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -2839,6 +3162,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.0"
|
||||
@@ -2853,12 +3186,31 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3080,6 +3432,8 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
@@ -3098,11 +3452,24 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
@@ -3311,12 +3678,38 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
@@ -3442,6 +3835,17 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prefix-trie"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
|
||||
dependencies = [
|
||||
"either",
|
||||
"ipnet",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -3835,7 +4239,7 @@ dependencies = [
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"rustls-platform-verifier 0.6.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
@@ -3853,6 +4257,12 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3873,6 +4283,15 @@ version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
@@ -3923,6 +4342,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -3943,6 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3981,7 +4410,28 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -4160,6 +4610,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -4374,6 +4830,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -4401,6 +4873,12 @@ version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
|
||||
[[package]]
|
||||
name = "smtp-proto"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e365d0fff6ae5036364ab62f236fa669ce05acbd5878df32d3755d48cfc1491b"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
@@ -4477,6 +4955,18 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stop-token"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b"
|
||||
dependencies = [
|
||||
"async-channel 1.9.0",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -4619,6 +5109,12 @@ dependencies = [
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.3"
|
||||
@@ -4637,7 +5133,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -4704,7 +5200,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -4949,7 +5445,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -4972,7 +5468,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -5187,9 +5683,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5208,6 +5716,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
@@ -5857,6 +6366,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -6377,7 +6892,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -6424,6 +6939,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -6473,7 +7005,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"event-listener 5.4.2",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
|
||||
+63
-1
@@ -30,7 +30,7 @@ sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
url = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tokio = { version = "1", features = ["sync", "time"] }
|
||||
tokio = { version = "1", features = ["sync", "time", "net", "io-util", "rt"] }
|
||||
|
||||
# gzip because Gmail's JSON compresses by an order of magnitude and hydration is thousands of
|
||||
# responses; http2 because a batch of 50 and the poll loop share one connection.
|
||||
@@ -69,6 +69,68 @@ bip39 = { version = "2.2", features = ["rand"] }
|
||||
# The system families the Appearance section offers alongside the six bundled ones.
|
||||
fontdb = "0.24"
|
||||
|
||||
# The second provider: a mailbox reached over IMAP and SMTP with a password, which is every
|
||||
# account that is not Google. Proton is one of these, through Bridge on the loopback.
|
||||
#
|
||||
# `async-imap` defaults to async-std, so both its default features are off and the tokio pair is
|
||||
# named instead: there is one runtime in this process and it is Tauri's.
|
||||
async-imap = { version = "0.11", default-features = false, features = ["runtime-tokio", "tokio"] }
|
||||
# `async-imap` speaks futures-io and `tokio-rustls` speaks tokio-io, so the stream is bridged.
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
# `async-imap` returns its responses as futures streams, so the combinators come with it.
|
||||
futures = "0.3"
|
||||
|
||||
# TLS for both, and the seam where a self-signed certificate is decided about.
|
||||
#
|
||||
# Every `ClientConfig` in this crate must name `rustls::crypto::ring::default_provider()`
|
||||
# explicitly. Both crypto providers are in the tree because of what reqwest pulls, so rustls has
|
||||
# no process default to fall back on and building a config without one panics at runtime rather
|
||||
# than failing to compile.
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
rustls-pki-types = "1"
|
||||
webpki-roots = "1"
|
||||
# The fingerprint shown in the certificate question, and the hostname check that decides whether
|
||||
# there is a question at all rather than a refusal.
|
||||
x509-parser = "0.18"
|
||||
|
||||
# Sending. Same author as mail-parser and mail-builder, and pinned to ring for the same reason as
|
||||
# above. No `dkim`: nothing here signs outgoing mail.
|
||||
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12", "cram-md5", "digest-md5", "md5", "rand"] }
|
||||
|
||||
# Autodiscovery. `hickory-resolver` is for the MX rung, which is what recognises a vanity domain
|
||||
# sitting on Google Workspace or Fastmail; `roxmltree` reads the clientConfig documents, which are
|
||||
# small, read-once and never written.
|
||||
hickory-resolver = { version = "0.26", default-features = false, features = ["system-config", "tokio"] }
|
||||
roxmltree = "0.21"
|
||||
|
||||
# Notifications on macOS are posted through UserNotifications directly (notify/macos.rs says why the
|
||||
# plugin's own path shows nothing on macOS 26). These are the versions wry already resolves, so the
|
||||
# build keeps a single copy of objc2.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = [
|
||||
"std",
|
||||
"NSDictionary",
|
||||
"NSError",
|
||||
|
||||
"NSObject",
|
||||
"NSString",
|
||||
] }
|
||||
objc2-user-notifications = { version = "0.3", default-features = false, features = [
|
||||
"std",
|
||||
"block2",
|
||||
"UNUserNotificationCenter",
|
||||
"UNNotification",
|
||||
"UNNotificationContent",
|
||||
"UNNotificationRequest",
|
||||
"UNNotificationResponse",
|
||||
"UNNotificationSettings",
|
||||
"UNNotificationSound",
|
||||
"UNNotificationTrigger",
|
||||
] }
|
||||
block2 = "0.6"
|
||||
|
||||
# There is no auto-updater and no process to restart on a phone: the store is the update channel.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 18:41:07 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Completed: Studio lease 2026
|
||||
From: DocuSign NA3 System <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
X-Mailer: Microsoft Outlook 16.0
|
||||
Content-Type: multipart/mixed; boundary="_004_DM6PR11MB4491_"
|
||||
|
||||
--_004_DM6PR11MB4491_
|
||||
Content-Type: text/plain; charset="us-ascii"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
All parties have completed the envelope. You can access the signed copy
|
||||
from the link below or from the attached PDF.
|
||||
|
||||
--_004_DM6PR11MB4491_
|
||||
Content-Type: application/pdf; name="=?utf-8?Q?Studio_lease_2026_=28sign=C3=A9=29.pdf?="
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="=?utf-8?Q?Studio_lease_2026_=28sign=C3=A9=29.pdf?="
|
||||
|
||||
JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBv
|
||||
Ymo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlw
|
||||
ZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNTk1IDg0Ml0+PmVuZG9iagp0cmFpbGVy
|
||||
PDwvUm9vdCAxIDAgUj4+CiUlRU9GCg==
|
||||
|
||||
--_004_DM6PR11MB4491_--
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="MessagingEngine">
|
||||
<domain>fastmail.com</domain>
|
||||
<displayName>Fastmail</displayName>
|
||||
<displayShortName>Fastmail</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.fastmail.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>OAuth2</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="pop3">
|
||||
<hostname>pop.fastmail.com</hostname>
|
||||
<port>995</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>OAuth2</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.fastmail.com</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>OAuth2</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<instruction url="https://www.fastmail.help/hc/en-us/articles/1500000278342">
|
||||
<descr lang="en">Server Names and Ports</descr>
|
||||
</instruction>
|
||||
<instruction url="https://www.fastmail.help/hc/en-us/articles/360058753054">
|
||||
<descr lang="en">Using Fastmail with Mozilla Thunderbird</descr>
|
||||
</instruction>
|
||||
|
||||
</emailProvider>
|
||||
<webMail>
|
||||
<loginPage url="https://app.fastmail.com/login/?domain=%EMAILDOMAIN%" />
|
||||
</webMail>
|
||||
</clientConfig>
|
||||
@@ -0,0 +1,77 @@
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="googlemail.com">
|
||||
<domain>gmail.com</domain>
|
||||
<domain>googlemail.com</domain>
|
||||
<!-- MX, for Google Apps -->
|
||||
<domain>google.com</domain>
|
||||
<domain>jazztel.es</domain>
|
||||
<domain>nyu.edu</domain>
|
||||
|
||||
<displayName>Google Mail</displayName>
|
||||
<displayShortName>GMail</displayShortName>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.gmail.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
<incomingServer type="pop3">
|
||||
<hostname>pop.gmail.com</hostname>
|
||||
<port>995</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<pop3>
|
||||
<leaveMessagesOnServer>true</leaveMessagesOnServer>
|
||||
</pop3>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.gmail.com</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>OAuth2</authentication>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</outgoingServer>
|
||||
|
||||
<documentation url="http://mail.google.com/support/bin/answer.py?answer=13273">
|
||||
<descr>How to enable IMAP/POP3 in GMail</descr>
|
||||
</documentation>
|
||||
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12806">
|
||||
<descr>How to configure email clients for IMAP</descr>
|
||||
</documentation>
|
||||
<documentation url="http://mail.google.com/support/bin/topic.py?topic=12805">
|
||||
<descr>How to configure email clients for POP3</descr>
|
||||
</documentation>
|
||||
<documentation url="http://mail.google.com/support/bin/answer.py?answer=86399">
|
||||
<descr>How to configure TB 2.0 for POP3</descr>
|
||||
</documentation>
|
||||
</emailProvider>
|
||||
|
||||
<oAuth2>
|
||||
<issuer>accounts.google.com</issuer>
|
||||
<!-- https://developers.google.com/identity/protocols/oauth2/scopes -->
|
||||
<scope>https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/carddav</scope>
|
||||
<authURL>https://accounts.google.com/o/oauth2/auth</authURL>
|
||||
<tokenURL>https://www.googleapis.com/oauth2/v3/token</tokenURL>
|
||||
</oAuth2>
|
||||
|
||||
<enable visiturl="https://mail.google.com/mail/?ui=2&shva=1#settings/fwdandpop">
|
||||
<instruction>You need to enable IMAP access</instruction>
|
||||
</enable>
|
||||
|
||||
<webMail>
|
||||
<loginPage url="https://accounts.google.com/ServiceLogin?service=mail&continue=http://mail.google.com/mail/"/>
|
||||
<loginPageInfo url="https://accounts.google.com/ServiceLogin?service=mail&continue=http://mail.google.com/mail/">
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<usernameField id="Email"/>
|
||||
<passwordField id="Passwd"/>
|
||||
<loginButton id="signIn"/>
|
||||
</loginPageInfo>
|
||||
</webMail>
|
||||
|
||||
</clientConfig>
|
||||
@@ -0,0 +1,99 @@
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="posteo.de">
|
||||
<domain>posteo.de</domain>
|
||||
<domain>posteo.at</domain>
|
||||
<domain>posteo.be</domain>
|
||||
<domain>posteo.ca</domain>
|
||||
<domain>posteo.ch</domain>
|
||||
<domain>posteo.cl</domain>
|
||||
<domain>posteo.co</domain>
|
||||
<domain>posteo.co.uk</domain>
|
||||
<domain>posteo.com</domain>
|
||||
<domain>posteo.com.br</domain>
|
||||
<domain>posteo.cr</domain>
|
||||
<domain>posteo.cz</domain>
|
||||
<domain>posteo.dk</domain>
|
||||
<domain>posteo.ee</domain>
|
||||
<domain>posteo.es</domain>
|
||||
<domain>posteo.eu</domain>
|
||||
<domain>posteo.fi</domain>
|
||||
<domain>posteo.gl</domain>
|
||||
<domain>posteo.gr</domain>
|
||||
<domain>posteo.hn</domain>
|
||||
<domain>posteo.hr</domain>
|
||||
<domain>posteo.hu</domain>
|
||||
<domain>posteo.ie</domain>
|
||||
<domain>posteo.in</domain>
|
||||
<domain>posteo.is</domain>
|
||||
<domain>posteo.it</domain>
|
||||
<domain>posteo.jp</domain>
|
||||
<domain>posteo.la</domain>
|
||||
<domain>posteo.li</domain>
|
||||
<domain>posteo.lt</domain>
|
||||
<domain>posteo.lu</domain>
|
||||
<domain>posteo.me</domain>
|
||||
<domain>posteo.mx</domain>
|
||||
<domain>posteo.my</domain>
|
||||
<domain>posteo.net</domain>
|
||||
<domain>posteo.nl</domain>
|
||||
<domain>posteo.no</domain>
|
||||
<domain>posteo.nz</domain>
|
||||
<domain>posteo.org</domain>
|
||||
<domain>posteo.pe</domain>
|
||||
<domain>posteo.pl</domain>
|
||||
<domain>posteo.pm</domain>
|
||||
<domain>posteo.pt</domain>
|
||||
<domain>posteo.ro</domain>
|
||||
<domain>posteo.se</domain>
|
||||
<domain>posteo.sg</domain>
|
||||
<domain>posteo.si</domain>
|
||||
<domain>posteo.tn</domain>
|
||||
<domain>posteo.uk</domain>
|
||||
<domain>posteo.us</domain>
|
||||
<displayName>Posteo</displayName>
|
||||
<displayShortName>Posteo</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>posteo.de</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
<incomingServer type="imap">
|
||||
<hostname>posteo.de</hostname>
|
||||
<port>143</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
<incomingServer type="pop3">
|
||||
<hostname>posteo.de</hostname>
|
||||
<port>995</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
<incomingServer type="pop3">
|
||||
<hostname>posteo.de</hostname>
|
||||
<port>110</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>posteo.de</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</outgoingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>posteo.de</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</outgoingServer>
|
||||
<documentation url="https://posteo.de/hilfe/wie-richte-ich-posteo-in-einem-mailprogramm-ein-pop3-imap-und-smtp"/>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="protonmail.com">
|
||||
<domain>protonmail.com</domain>
|
||||
<displayName>ProtonMail</displayName>
|
||||
<displayShortName>ProtonMail</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>127.0.0.1</hostname>
|
||||
<port>1143</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>127.0.0.1</hostname>
|
||||
<port>1025</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<documentation url="https://protonmail.com/support">
|
||||
<descr lang="en">Support page</descr>
|
||||
</documentation>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
@@ -0,0 +1,106 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 15:40:11 +0530
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Invitation: Piano lesson: Cooper @ Wed 9 Sep 2026 17:00 - 17:45 (IST)
|
||||
([email protected])
|
||||
From: Sam Okafor <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/mixed; boundary="mixed-piano-0001"
|
||||
|
||||
--mixed-piano-0001
|
||||
Content-Type: multipart/alternative; boundary="alt-piano-0001"
|
||||
|
||||
--alt-piano-0001
|
||||
Content-Type: text/plain; charset=UTF-8; format=flowed; delsp=yes
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
You have been invited to the following event.
|
||||
|
||||
Piano lesson: Cooper
|
||||
When: Wed 9 Sep 2026 17:00 to 17:45 India Standard Time
|
||||
Where: Sunny Day Music, Bandra
|
||||
Who: Sam Okafor, Priyanshu Jain
|
||||
|
||||
--alt-piano-0001
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html><body><div style=3D"padding:16px">
|
||||
<p>You have been invited to the following event.</p>
|
||||
<h3>Piano lesson: Cooper</h3>
|
||||
<p><b>When</b> Wed 9 Sep 2026 17:00 to 17:45 India Standard Time<br>
|
||||
<b>Where</b> Sunny Day Music, Bandra<br>
|
||||
<b>Who</b> Sam Okafor, Priyanshu Jain</p>
|
||||
</div></body></html>
|
||||
|
||||
--alt-piano-0001
|
||||
Content-Type: text/calendar; charset=UTF-8; method=REQUEST
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:REQUEST
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Asia/Kolkata
|
||||
X-LIC-LOCATION:Asia/Kolkata
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0530
|
||||
TZOFFSETTO:+0530
|
||||
TZNAME:IST
|
||||
DTSTART:19700101T000000
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Asia/Kolkata:20260909T170000
|
||||
DTEND;TZID=Asia/Kolkata:20260909T174500
|
||||
DTSTAMP:20260902T101500Z
|
||||
ORGANIZER;CN=Sunny Day Music:mailto:[email protected]
|
||||
UID:[email protected]
|
||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=T
|
||||
RUE;CN=Priyanshu Jain;X-NUM-GUESTS=0:mailto:[email protected]
|
||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Sam Oka
|
||||
for;X-NUM-GUESTS=0:mailto:[email protected]
|
||||
CREATED:20260902T101500Z
|
||||
DESCRIPTION:First lesson for Cooper. Nothing to prepare\, and there is a pia
|
||||
no here\, so nothing to carry either.
|
||||
LAST-MODIFIED:20260902T101500Z
|
||||
LOCATION:Sunny Day Music\, Bandra
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Piano lesson: Cooper
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
--alt-piano-0001--
|
||||
|
||||
--mixed-piano-0001
|
||||
Content-Type: application/ics; name="invite.ics"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="invite.ics"
|
||||
|
||||
QkVHSU46VkNBTEVOREFSDQpQUk9ESUQ6LS8vR29vZ2xlIEluYy8vR29vZ2xlIENhbGVuZGFyIDcw
|
||||
LjkwNTQvL0VODQpWRVJTSU9OOjIuMA0KQ0FMU0NBTEU6R1JFR09SSUFODQpNRVRIT0Q6UkVRVUVT
|
||||
VA0KQkVHSU46VlRJTUVaT05FDQpUWklEOkFzaWEvS29sa2F0YQ0KWC1MSUMtTE9DQVRJT046QXNp
|
||||
YS9Lb2xrYXRhDQpCRUdJTjpTVEFOREFSRA0KVFpPRkZTRVRGUk9NOiswNTMwDQpUWk9GRlNFVFRP
|
||||
OiswNTMwDQpUWk5BTUU6SVNUDQpEVFNUQVJUOjE5NzAwMTAxVDAwMDAwMA0KRU5EOlNUQU5EQVJE
|
||||
DQpFTkQ6VlRJTUVaT05FDQpCRUdJTjpWRVZFTlQNCkRUU1RBUlQ7VFpJRD1Bc2lhL0tvbGthdGE6
|
||||
MjAyNjA5MDlUMTcwMDAwDQpEVEVORDtUWklEPUFzaWEvS29sa2F0YToyMDI2MDkwOVQxNzQ1MDAN
|
||||
CkRUU1RBTVA6MjAyNjA5MDJUMTAxNTAwWg0KT1JHQU5JWkVSO0NOPVN1bm55IERheSBNdXNpYzpt
|
||||
YWlsdG86bGVzc29uc0BzdW5ueWRheW11c2ljLmV4YW1wbGUNClVJRDo0YzliMmY3YS1waWFuby1j
|
||||
b29wZXJAc3VubnlkYXltdXNpYy5leGFtcGxlDQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJRFVBTDtS
|
||||
T0xFPVJFUS1QQVJUSUNJUEFOVDtQQVJUU1RBVD1ORUVEUy1BQ1RJT047UlNWUD1UDQogUlVFO0NO
|
||||
PVByaXlhbnNodSBKYWluO1gtTlVNLUdVRVNUUz0wOm1haWx0bzpwakA3M2FpLm9yZw0KQVRURU5E
|
||||
RUU7Q1VUWVBFPUlORElWSURVQUw7Uk9MRT1SRVEtUEFSVElDSVBBTlQ7UEFSVFNUQVQ9QUNDRVBU
|
||||
RUQ7Q049U2FtIE9rYQ0KIGZvcjtYLU5VTS1HVUVTVFM9MDptYWlsdG86c2FtQHN1bm55ZGF5bXVz
|
||||
aWMuZXhhbXBsZQ0KQ1JFQVRFRDoyMDI2MDkwMlQxMDE1MDBaDQpERVNDUklQVElPTjpGaXJzdCBs
|
||||
ZXNzb24gZm9yIENvb3Blci4gTm90aGluZyB0byBwcmVwYXJlXCwgYW5kIHRoZXJlIGlzIGEgcGlh
|
||||
DQogbm8gaGVyZVwsIHNvIG5vdGhpbmcgdG8gY2FycnkgZWl0aGVyLg0KTEFTVC1NT0RJRklFRDoy
|
||||
MDI2MDkwMlQxMDE1MDBaDQpMT0NBVElPTjpTdW5ueSBEYXkgTXVzaWNcLCBCYW5kcmENClNFUVVF
|
||||
TkNFOjANClNUQVRVUzpDT05GSVJNRUQNClNVTU1BUlk6UGlhbm8gbGVzc29uOiBDb29wZXINClRS
|
||||
QU5TUDpPUEFRVUUNCkVORDpWRVZFTlQNCkVORDpWQ0FMRU5EQVINCg==
|
||||
|
||||
--mixed-piano-0001--
|
||||
@@ -0,0 +1,17 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 1 Sep 2026 14:07:33 +0900
|
||||
Message-ID: <[email protected]>
|
||||
Subject: =?ISO-2022-JP?B?GyRCMnE1RCROO3FOQSRyQXckaiReJDkbKEI=?=
|
||||
From: =?ISO-2022-JP?B?GyRCOjRGIxsoQiAbJEIyVjtSGyhC?= <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
X-Mailer: Becky! ver. 2.75
|
||||
Content-Type: text/plain; charset=ISO-2022-JP
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
$B$*@$OC$K$J$C$F$*$j$^$9!#:4F#$G$9!#(B
|
||||
|
||||
$BMh=5$N2q5D$N;qNA$r$*Aw$j$7$^$9!#$43NG'$N$&$(!"$40U8+$r$$$?$@$1$^$9$H(B
|
||||
$B9,$$$G$9!#(B
|
||||
|
||||
$B$h$m$7$/$*4j$$$$$?$7$^$9!#(B
|
||||
@@ -0,0 +1,21 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Sun, 30 Aug 2026 19:38:12 +0200
|
||||
Message-ID: <[email protected]>
|
||||
Subject: =?iso-8859-1?Q?Votre_r=E9servation_est_confirm=E9e?=
|
||||
From: =?iso-8859-1?Q?Th=E9=E2tre_du_Ch=E2telet?= <[email protected]>
|
||||
To: [email protected]
|
||||
Content-Type: text/plain; charset=iso-8859-1
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Bonjour,
|
||||
|
||||
Votre réservation est confirmée pour la deuxième soirée. Les places sont
|
||||
au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures.
|
||||
|
||||
Merci de présenter ce message à l'entrée. Aucun billet papier ne sera
|
||||
envoyé.
|
||||
|
||||
Bien cordialement,
|
||||
La billetterie
|
||||
Théâtre du Châtelet
|
||||
@@ -0,0 +1,16 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 1 Sep 2026 08:15:33 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Fwd: Dock schedule for the week
|
||||
From: Harbour Line Ops <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html><body>
|
||||
<p>Forwarding the schedule. The chart was in the original.</p>
|
||||
<p><img src="cid:[email protected]" width="480" height="320"
|
||||
alt="Dock schedule chart"></p>
|
||||
<p>Nothing else changed this week.</p>
|
||||
</body></html>
|
||||
@@ -0,0 +1,19 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 24 Aug 2026 17:55:30 -0400
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Pumpkin bread recipe
|
||||
From: "Young, Russell \"Russ\"" <[email protected]>
|
||||
To: "Jain, Priyanshu" <[email protected]>,
|
||||
"Weiss, Hannah (nee Fischer)" <[email protected]>
|
||||
Reply-To: "Young, Russell \"Russ\"" <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
From my mother's card, transcribed as best I could. Bake at 175 for
|
||||
fifty minutes, and do not open the oven before forty.
|
||||
|
||||
Two loaves, or one loaf and twelve muffins. The muffins take half the
|
||||
time and are better the next day.
|
||||
|
||||
Russ
|
||||
@@ -0,0 +1,20 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Thu, 3 Sep 2026 09:03:20 +0530
|
||||
Message-ID: <[email protected]>
|
||||
Subject: =?UTF-8?B?WW91ciByZXNlcnZhdGlvbiBpbiBMaXNib24gaXMgY29uZmlybWVkLCBJbsM=?=
|
||||
=?UTF-8?B?qnMgaXMgZXhwZWN0aW5nIHlvdQ==?=
|
||||
From: Airbnb <[email protected]>
|
||||
Reply-To: =?UTF-8?Q?In=C3=AAs?= <[email protected]>
|
||||
To: [email protected]
|
||||
X-Auto-Response-Suppress: All
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Check-in Friday 12 September after 15:00. Your host Inês will send the
|
||||
door code the evening before.
|
||||
|
||||
Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the
|
||||
street and the walk down is five minutes.
|
||||
|
||||
Airbnb
|
||||
@@ -0,0 +1,15 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Sun, 30 Aug 2026 21:04:55 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: =?UTF-8?B?TGVzIE1pc8OpcmFibGVzIHRpY2tldHMsIHNlY29uZCBuaWdodA==?=
|
||||
From: Caroline Bauhaus <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>, Maya Raghunathan <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
SSBnb3QgZm91ciBmb3IgdGhlIHNlY29uZCBuaWdodCByYXRoZXIgdGhhbiB0aGUgZmlyc3QsIHRo
|
||||
ZSBzZWF0cyBhcmUKYmV0dGVyIGFuZCBpdCBpcyB0ZW4gcG91bmRzIGxlc3MgZWFjaC4gUm93IEgs
|
||||
IHNsaWdodGx5IGxlZnQgb2YgY2VudHJlLgoKTm9ib2R5IGhhcyB0byBwYXkgbWUgYmFjayB1bnRp
|
||||
bCBhZnRlciwgYW5kIGlmIEthcnRoaWsgZHJvcHMgb3V0IEkga25vdwp0d28gcGVvcGxlIHdobyB3
|
||||
b3VsZCB0YWtlIGhpcy4KCkNhcm9saW5lCg==
|
||||
@@ -0,0 +1,17 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 17:26:41 +0200
|
||||
Message-ID: <[email protected]>
|
||||
Subject: =?utf-8?Q?Angebot=3A_K=C3=BCchenarbeitsplatte_in_Eiche_massi?=
|
||||
=?utf-8?Q?v_=28Nachtrag=29?=
|
||||
From: =?iso-8859-1?Q?Lena_Brandt_=28Tischlerei_M=FCnchen=29?= <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Sounds good, Julie. Any afternoon next week works for me.
|
||||
|
||||
Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie
|
||||
besprochen. Die Kante bleibt gerade.
|
||||
|
||||
Lena
|
||||
@@ -0,0 +1,29 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: DocuSign NA3 System <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788201667000
|
||||
subject: Completed: Studio lease 2026
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: All parties have completed the envelope. You can access the signed copy from the link below or from the attached PDF.
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
attachment: part=2 name="Studio lease 2026 (signé).pdf" type=application/pdf size=193 inline=false cid=- sha256=e5a636ac00ea3c17
|
||||
|
||||
--- html ---
|
||||
<p>All parties have completed the envelope. You can access the signed copy from the link below or from the attached PDF.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
All parties have completed the envelope. You can access the signed copy
|
||||
from the link below or from the attached PDF.
|
||||
@@ -0,0 +1,47 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Sam Okafor <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788343811000
|
||||
subject: Invitation: Piano lesson: Cooper @ Wed 9 Sep 2026 17:00 - 17:45 (IST) ([email protected])
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: You have been invited to the following event. Piano lesson: Cooper When Wed 9 Sep 2026 17:00 to 17:45 India Standard Time Where Sunny Day Music, Bandra Who Sam Okafor, Priyanshu Jain
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
invite-uid: [email protected]
|
||||
invite-summary: Piano lesson: Cooper
|
||||
invite-start-ms: 1788953400000
|
||||
invite-end-ms: 1788956100000
|
||||
invite-all-day: false
|
||||
invite-location: Sunny Day Music, Bandra
|
||||
invite-organizer: Sunny Day Music <[email protected]>
|
||||
invite-my-response: NeedsAction
|
||||
invite-description: First lesson for Cooper. Nothing to prepare, and there is a piano here, so nothing to carry either.
|
||||
|
||||
--- html ---
|
||||
<div style="padding:16px">
|
||||
<p>You have been invited to the following event.</p>
|
||||
<h3>Piano lesson: Cooper</h3>
|
||||
<p><b>When</b> Wed 9 Sep 2026 17:00 to 17:45 India Standard Time<br>
|
||||
<b>Where</b> Sunny Day Music, Bandra<br>
|
||||
<b>Who</b> Sam Okafor, Priyanshu Jain</p>
|
||||
</div>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
You have been invited to the following event.
|
||||
|
||||
Piano lesson: Cooper
|
||||
When: Wed 9 Sep 2026 17:00 to 17:45 India Standard Time
|
||||
Where: Sunny Day Music, Bandra
|
||||
Who: Sam Okafor, Priyanshu Jain
|
||||
@@ -0,0 +1,35 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: 佐藤 花子 <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788239253000
|
||||
subject: 会議の資料を送ります
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: お世話になっております。佐藤です。 来週の会議の資料をお送りします。ご確認のうえ、ご意見をいただけますと 幸いです。 よろしくお願いいたします。
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>お世話になっております。佐藤です。</p>
|
||||
<p>来週の会議の資料をお送りします。ご確認のうえ、ご意見をいただけますと<br>
|
||||
幸いです。</p>
|
||||
<p>よろしくお願いいたします。</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
お世話になっております。佐藤です。
|
||||
|
||||
来週の会議の資料をお送りします。ご確認のうえ、ご意見をいただけますと
|
||||
幸いです。
|
||||
|
||||
よろしくお願いいたします。
|
||||
@@ -0,0 +1,42 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Théâtre du Châtelet <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788111492000
|
||||
subject: Votre réservation est confirmée
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Bonjour, Votre réservation est confirmée pour la deuxième soirée. Les places sont au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures. Merci de présenter ce message à l'entrée. Aucun billet…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Bonjour,</p>
|
||||
<p>Votre réservation est confirmée pour la deuxième soirée. Les places sont au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures.</p>
|
||||
<p>Merci de présenter ce message à l'entrée. Aucun billet papier ne sera envoyé.</p>
|
||||
<p>Bien cordialement,<br>
|
||||
La billetterie<br>
|
||||
Théâtre du Châtelet</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Bonjour,
|
||||
|
||||
Votre réservation est confirmée pour la deuxième soirée. Les places sont
|
||||
au rang H, côté jardin, et le théâtre ouvre à dix-neuf heures.
|
||||
|
||||
Merci de présenter ce message à l'entrée. Aucun billet papier ne sera
|
||||
envoyé.
|
||||
|
||||
Bien cordialement,
|
||||
La billetterie
|
||||
Théâtre du Châtelet
|
||||
@@ -0,0 +1,30 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Harbour Line Ops <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788250533000
|
||||
subject: Fwd: Dock schedule for the week
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Forwarding the schedule. The chart was in the original. Nothing else changed this week.
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
<p>Forwarding the schedule. The chart was in the original.</p>
|
||||
<p><img alt="Dock schedule chart" width="480" height="320"></p>
|
||||
<p>Nothing else changed this week.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Forwarding the schedule. The chart was in the original. Nothing else changed this week.
|
||||
@@ -0,0 +1,35 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Young, Russell "Russ" <[email protected]>
|
||||
to: Jain, Priyanshu <[email protected]>, Weiss, Hannah (nee Fischer) <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: Young, Russell "Russ" <[email protected]>
|
||||
date-ms: 1787608530000
|
||||
subject: Pumpkin bread recipe
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: From my mother's card, transcribed as best I could. Bake at 175 for fifty minutes, and do not open the oven before forty. Two loaves, or one loaf and twelve muffins. The muffins take half the time…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>From my mother's card, transcribed as best I could. Bake at 175 for fifty minutes, and do not open the oven before forty.</p>
|
||||
<p>Two loaves, or one loaf and twelve muffins. The muffins take half the time and are better the next day.</p>
|
||||
<p>Russ</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
From my mother's card, transcribed as best I could. Bake at 175 for
|
||||
fifty minutes, and do not open the oven before forty.
|
||||
|
||||
Two loaves, or one loaf and twelve muffins. The muffins take half the
|
||||
time and are better the next day.
|
||||
|
||||
Russ
|
||||
@@ -0,0 +1,35 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Airbnb <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: Inês <[email protected]>
|
||||
date-ms: 1788406400000
|
||||
subject: Your reservation in Lisbon is confirmed, Inês is expecting you
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Check-in Friday 12 September after 15:00. Your host Inês will send the door code the evening before. Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the street and the walk down…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Check-in Friday 12 September after 15:00. Your host Inês will send the door code the evening before.</p>
|
||||
<p>Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the street and the walk down is five minutes.</p>
|
||||
<p>Airbnb</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Check-in Friday 12 September after 15:00. Your host Inês will send the
|
||||
door code the evening before.
|
||||
|
||||
Rua da Bica de Duarte Belo 42, Lisboa. The tram stops at the top of the
|
||||
street and the walk down is five minutes.
|
||||
|
||||
Airbnb
|
||||
@@ -0,0 +1,35 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Caroline Bauhaus <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>, Maya Raghunathan <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788120295000
|
||||
subject: Les Misérables tickets, second night
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: I got four for the second night rather than the first, the seats are better and it is ten pounds less each. Row H, slightly left of centre. Nobody has to pay me back until after, and if Karthik drops…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>I got four for the second night rather than the first, the seats are better and it is ten pounds less each. Row H, slightly left of centre.</p>
|
||||
<p>Nobody has to pay me back until after, and if Karthik drops out I know two people who would take his.</p>
|
||||
<p>Caroline</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
I got four for the second night rather than the first, the seats are
|
||||
better and it is ten pounds less each. Row H, slightly left of centre.
|
||||
|
||||
Nobody has to pay me back until after, and if Karthik drops out I know
|
||||
two people who would take his.
|
||||
|
||||
Caroline
|
||||
@@ -0,0 +1,34 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Lena Brandt (Tischlerei München) <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788362801000
|
||||
subject: Angebot: Küchenarbeitsplatte in Eiche massiv (Nachtrag)
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Sounds good, Julie. Any afternoon next week works for me. Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie besprochen. Die Kante bleibt gerade. Lena
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Sounds good, Julie. Any afternoon next week works for me.</p>
|
||||
<p>Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie besprochen. Die Kante bleibt gerade.</p>
|
||||
<p>Lena</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Sounds good, Julie. Any afternoon next week works for me.
|
||||
|
||||
Der Nachtrag betrifft nur die Oberflaeche: geoelt statt lackiert, wie
|
||||
besprochen. Die Kante bleibt gerade.
|
||||
|
||||
Lena
|
||||
@@ -0,0 +1,38 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: [email protected]
|
||||
references-first: [email protected]
|
||||
from: Jeff Wolfe <[email protected]>
|
||||
to: <[email protected]>, <[email protected]>, Bauhaus, Caroline <[email protected]>
|
||||
cc: <[email protected]>
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788382927000
|
||||
subject: Re: Cooper's parent-teacher conference
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Mr. and Mrs. Young, this is just a reminder to schedule your parent-teacher conference with me. You can feel free to do that here. If you would prefer a video call instead of meeting at school, I am…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Mr. and Mrs. Young, this is just a reminder to schedule your parent-teacher conference with me. You can feel free to do that here.</p>
|
||||
<p>If you would prefer a video call instead of meeting at school, I am happy to do that as well. Please let me know what date and time works best for you.</p>
|
||||
<p>Jeff Wolfe<br>
|
||||
Year 4, Oakridge</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Mr. and Mrs. Young, this is just a reminder to schedule your
|
||||
parent-teacher conference with me. You can feel free to do that here.
|
||||
|
||||
If you would prefer a video call instead of meeting at school, I am happy
|
||||
to do that as well. Please let me know what date and time works best for
|
||||
you.
|
||||
|
||||
Jeff Wolfe
|
||||
Year 4, Oakridge
|
||||
@@ -0,0 +1,31 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Ledger Lines <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788067838000
|
||||
subject: The bond market, again
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Rates did the one thing nobody had written a note about, which is nothing at all. Here is what that means for the long end. The full piece is on the site. This mail is the summary.
|
||||
list-id: weekly.ledgerlines.example
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: one-click=false mailto=mailto:[email protected] url=-
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Rates did the one thing nobody had written a note about, which is nothing at all. Here is what that means for the long end.</p>
|
||||
<p>The full piece is on the site. This mail is the summary.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Rates did the one thing nobody had written a note about, which is
|
||||
nothing at all. Here is what that means for the long end.
|
||||
|
||||
The full piece is on the site. This mail is the summary.
|
||||
@@ -0,0 +1,43 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Discount Tire <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1787752800000
|
||||
subject: Your air pressure reminder
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: It has been 30 days since your last check. Stop by any store for a free air pressure check, no appointment needed. Book now Find a store near you
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: one-click=false mailto=- url=https://discounttire.example/u/4471
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div>
|
||||
<p>It has been 30 days since your last check.
|
||||
Stop by any store for a free air pressure check, no appointment needed.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<p><a target="_blank" rel="noopener noreferrer">Book
|
||||
now</a></p>
|
||||
<p><a href="https://discounttire.example/stores" target="_blank" rel="noopener noreferrer">Find a store near you</a></p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
It has been 30 days since your last check. Stop by any store for a free air pressure check, no appointment needed. Book now Find a store near you
|
||||
@@ -0,0 +1,30 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Clare Dunn <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788182527000
|
||||
subject: The signature block, and one photo
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: The photo you asked for is below. Clare
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
<p>The photo you asked for is below.</p>
|
||||
<p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" width="600" height="400" alt="The site"></p>
|
||||
<p>Clare</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
The photo you asked for is below. Clare
|
||||
@@ -0,0 +1,36 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Hannah Weiss <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788085180000
|
||||
subject: Photos from the Hawaii trip
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Finally went through them all. The ones from the ridge walk are the best of the lot, two of them are below and the rest are in the folder. Hannah
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<div>
|
||||
<p>Finally went through them all. The ones from the ridge walk are the
|
||||
best of the lot, two of them are below and the rest are in the folder.</p>
|
||||
<p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3ZrcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxcQwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWScmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6cn0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1QyVTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Qru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=" alt="The ridge at seven" width="16" height="16"></p>
|
||||
<p><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3ZrcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxcQwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWScmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6cn0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1QyVTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Qru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=" alt="Looking back down" width="16" height="16"></p>
|
||||
<p>Hannah</p>
|
||||
</div>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Finally went through them all. The ones from the ridge walk are the best
|
||||
of the lot, two of them are below and the rest are in the folder.
|
||||
|
||||
Hannah
|
||||
@@ -0,0 +1,37 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Nadia Osei <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788343491000
|
||||
subject: Invitation: Quarterly review @ Thu 15 Oct 2026 14:00 - 15:00 (BST)
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: You have been invited to Quarterly review on Thursday 15 October 2026, 14:00 to 15:00 London time.
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
invite-uid: [email protected]
|
||||
invite-summary: Quarterly review
|
||||
invite-start-ms: 1792069200000
|
||||
invite-end-ms: 1792072800000
|
||||
invite-all-day: false
|
||||
invite-location: Westferry, meeting room 2
|
||||
invite-organizer: Nadia Osei <[email protected]>
|
||||
invite-my-response: NeedsAction
|
||||
invite-description: The quarterly numbers, then the roadmap. Bring the deck.
|
||||
|
||||
--- html ---
|
||||
<p>You have been invited to Quarterly review on Thursday 15 October 2026, 14:00 to 15:00 London time.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
You have been invited to Quarterly review on Thursday 15 October 2026,
|
||||
14:00 to 15:00 London time.
|
||||
@@ -0,0 +1,44 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: City Power <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788383091000
|
||||
subject: Bill payment pending
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Dear Customer, Your one-time electronic payment of 98.26 for your City Power bill has been received and is pending. Your payment will be posted within two business days. Confirmation number…
|
||||
list-id: -
|
||||
auto-submitted: auto-generated
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
attachment: part=2 name="Statement-August.pdf" type=application/pdf size=193 inline=false cid=- sha256=e5a636ac00ea3c17
|
||||
|
||||
--- html ---
|
||||
|
||||
<p>Dear Customer,</p>
|
||||
<p>Your one-time electronic payment of <b>98.26</b> for your City Power bill has been received and is pending. Your payment will be posted within two business days.</p>
|
||||
<p>Confirmation number 116400046977232</p>
|
||||
<p>Thank you,<br>City Power Customer Care</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Dear Customer,
|
||||
|
||||
Your one-time electronic payment of 98.26 for your City Power bill has
|
||||
been received and is pending. Your payment will be posted within two
|
||||
business days.
|
||||
|
||||
Confirmation number 116400046977232
|
||||
|
||||
Please keep this message for your records. If you did not make this
|
||||
payment, call us on the number printed on your statement.
|
||||
|
||||
Thank you,
|
||||
City Power Customer Care
|
||||
@@ -0,0 +1,42 @@
|
||||
render-version: 2
|
||||
message-id: 01000198a2f4c1b2-9b0c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e@thebrowser.example
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: The Browser <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788411603000
|
||||
subject: Five things worth reading this weekend
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Good morning. It is a long weekend in some places and a wet one in most, so this edition leans toward pieces you can settle into. The pencil, at length. Henry Petroski wrote a whole book about the…
|
||||
list-id: the-browser.list.thebrowser.example
|
||||
auto-submitted: -
|
||||
precedence: bulk
|
||||
unsubscribe: one-click=true mailto=mailto:[email protected]?subject=unsub url=https://thebrowser.example/unsubscribe?u=91827&id=8f3a1c
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
<p>Good morning. It is a long weekend in some places and a wet one in most, so this edition leans toward pieces you can settle into.</p>
|
||||
<p><b>The pencil, at length.</b> Henry Petroski wrote a whole book about the pencil and this essay is the argument for why that was a reasonable thing to do. Graphite, cedar, the Napoleonic wars and a factory in Nuremberg that still runs.</p>
|
||||
<p><b>Why bridges hum.</b> A short explanation of vortex shedding from an engineer who spent a career listening to cables.</p>
|
||||
<p><b>The last typewriter repairman in Mumbai.</b> A profile that is really about what it means to keep a trade going after the trade has gone.</p>
|
||||
<p><a href="https://thebrowser.example/unsubscribe?u=91827&id=8f3a1c" target="_blank" rel="noopener noreferrer">Unsubscribe</a></p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Good morning. It is a long weekend in some places and a wet one in most,
|
||||
so this edition leans toward pieces you can settle into. A long piece on
|
||||
the history of the pencil, a short one on why bridges hum, and three more.
|
||||
|
||||
THE PENCIL, AT LENGTH. Henry Petroski wrote a whole book about the pencil
|
||||
and this essay is the argument for why that was a reasonable thing to do.
|
||||
|
||||
WHY BRIDGES HUM. A short explanation of vortex shedding from an engineer
|
||||
who spent a career listening to cables.
|
||||
|
||||
Unsubscribe: https://thebrowser.example/unsubscribe?u=91827&id=8f3a1c
|
||||
@@ -0,0 +1,32 @@
|
||||
render-version: 2
|
||||
message-id: -
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Sunny Day Music <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1786512739000
|
||||
subject: Enrolment received for Cooper
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Enrolment received for Cooper. Term starts the week of 8 September and your teacher will be in touch to fix a weekly slot. Nothing further is needed from you today. The term dates and the studio…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Enrolment received for Cooper. Term starts the week of 8 September and your teacher will be in touch to fix a weekly slot.</p>
|
||||
<p>Nothing further is needed from you today. The term dates and the studio address are on the enrolment form you signed.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Enrolment received for Cooper. Term starts the week of 8 September and
|
||||
your teacher will be in touch to fix a weekly slot.
|
||||
|
||||
Nothing further is needed from you today. The term dates and the studio
|
||||
address are on the enrolment form you signed.
|
||||
@@ -0,0 +1,48 @@
|
||||
render-version: 2
|
||||
message-id: AM0PR07MB5218F1C0D9E2A4E1B@AM0PR07MB5218.eurprd07.prod.outlook.com
|
||||
in-reply-to: [email protected]
|
||||
references-first: [email protected]
|
||||
from: Young, Russell <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788277269000
|
||||
subject: RE: The lease, clause 14
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Clause 14 is the standard form. I would not sign it as drafted. Russell
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
<p>Clause 14 is the standard form. I would not sign it as drafted.</p>
|
||||
<p>Russell</p>
|
||||
<div style="border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm 0cm 0cm">
|
||||
</div>
|
||||
|
||||
--- quoted ---
|
||||
<hr>
|
||||
<div>
|
||||
<p><b>From:</b> Priyanshu Jain <[email protected]><br>
|
||||
<b>Sent:</b> Tuesday, 1 September 2026 12:00<br>
|
||||
<b>Subject:</b> The lease, clause 14</p>
|
||||
<p>Russell, is clause 14 negotiable or is it the landlord's standard form?</p>
|
||||
</div>
|
||||
|
||||
--- text ---
|
||||
Clause 14 is the standard form. I would not sign it as drafted.
|
||||
|
||||
Russell
|
||||
|
||||
-----Original Message-----
|
||||
From: Priyanshu Jain <[email protected]>
|
||||
Sent: Tuesday, 1 September 2026 12:00
|
||||
To: Young, Russell <[email protected]>
|
||||
Subject: The lease, clause 14
|
||||
|
||||
Russell, is clause 14 negotiable or is it the landlord's standard form?
|
||||
@@ -0,0 +1,36 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Maya Raghunathan <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788415929000
|
||||
subject: Dinner on Thursday?
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Priya said the place on Church Street takes bookings now, want me to put us down for four at eight? Everyone can make Thursday except Karthik, who has the badminton thing and says he will come late…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Priya said the place on Church Street takes bookings now, want me to put us down for four at eight? Everyone can make Thursday except Karthik, who has the badminton thing and says he will come late.</p>
|
||||
<p>If you would rather somewhere quieter there is the Malleswaram place we went to in June. Say by tomorrow and I will call them.</p>
|
||||
<p>Maya</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Priya said the place on Church Street takes bookings now, want me to put
|
||||
us down for four at eight? Everyone can make Thursday except Karthik, who
|
||||
has the badminton thing and says he will come late.
|
||||
|
||||
If you would rather somewhere quieter there is the Malleswaram place we
|
||||
went to in June. Say by tomorrow and I will call them.
|
||||
|
||||
Maya
|
||||
@@ -0,0 +1,34 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Field Notes Dispatch <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788168600000
|
||||
subject: September: the notebook that survived a washing machine
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: A reader in Tromsø sent us a photo of a pocket notebook that went through a full cycle at forty degrees, in a coat, with the coat. Every page is still legible. Also this month: the autumn edition…
|
||||
list-id: dispatch.fieldnotes.example
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: one-click=false mailto=- url=https://fieldnotes.example/u/7712
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>A reader in Tromsø sent us a photo of a pocket notebook that went through a full cycle at forty degrees, in a coat, with the coat. Every page is still legible.</p>
|
||||
<p>Also this month: the autumn edition ships on the 15th. Three colours, the usual count. The paper weight is unchanged at 100 g/m², and the price stays at €12 for the three pack.</p>
|
||||
<p>The equals sign is written = when it stands alone, which is the part of this encoding that catches parsers out. Trailing whitespace is kept by encoding it, like the space at the end of the previous line.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
A reader in Tromsø sent us a photo of a pocket notebook that went through a full cycle at forty degrees, in a coat, with the coat. Every page is still legible.
|
||||
|
||||
Also this month: the autumn edition ships on the 15th. Three colours, the usual count. The paper weight is unchanged at 100 g/m², and the price stays at €12 for the three pack.
|
||||
|
||||
The equals sign is written = when it stands alone, which is the part of this encoding that catches parsers out. Trailing whitespace is kept
|
||||
by encoding it, like the space at the end of the previous line.
|
||||
@@ -0,0 +1,47 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: [email protected]
|
||||
references-first: [email protected]
|
||||
from: Dev Patel <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788191095000
|
||||
subject: Re: Slides from the talk
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Here they are, plus the reading list I mentioned. The paper on cache oblivious layouts is the one to start with.
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<div dir="ltr">Here they are, plus the reading list I mentioned. The paper
|
||||
on cache oblivious layouts is the one to start with.</div>
|
||||
|
||||
--- quoted ---
|
||||
<br>
|
||||
<div>
|
||||
<div dir="ltr">On Mon, 31 Aug 2026 at 18:40, Priyanshu
|
||||
Jain <<a href="mailto:[email protected]" target="_blank" rel="noopener noreferrer">[email protected]</a>> wrote:</div>
|
||||
<blockquote style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex">
|
||||
<div dir="ltr">Good talk. Could you send the slides, and the reading list
|
||||
you put up at the end?</div>
|
||||
<br>
|
||||
<div>
|
||||
<div dir="ltr">On Fri, 28 Aug 2026 at 12:02, Dev Patel
|
||||
<<a href="mailto:[email protected]" target="_blank" rel="noopener noreferrer">[email protected]</a>>
|
||||
wrote:</div>
|
||||
<blockquote style="margin:0 0 0 0.8ex;border-left:1px solid #ccc;padding-left:1ex">
|
||||
<div dir="ltr">Talk is on Monday at six, room 4.02. Come if you are free.</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
--- text ---
|
||||
Here they are, plus the reading list I mentioned. The paper on cache
|
||||
oblivious layouts is the one to start with.
|
||||
@@ -0,0 +1,61 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: [email protected]
|
||||
references-first: [email protected]
|
||||
from: Sam Okafor <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788343802000
|
||||
subject: Re: Fw: (no subject)
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Got it, thank you. Wednesdays at five work for us. The first lesson is on the 9th, nothing to prepare. The invitation is attached, it should land in your calendar. Sam
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Got it, thank you. Wednesdays at five work for us.</p>
|
||||
<p>The first lesson is on the 9th, nothing to prepare. The invitation is attached, it should land in your calendar.</p>
|
||||
<p>Sam</p>
|
||||
|
||||
--- quoted ---
|
||||
<p>On Mon, 31 Aug 2026 at 09:12, Priyanshu Jain <[email protected]> wrote:<br>
|
||||
> Hi Sam, form signed and attached. Would a weekday after school suit<br>
|
||||
> Cooper? He finishes at four and we are ten minutes away on foot.<br>
|
||||
><br>
|
||||
> On Wed, 12 Aug 2026 at 11:02, Sunny Day Music<br>
|
||||
> <[email protected]> wrote:<br>
|
||||
> > Enrolment received for Cooper. Term starts the week of 8 September<br>
|
||||
> > and your teacher will be in touch to fix a weekly slot.<br>
|
||||
> ><br>
|
||||
> > Nothing further is needed from you today.<br>
|
||||
><br>
|
||||
> Thanks,<br>
|
||||
> Priyanshu</p>
|
||||
|
||||
--- text ---
|
||||
Got it, thank you. Wednesdays at five work for us.
|
||||
|
||||
The first lesson is on the 9th, nothing to prepare. The invitation is
|
||||
attached, it should land in your calendar.
|
||||
|
||||
Sam
|
||||
|
||||
On Mon, 31 Aug 2026 at 09:12, Priyanshu Jain <[email protected]> wrote:
|
||||
> Hi Sam, form signed and attached. Would a weekday after school suit
|
||||
> Cooper? He finishes at four and we are ten minutes away on foot.
|
||||
>
|
||||
> On Wed, 12 Aug 2026 at 11:02, Sunny Day Music
|
||||
> <[email protected]> wrote:
|
||||
> > Enrolment received for Cooper. Term starts the week of 8 September
|
||||
> > and your teacher will be in touch to fix a weekly slot.
|
||||
> >
|
||||
> > Nothing further is needed from you today.
|
||||
>
|
||||
> Thanks,
|
||||
> Priyanshu
|
||||
@@ -0,0 +1,32 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Spotify <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788417018000
|
||||
subject: Your receipt
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Thank you for purchasing Spotify Premium. Amount charged 11.99, on the card ending 4417. Your next payment is due on 3 October 2026. This mailbox is not monitored. Manage your subscription from the…
|
||||
list-id: -
|
||||
auto-submitted: auto-generated
|
||||
precedence: bulk
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Thank you for purchasing Spotify Premium. Amount charged 11.99, on the card ending 4417. Your next payment is due on 3 October 2026.</p>
|
||||
<p>This mailbox is not monitored. Manage your subscription from the account page.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Thank you for purchasing Spotify Premium. Amount charged 11.99, on the
|
||||
card ending 4417. Your next payment is due on 3 October 2026.
|
||||
|
||||
This mailbox is not monitored. Manage your subscription from the account
|
||||
page.
|
||||
@@ -0,0 +1,41 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: [email protected]
|
||||
references-first: [email protected]
|
||||
from: Arun Kulkarni <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: Meridian Leasing <[email protected]>
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788410702000
|
||||
subject: Re: Lease renewal for the studio
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Hi Priyanshu, Attached the revised draft. The only change is clause 7, which now reads three months either side rather than six. The rent review in clause 12 stays as it was, tied to the index and…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Hi Priyanshu,</p>
|
||||
<p>Attached the revised draft. The only change is clause 7, which now reads three months either side rather than six. The rent review in clause 12 stays as it was, tied to the index and capped at four percent.</p>
|
||||
<p>If that works, sign when you get a moment and I will countersign the same day. Happy to walk through anything on a call, Thursday afternoon is open.</p>
|
||||
<p>Best,<br>
|
||||
Arun</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Hi Priyanshu,
|
||||
|
||||
Attached the revised draft. The only change is clause 7, which now reads
|
||||
three months either side rather than six. The rent review in clause 12
|
||||
stays as it was, tied to the index and capped at four percent.
|
||||
|
||||
If that works, sign when you get a moment and I will countersign the same
|
||||
day. Happy to walk through anything on a call, Thursday afternoon is open.
|
||||
|
||||
Best,
|
||||
Arun
|
||||
@@ -0,0 +1,31 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Lena Brandt <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788333120000
|
||||
subject: Kitchen bench quote
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: The quote is attached. Prices hold for thirty days. Lena
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
attachment: part=2 name="Angebot Küchenarbeitsplatte Eiche massiv 2026.pdf" type=application/pdf size=193 inline=false cid=- sha256=e5a636ac00ea3c17
|
||||
|
||||
--- html ---
|
||||
<p>The quote is attached. Prices hold for thirty days.</p>
|
||||
<p>Lena</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
The quote is attached. Prices hold for thirty days.
|
||||
|
||||
Lena
|
||||
@@ -0,0 +1,36 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Todd Markham <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788187692000
|
||||
subject: Re: Life insurance quote
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Hello Ms. Young, I hope all is well with you. I wanted to touch base on our conversation from a few weeks ago. Following up on the quote I sent through last week for the twenty year term policy. If…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Hello Ms. Young, I hope all is well with you. I wanted to touch base on our conversation from a few weeks ago.</p>
|
||||
<p>Following up on the quote I sent through last week for the twenty year term policy. If the premium looks right, I can start the paperwork whenever you are ready.</p>
|
||||
<p>Todd</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Hello Ms. Young, I hope all is well with you. I wanted to touch base on
|
||||
our conversation from a few weeks ago.
|
||||
|
||||
Following up on the quote I sent through last week for the twenty year
|
||||
term policy. If the premium looks right, I can start the paperwork
|
||||
whenever you are ready.
|
||||
|
||||
Todd
|
||||
@@ -0,0 +1,31 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Evite on behalf of Robyn Madison <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: Robyn Madison <[email protected]>
|
||||
date-ms: 1788315761000
|
||||
subject: You have an invitation from Robyn Madison
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Join us for Jack's 8th birthday! Robyn Madison needs your RSVP. Saturday 19 September, from two in the afternoon, at the park by the lake. Bring nothing but a hat.
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: bulk
|
||||
unsubscribe: one-click=false mailto=- url=https://evite.example/optout/99120
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Join us for Jack's 8th birthday! Robyn Madison needs your RSVP.</p>
|
||||
<p>Saturday 19 September, from two in the afternoon, at the park by the lake. Bring nothing but a hat.</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Join us for Jack's 8th birthday! Robyn Madison needs your RSVP.
|
||||
|
||||
Saturday 19 September, from two in the afternoon, at the park by the
|
||||
lake. Bring nothing but a hat.
|
||||
@@ -0,0 +1,32 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Lena Brandt <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788188733000
|
||||
subject: The worktop, and one more thing
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: The worktop is oiled rather than lacquered, as we agreed. The delivery slot moved to the fourteenth. Everything else is unchanged. Lena Brandt, Brandt Tischlerei
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<div style="font-family:Arial , sans-serif">
|
||||
<p>The worktop is oiled rather than lacquered, as we agreed.</p>
|
||||
<p style="color:#d32f2f">The delivery slot moved to the fourteenth.</p>
|
||||
<p><font>Everything else is unchanged.</font></p>
|
||||
<p style="color:#666666">Lena Brandt, Brandt Tischlerei</p>
|
||||
</div>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
The worktop is oiled rather than lacquered, as we agreed. The delivery slot moved to the fourteenth. Everything else is unchanged. Lena Brandt, Brandt Tischlerei
|
||||
@@ -0,0 +1,37 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Harbour Market <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788326100000
|
||||
subject: The autumn box is back
|
||||
is-html: true
|
||||
surface: paper
|
||||
snippet: The autumn box is back, and it is heavier than last year. See what is in it
|
||||
list-id: news.harbourmarket.example
|
||||
auto-submitted: -
|
||||
precedence: bulk
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tbody><tr><td align="center" style="padding:20px 0">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0">
|
||||
<tbody><tr><td style="padding:28px;color:#2b2b2b;font-family:Georgia , serif">
|
||||
<p style="margin:0 0 12px">The autumn box is back, and it is heavier than last year.</p>
|
||||
<p style="margin:0"><a href="https://harbourmarket.example/boxes" target="_blank" rel="noopener noreferrer">See what is in it</a></p>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
The autumn box is back, and it is heavier than last year. See what is in it
|
||||
@@ -0,0 +1,43 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: The Long Read <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788415211000
|
||||
subject: The weekend edition
|
||||
is-html: true
|
||||
surface: paper
|
||||
snippet: The weekend edition Three pieces to settle into, and one to argue with. The pencil, at length Why bridges hum You are getting this because you asked for it. Unsubscribe
|
||||
list-id: weekly.thelongread.example
|
||||
auto-submitted: -
|
||||
precedence: bulk
|
||||
unsubscribe: one-click=false mailto=- url=https://thelongread.example/unsubscribe?u=4471
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f4f1ea">
|
||||
<tbody><tr><td align="center" style="padding:24px 0">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff">
|
||||
<tbody><tr><td style="padding:32px">
|
||||
<h1 style="font-family:Georgia , serif;font-size:26px;color:#1a1a1a;margin:0 0 18px">The weekend edition</h1>
|
||||
<p style="font-family:Georgia , serif;font-size:16px;color:#333333;margin:0 0 14px">Three pieces to settle into, and one to argue with.</p>
|
||||
<p style="margin:0 0 14px"><a href="https://thelongread.example/pencil" style="color:#8a5a2b" target="_blank" rel="noopener noreferrer">The pencil, at length</a></p>
|
||||
<p style="margin:0"><a href="https://thelongread.example/bridges" style="color:#8a5a2b" target="_blank" rel="noopener noreferrer">Why bridges hum</a></p>
|
||||
</td></tr>
|
||||
<tr><td bgcolor="#efece4" style="padding:18px 32px;font-size:12px;color:#6b6b6b">
|
||||
You are getting this because you asked for it.
|
||||
<a href="https://thelongread.example/unsubscribe?u=4471" target="_blank" rel="noopener noreferrer">Unsubscribe</a>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
The weekend edition Three pieces to settle into, and one to argue with. The pencil, at length Why bridges hum You are getting this because you asked for it. Unsubscribe
|
||||
@@ -0,0 +1,46 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Arun Mehta <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788327842000
|
||||
subject: Three things before Thursday
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Morning Priyanshu, Three things before Thursday, none of them urgent : The revised lease is with the landlord's solicitor. The service charge reconciliation went out on Monday. Parking bay 12 is…
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<div>
|
||||
<p>Morning Priyanshu,</p>
|
||||
<p>Three things before Thursday, none of them <b>urgent</b>:</p>
|
||||
<ul>
|
||||
<li>The <b>revised lease</b> is with the landlord's solicitor.</li>
|
||||
<li>The service charge reconciliation went out on Monday.</li>
|
||||
<li>Parking bay 12 is yours from the first.</li>
|
||||
</ul>
|
||||
<p>Links, in case you want them now rather than Thursday:
|
||||
<a href="https://meridianproperties.example/lease/8812" target="_blank" rel="noopener noreferrer">the draft</a>,
|
||||
<a href="https://meridianproperties.example/charges/2026" target="_blank" rel="noopener noreferrer">the reconciliation</a>,
|
||||
<a href="https://meridianproperties.example/parking" target="_blank" rel="noopener noreferrer">the bay plan</a> and the
|
||||
<a href="https://meridianproperties.example/contact" target="_blank" rel="noopener noreferrer">office number</a>.</p>
|
||||
<p>Best,<br>Arun</p>
|
||||
<div>
|
||||
<p>Arun Mehta<br>
|
||||
Meridian Properties<br>
|
||||
+91 22 6100 4400</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Morning Priyanshu, Three things before Thursday, none of them urgent : The revised lease is with the landlord's solicitor. The service charge reconciliation went out on Monday. Parking bay 12 is yours from the first. Links, in case you want them now rather than Thursday: the draft , the reconciliation , the bay plan and the office number . Best, Arun Arun Mehta Meridian Properties +91 22 6100 4400
|
||||
@@ -0,0 +1,33 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Station House <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788510600000
|
||||
subject: Tonight at the Station House
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Doors at seven, the band at eight. Tickets on the door only, cash or card. Sold out on Saturday.
|
||||
list-id: news.stationhouse.example
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
|
||||
|
||||
<div>
|
||||
<p>Doors at seven, the band at eight.</p>
|
||||
<p>Tickets on the door only, cash or card.</p>
|
||||
<p style="color:#d32f2f">Sold out on Saturday.</p>
|
||||
</div>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Doors at seven, the band at eight. Tickets on the door only, cash or card. Sold out on Saturday.
|
||||
@@ -0,0 +1,34 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Bramble Coffee <[email protected]>
|
||||
to: <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788247367000
|
||||
subject: Your receipt from Bramble Coffee
|
||||
is-html: true
|
||||
surface: paper
|
||||
snippet: Thanks, that is paid. Flat white and a cardamom bun £6.40 on the card ending 4417 Bramble Coffee, 14 Rivington Street
|
||||
list-id: -
|
||||
auto-submitted: auto-generated
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<div style="background-color:#faf7f0;padding:28px;font-family:Helvetica , Arial , sans-serif">
|
||||
<div style="max-width:520px;margin:0 auto">
|
||||
<p style="font-size:18px;margin:0 0 16px">Thanks, that is paid.</p>
|
||||
<p style="margin:0 0 8px">Flat white and a cardamom bun</p>
|
||||
<p style="margin:0 0 16px">£6.40 on the card ending 4417</p>
|
||||
<p style="font-size:12px;color:#7a7a7a;margin:0">Bramble Coffee, 14 Rivington Street</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Thanks, that is paid. Flat white and a cardamom bun £6.40 on the card ending 4417 Bramble Coffee, 14 Rivington Street
|
||||
@@ -0,0 +1,36 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Arun Kulkarni <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788157964000
|
||||
subject: The studio, one more thing
|
||||
is-html: true
|
||||
surface: theme
|
||||
snippet: Priyanshu, the floor plan you asked for is below. The measurements are from the survey in June, not the original drawing. Arun
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 1
|
||||
blocked-url: https://meridianproperties.in/img/studio-floorplan.jpg
|
||||
tracker: HubSpot https://track.hubspot.com/__ptq.gif?k=8812&r=pj%4073ai.org
|
||||
tracker: meridianproperties.in https://meridianproperties.in/o/open.png?id=8812
|
||||
|
||||
--- html ---
|
||||
|
||||
<p>Priyanshu, the floor plan you asked for is below. The measurements are
|
||||
from the survey in June, not the original drawing.</p>
|
||||
<p><img alt="Studio floor plan" width="640" height="420"></p>
|
||||
<p>Arun</p>
|
||||
<img style="border:0" width="1" height="1" alt="">
|
||||
<img alt="" width="1" height="1">
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Priyanshu, the floor plan you asked for is below. The measurements are from the survey in June, not the original drawing. Arun
|
||||
@@ -0,0 +1,37 @@
|
||||
render-version: 2
|
||||
message-id: [email protected]
|
||||
in-reply-to: -
|
||||
references-first: -
|
||||
from: Inês Carvalho <[email protected]>
|
||||
to: Priyanshu Jain <[email protected]>
|
||||
cc: -
|
||||
bcc: -
|
||||
reply-to: -
|
||||
date-ms: 1788505920000
|
||||
subject: Instruções para a chegada, com um café à espera
|
||||
is-html: false
|
||||
surface: theme
|
||||
snippet: Olá Priyanshu, O código da porta é 4417 e o elevador está do lado direito. Deixo café e pão na cozinha para a manhã seguinte. Se o comboio atrasar, telefone. Não há problema nenhum. Inês
|
||||
list-id: -
|
||||
auto-submitted: -
|
||||
precedence: -
|
||||
unsubscribe: -
|
||||
blocked-images: 0
|
||||
|
||||
--- html ---
|
||||
<p>Olá Priyanshu,</p>
|
||||
<p>O código da porta é 4417 e o elevador está do lado direito. Deixo café e pão na cozinha para a manhã seguinte.</p>
|
||||
<p>Se o comboio atrasar, telefone. Não há problema nenhum.</p>
|
||||
<p>Inês</p>
|
||||
|
||||
--- quoted: none ---
|
||||
|
||||
--- text ---
|
||||
Olá Priyanshu,
|
||||
|
||||
O código da porta é 4417 e o elevador está do lado direito. Deixo café e
|
||||
pão na cozinha para a manhã seguinte.
|
||||
|
||||
Se o comboio atrasar, telefone. Não há problema nenhum.
|
||||
|
||||
Inês
|
||||
@@ -0,0 +1,24 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 16:02:07 -0500
|
||||
Message-ID: <[email protected]>
|
||||
In-Reply-To: <[email protected]>
|
||||
References: <[email protected]>
|
||||
Subject: Re: Cooper's parent-teacher conference
|
||||
From: Jeff Wolfe <[email protected]>
|
||||
To: Year 4 parents: [email protected], [email protected],
|
||||
"Bauhaus, Caroline" <[email protected]>;,
|
||||
undisclosed-recipients: ;
|
||||
Cc: Oakridge Office: [email protected];
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Mr. and Mrs. Young, this is just a reminder to schedule your
|
||||
parent-teacher conference with me. You can feel free to do that here.
|
||||
|
||||
If you would prefer a video call instead of meeting at school, I am happy
|
||||
to do that as well. Please let me know what date and time works best for
|
||||
you.
|
||||
|
||||
Jeff Wolfe
|
||||
Year 4, Oakridge
|
||||
@@ -0,0 +1,28 @@
|
||||
Return-Path: <[email protected]>
|
||||
Received: from a.mx.ledgerlines.example (a.mx.ledgerlines.example
|
||||
[198.51.100.24]) by mx.73ai.org with ESMTPS id 991ab2
|
||||
for <[email protected]>; Sun, 30 Aug 2026 11:00:41 +0530 (IST)
|
||||
Received: by a.mx.ledgerlines.example with SMTP id 22c1f0;
|
||||
Sun, 30 Aug 2026 05:30:39 +0000 (UTC)
|
||||
MIME-Version: 1.0
|
||||
Date: Sun, 30 Aug 2026 05:30:38 +0000 (UTC)
|
||||
Message-ID: <[email protected]>
|
||||
Subject: The
|
||||
bond
|
||||
market,
|
||||
again
|
||||
From: Ledger Lines
|
||||
<[email protected]>
|
||||
To:
|
||||
[email protected]
|
||||
List-Id: Ledger Lines <weekly.ledgerlines.example>
|
||||
List-Unsubscribe: <mailto:[email protected]>
|
||||
X-Campaign-Tags: bonds,rates,quarterly,long-form,subscriber-only,archive
|
||||
,resend,segment-b
|
||||
Content-Type: text/plain; charset=us-ascii
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Rates did the one thing nobody had written a note about, which is
|
||||
nothing at all. Here is what that means for the long end.
|
||||
|
||||
The full piece is on the site. This mail is the summary.
|
||||
@@ -0,0 +1,36 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 26 Aug 2026 08:00:00 -0600
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Your air pressure reminder
|
||||
From: Discount Tire <[email protected]>
|
||||
To: [email protected]
|
||||
List-Unsubscribe: <https://discounttire.example/u/4471>
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="5;url=https://discounttire.example/go">
|
||||
<link rel="stylesheet" href="https://discounttire.example/css/mail.css">
|
||||
<style>body { font-family: Arial, sans-serif }</style>
|
||||
<script>window.location = "https://discounttire.example/track?o=1";</script>
|
||||
</head>
|
||||
<body onload="fetch('https://discounttire.example/beacon')">
|
||||
<div style="background-image: url('https://discounttire.example/bg.png')">
|
||||
<p onclick="alert('opened')">It has been 30 days since your last check.
|
||||
Stop by any store for a free air pressure check, no appointment needed.</p>
|
||||
</div>
|
||||
<form action="https://discounttire.example/book" method="post">
|
||||
<input type="text" name="postcode" placeholder="Postcode">
|
||||
<button type="submit">Find a store</button>
|
||||
</form>
|
||||
<iframe src="https://discounttire.example/offer" width="600" height="200"
|
||||
frameborder="0"></iframe>
|
||||
<p><a href="javascript:void(document.location='https://evil.example')">Book
|
||||
now</a></p>
|
||||
<p><a href="https://discounttire.example/stores">Find a store near you</a></p>
|
||||
<object data="https://discounttire.example/flash.swf"></object>
|
||||
<embed src="https://discounttire.example/flash.swf">
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 14:22:07 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: The signature block, and one photo
|
||||
From: Clare Dunn <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/related; type="text/html"; boundary="_004_AM6PR03MB4419_"
|
||||
|
||||
--_004_AM6PR03MB4419_
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html><body>
|
||||
<p>The photo you asked for is below.</p>
|
||||
<p><img src="cid:[email protected]" width="600" height="400" alt="The site"></p>
|
||||
<p>Clare</p>
|
||||
</body></html>
|
||||
|
||||
--_004_AM6PR03MB4419_
|
||||
Content-Type: image/png; name="site.png"
|
||||
Content-Description: site.png
|
||||
Content-Disposition: attachment; filename="site.png"; size=70
|
||||
Content-ID: <[email protected]>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
|
||||
--_004_AM6PR03MB4419_--
|
||||
@@ -0,0 +1,75 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Sun, 30 Aug 2026 12:19:40 +0200
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Photos from the Hawaii trip
|
||||
From: Hannah Weiss <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/related; type="multipart/alternative";
|
||||
boundary="rel-hawaii-04"
|
||||
|
||||
--rel-hawaii-04
|
||||
Content-Type: multipart/alternative; boundary="alt-hawaii-04"
|
||||
|
||||
--alt-hawaii-04
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Finally went through them all. The ones from the ridge walk are the best
|
||||
of the lot, two of them are below and the rest are in the folder.
|
||||
|
||||
Hannah
|
||||
|
||||
--alt-hawaii-04
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<div>
|
||||
<p>Finally went through them all. The ones from the ridge walk are the
|
||||
best of the lot, two of them are below and the rest are in the folder.</p>
|
||||
<p><img src="cid:[email protected]" alt="The ridge at seven"
|
||||
width="16" height="16"></p>
|
||||
<p><img src="cid:[email protected]" alt="Looking back down"
|
||||
width="16" height="16"></p>
|
||||
<p>Hannah</p>
|
||||
</div>
|
||||
|
||||
--alt-hawaii-04--
|
||||
|
||||
--rel-hawaii-04
|
||||
Content-Type: image/png; name="ridge-walk-1.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <[email protected]>
|
||||
Content-Disposition: inline; filename="ridge-walk-1.png"
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0
|
||||
EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2
|
||||
RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3Z
|
||||
rcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxc
|
||||
QwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWS
|
||||
cmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6c
|
||||
n0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1Qy
|
||||
VTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Q
|
||||
ru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD
|
||||
5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV
|
||||
7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=
|
||||
|
||||
--rel-hawaii-04
|
||||
Content-Type: image/png; name="ridge-walk-2.png"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <[email protected]>
|
||||
Content-Disposition: inline; filename="ridge-walk-2.png"
|
||||
|
||||
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACJ0lEQVR42g3Loc6GIBQA0P9xvmg0
|
||||
EAgEAoFgYMxwA2MEAmOGm5jBwEwkRjA4EsnkA/6efv5+mkyazppRzbkWUkulF9DK6TVoQG127bL2
|
||||
RYemt1vj3w/IBHQGRoFzEBKkggVAOVgDAILZwWXwBUKD7YYvWDJZOltGLedWSCuVXcAqZ9dgAa3Z
|
||||
rcvWFxua3W77BU8mT2fPqOfcC+ml8gt45fwaPKA3u3fZ++JD89vtvxDJFOkcGY2cRyGjVHGBqFxc
|
||||
QwSMZo8uR19iaHG74xeQTEhnZBQ5RyFRKlwAlcM1ICCaHV1GXzA03G78QiJTonNiNHGehExSpQWS
|
||||
cmkNCTCZPbmcfEmhpe1OXzjIdND5YPTg/BDykOpY4FDuWMMBeJj9cPnw5Qjt2O7jCyeZTjqfjJ6c
|
||||
n0KeUp0LnMqdazgBT7OfLp++nKGd231+oZCp0LkwWjgvQhapygJFubKGAljMXlwuvpTQynaXL1Qy
|
||||
VTpXRivnVcgqVV2gKlfXUAGr2avL1ZcaWt3u+oWLTBedL0Yvzi8hL6muBS7lrjVcgJfZL5cvX67Q
|
||||
ru2+vtDJ1OncGe2cdyG7VH2BrlxfQwfsZu8ud196aH27+xcGmQadB6OD8yHkkGosMJQbaxiAw+zD
|
||||
5eHLCG1s9/jCQ6aHzg+jD+ePkI9UzwKPcs8aHsDH7I/Ljy9PaM92P194yfTS+WX05fwV8pXqXeBV
|
||||
7l3DC/ia/XX59eUN7d3uF/8BbBpFEE55jaYAAAAASUVORK5CYII=
|
||||
|
||||
--rel-hawaii-04--
|
||||
@@ -0,0 +1,65 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 11:04:51 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Invitation: Quarterly review @ Thu 15 Oct 2026 14:00 - 15:00 (BST)
|
||||
From: Nadia Osei <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/alternative; boundary="000000000000c1a4"
|
||||
|
||||
--000000000000c1a4
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
You have been invited to Quarterly review on Thursday 15 October 2026,
|
||||
14:00 to 15:00 London time.
|
||||
|
||||
--000000000000c1a4
|
||||
Content-Type: text/calendar; charset="UTF-8"; method=REQUEST
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
METHOD:REQUEST
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/London
|
||||
X-LIC-LOCATION:Europe/London
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0000
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:BST
|
||||
DTSTART:19700329T010000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0000
|
||||
TZNAME:GMT
|
||||
DTSTART:19701025T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DTSTART;TZID=Europe/London:20261015T140000
|
||||
DTEND;TZID=Europe/London:20261015T150000
|
||||
DTSTAMP:20260902T100451Z
|
||||
ORGANIZER;CN=Nadia Osei:mailto:[email protected]
|
||||
UID:[email protected]
|
||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
|
||||
TRUE;CN=Priyanshu Jain;X-NUM-GUESTS=0:mailto:[email protected]
|
||||
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Nadia
|
||||
Osei;X-NUM-GUESTS=0:mailto:[email protected]
|
||||
CREATED:20260902T100450Z
|
||||
DESCRIPTION:The quarterly numbers\, then the roadmap. Bring the deck.
|
||||
LAST-MODIFIED:20260902T100450Z
|
||||
LOCATION:Westferry\, meeting room 2
|
||||
SEQUENCE:0
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Quarterly review
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
--000000000000c1a4--
|
||||
@@ -0,0 +1,57 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 16:04:51 -0500
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Bill payment pending
|
||||
From: City Power <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Auto-Submitted: auto-generated
|
||||
Content-Type: multipart/mixed; boundary="outer-116400046977232"
|
||||
|
||||
--outer-116400046977232
|
||||
Content-Type: multipart/alternative; boundary="inner-116400046977232"
|
||||
|
||||
--inner-116400046977232
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Dear Customer,
|
||||
|
||||
Your one-time electronic payment of 98.26 for your City Power bill has
|
||||
been received and is pending. Your payment will be posted within two
|
||||
business days.
|
||||
|
||||
Confirmation number 116400046977232
|
||||
|
||||
Please keep this message for your records. If you did not make this
|
||||
payment, call us on the number printed on your statement.
|
||||
|
||||
Thank you,
|
||||
City Power Customer Care
|
||||
|
||||
--inner-116400046977232
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html><body style=3D"font-family:Arial,sans-serif">
|
||||
<p>Dear Customer,</p>
|
||||
<p>Your one-time electronic payment of <b>98.26</b> for your City Power =
|
||||
bill has been received and is pending. Your payment will be posted with=
|
||||
in two business days.</p>
|
||||
<p>Confirmation number 116400046977232</p>
|
||||
<p>Thank you,<br>City Power Customer Care</p>
|
||||
</body></html>
|
||||
|
||||
--inner-116400046977232--
|
||||
|
||||
--outer-116400046977232
|
||||
Content-Type: application/pdf; name="Statement-August.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="Statement-August.pdf"
|
||||
|
||||
JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBv
|
||||
Ymo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlw
|
||||
ZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNTk1IDg0Ml0+PmVuZG9iagp0cmFpbGVy
|
||||
PDwvUm9vdCAxIDAgUj4+CiUlRU9GCg==
|
||||
|
||||
--outer-116400046977232--
|
||||
@@ -0,0 +1,52 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Thu, 3 Sep 2026 06:00:03 +0100
|
||||
Message-ID: <01000198a2f4c1b2-9b0c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e@thebrowser.example>
|
||||
Subject: Five things worth reading this weekend
|
||||
From: The Browser <[email protected]>
|
||||
To: [email protected]
|
||||
List-Id: The Browser <the-browser.list.thebrowser.example>
|
||||
List-Unsubscribe: <mailto:[email protected]?subject=unsub>,
|
||||
<https://thebrowser.example/unsubscribe?u=91827&id=8f3a1c>
|
||||
List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
List-Post: NO
|
||||
Precedence: bulk
|
||||
Feedback-ID: 91827:the-browser:thebrowser
|
||||
Content-Type: multipart/alternative; boundary="b1-the-browser"
|
||||
|
||||
--b1-the-browser
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Good morning. It is a long weekend in some places and a wet one in most,
|
||||
so this edition leans toward pieces you can settle into. A long piece on
|
||||
the history of the pencil, a short one on why bridges hum, and three more.
|
||||
|
||||
THE PENCIL, AT LENGTH. Henry Petroski wrote a whole book about the pencil
|
||||
and this essay is the argument for why that was a reasonable thing to do.
|
||||
|
||||
WHY BRIDGES HUM. A short explanation of vortex shedding from an engineer
|
||||
who spent a career listening to cables.
|
||||
|
||||
Unsubscribe: https://thebrowser.example/unsubscribe?u=3D91827&id=3D8f3a1c
|
||||
|
||||
--b1-the-browser
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html><body style=3D"margin:0;padding:0">
|
||||
<p>Good morning. It is a long weekend in some places and a wet one in mo=
|
||||
st, so this edition leans toward pieces you can settle into.</p>
|
||||
<p><b>The pencil, at length.</b> Henry Petroski wrote a whole book about =
|
||||
the pencil and this essay is the argument for why that was a reasonable =
|
||||
thing to do. Graphite, cedar, the Napoleonic wars and a factory in Nurem=
|
||||
berg that still runs.</p>
|
||||
<p><b>Why bridges hum.</b> A short explanation of vortex shedding from an=
|
||||
engineer who spent a career listening to cables.</p>
|
||||
<p><b>The last typewriter repairman in Mumbai.</b> A profile that is real=
|
||||
ly about what it means to keep a trade going after the trade has gone.</p>
|
||||
<p><a href=3D"https://thebrowser.example/unsubscribe?u=3D91827&id=3D8=
|
||||
f3a1c">Unsubscribe</a></p>
|
||||
</body></html>
|
||||
|
||||
--b1-the-browser--
|
||||
@@ -0,0 +1,15 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 12 Aug 2026 11:02:19 +0530
|
||||
Subject: Enrolment received for Cooper
|
||||
From: Sunny Day Music <[email protected]>
|
||||
To: [email protected]
|
||||
X-Mailer: PHPMailer 5.2.9
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Enrolment received for Cooper. Term starts the week of 8 September and
|
||||
your teacher will be in touch to fix a weekly slot.
|
||||
|
||||
Nothing further is needed from you today. The term dates and the studio
|
||||
address are on the enrolment form you signed.
|
||||
@@ -0,0 +1,46 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 1 Sep 2026 16:41:09 +0100
|
||||
Message-ID: <AM0PR07MB5218F1C0D9E2A4E1B@AM0PR07MB5218.eurprd07.prod.outlook.com>
|
||||
In-Reply-To: <[email protected]>
|
||||
References: <[email protected]>
|
||||
Subject: RE: The lease, clause 14
|
||||
From: "Young, Russell" <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/alternative; boundary="_000_AM0PR07MB5218_"
|
||||
|
||||
--_000_AM0PR07MB5218_
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Clause 14 is the standard form. I would not sign it as drafted.
|
||||
|
||||
Russell
|
||||
|
||||
-----Original Message-----
|
||||
From: Priyanshu Jain <[email protected]>
|
||||
Sent: Tuesday, 1 September 2026 12:00
|
||||
To: Young, Russell <[email protected]>
|
||||
Subject: The lease, clause 14
|
||||
|
||||
Russell, is clause 14 negotiable or is it the landlord's standard form?
|
||||
|
||||
--_000_AM0PR07MB5218_
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html><body>
|
||||
<p>Clause 14 is the standard form. I would not sign it as drafted.</p>
|
||||
<p>Russell</p>
|
||||
<div style=3D"border:none;border-top:solid #E1E1E1 1.0pt;padding:3.0pt 0cm =
|
||||
0cm 0cm">
|
||||
<hr>
|
||||
<div id=3D"divRplyFwdMsg">
|
||||
<p><b>From:</b> Priyanshu Jain <[email protected]><br>
|
||||
<b>Sent:</b> Tuesday, 1 September 2026 12:00<br>
|
||||
<b>Subject:</b> The lease, clause 14</p>
|
||||
<p>Russell, is clause 14 negotiable or is it the landlord's standard form?</p>
|
||||
</div></div>
|
||||
</body></html>
|
||||
|
||||
--_000_AM0PR07MB5218_--
|
||||
@@ -0,0 +1,21 @@
|
||||
Return-Path: <[email protected]>
|
||||
Received: from mail-pl1-f174.example.com (mail-pl1-f174.example.com [209.85.214.174])
|
||||
by mx.73ai.org with ESMTPS id 4c1f9a2b3d
|
||||
for <[email protected]>; Thu, 3 Sep 2026 11:42:14 +0530 (IST)
|
||||
MIME-Version: 1.0
|
||||
Date: Thu, 3 Sep 2026 11:42:09 +0530
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Dinner on Thursday?
|
||||
From: Maya Raghunathan <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Priya said the place on Church Street takes bookings now, want me to put
|
||||
us down for four at eight? Everyone can make Thursday except Karthik, who
|
||||
has the badminton thing and says he will come late.
|
||||
|
||||
If you would rather somewhere quieter there is the Malleswaram place we
|
||||
went to in June. Say by tomorrow and I will call them.
|
||||
|
||||
Maya
|
||||
@@ -0,0 +1,24 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 09:30:00 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Subject: =?utf-8?Q?September=3A_the_notebook_that_survived_a_washin?=
|
||||
=?utf-8?Q?g_machine?=
|
||||
From: Field Notes Dispatch <[email protected]>
|
||||
To: [email protected]
|
||||
List-Id: <dispatch.fieldnotes.example>
|
||||
List-Unsubscribe: <https://fieldnotes.example/u/7712>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
A reader in Troms=C3=B8 sent us a photo of a pocket noteb=
|
||||
ook that went through a full cycle at forty degrees, in a coat, with the=
|
||||
coat. Every page is still legible.
|
||||
|
||||
Also this month: the autumn edition ships on the 15th. Three colours, th=
|
||||
e usual count. The paper weight is unchanged at 100 g/m=C2=B2, and the p=
|
||||
rice stays at =E2=82=AC12 for the three pack.
|
||||
|
||||
The equals sign is written =3D when it stands alone, which is the part o=
|
||||
f this encoding that catches parsers out. Trailing whitespace is kept=20
|
||||
by encoding it, like the space at the end of the previous line.
|
||||
@@ -0,0 +1,45 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 21:14:55 +0530
|
||||
Message-ID: <[email protected]>
|
||||
In-Reply-To: <[email protected]>
|
||||
References: <[email protected]>
|
||||
<[email protected]>
|
||||
Subject: Re: Slides from the talk
|
||||
From: Dev Patel <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/alternative; boundary="alt-slides-03"
|
||||
|
||||
--alt-slides-03
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Here they are, plus the reading list I mentioned. The paper on cache
|
||||
oblivious layouts is the one to start with.
|
||||
|
||||
--alt-slides-03
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div dir="ltr">Here they are, plus the reading list I mentioned. The paper
|
||||
on cache oblivious layouts is the one to start with.</div>
|
||||
<br>
|
||||
<div class="gmail_quote">
|
||||
<div dir="ltr" class="gmail_attr">On Mon, 31 Aug 2026 at 18:40, Priyanshu
|
||||
Jain <<a href="mailto:[email protected]">[email protected]</a>> wrote:</div>
|
||||
<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px
|
||||
solid #ccc;padding-left:1ex">
|
||||
<div dir="ltr">Good talk. Could you send the slides, and the reading list
|
||||
you put up at the end?</div>
|
||||
<br>
|
||||
<div class="gmail_quote">
|
||||
<div dir="ltr" class="gmail_attr">On Fri, 28 Aug 2026 at 12:02, Dev Patel
|
||||
<<a href="mailto:[email protected]">[email protected]</a>>
|
||||
wrote:</div>
|
||||
<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px
|
||||
solid #ccc;padding-left:1ex">
|
||||
<div dir="ltr">Talk is on Monday at six, room 4.02. Come if you are free.</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
--alt-slides-03--
|
||||
@@ -0,0 +1,33 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 15:40:02 +0530
|
||||
Message-ID: <[email protected]>
|
||||
In-Reply-To: <[email protected]>
|
||||
References: <[email protected]>
|
||||
<[email protected]>
|
||||
Subject: Re: Fw: (no subject)
|
||||
From: Sam Okafor <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Got it, thank you. Wednesdays at five work for us.
|
||||
|
||||
The first lesson is on the 9th, nothing to prepare. The invitation is
|
||||
attached, it should land in your calendar.
|
||||
|
||||
Sam
|
||||
|
||||
On Mon, 31 Aug 2026 at 09:12, Priyanshu Jain <[email protected]> wrote:
|
||||
> Hi Sam, form signed and attached. Would a weekday after school suit
|
||||
> Cooper? He finishes at four and we are ten minutes away on foot.
|
||||
>
|
||||
> On Wed, 12 Aug 2026 at 11:02, Sunny Day Music
|
||||
> <[email protected]> wrote:
|
||||
> > Enrolment received for Cooper. Term starts the week of 8 September
|
||||
> > and your teacher will be in touch to fix a weekly slot.
|
||||
> >
|
||||
> > Nothing further is needed from you today.
|
||||
>
|
||||
> Thanks,
|
||||
> Priyanshu
|
||||
@@ -0,0 +1,18 @@
|
||||
Return-Path: <>
|
||||
MIME-Version: 1.0
|
||||
Date: Thu, 3 Sep 2026 06:30:18 +0000
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Your receipt
|
||||
From: Spotify <[email protected]>
|
||||
To: [email protected]
|
||||
Auto-Submitted: auto-generated
|
||||
Precedence: bulk
|
||||
X-Entity-Ref-ID: 8f21c0aa-receipt
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Thank you for purchasing Spotify Premium. Amount charged 11.99, on the
|
||||
card ending 4417. Your next payment is due on 3 October 2026.
|
||||
|
||||
This mailbox is not monitored. Manage your subscription from the account
|
||||
page.
|
||||
@@ -0,0 +1,26 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Thu, 3 Sep 2026 10:15:02 +0530
|
||||
Message-ID: <[email protected]>
|
||||
In-Reply-To: <[email protected]>
|
||||
References: <[email protected]>
|
||||
<[email protected]>
|
||||
<[email protected]>
|
||||
Subject: Re: Lease renewal for the studio
|
||||
From: Arun Kulkarni <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Cc: Meridian Leasing <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Hi Priyanshu,
|
||||
|
||||
Attached the revised draft. The only change is clause 7, which now reads
|
||||
three months either side rather than six. The rent review in clause 12
|
||||
stays as it was, tied to the index and capped at four percent.
|
||||
|
||||
If that works, sign when you get a moment and I will countersign the same
|
||||
day. Happy to walk through anything on a call, Thursday afternoon is open.
|
||||
|
||||
Best,
|
||||
Arun
|
||||
@@ -0,0 +1,33 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 09:12:00 +0200
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Kitchen bench quote
|
||||
From: Lena Brandt <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: multipart/mixed; boundary="----=_Part_9021_1471903384.1756800720"
|
||||
|
||||
------=_Part_9021_1471903384.1756800720
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
The quote is attached. Prices hold for thirty days.
|
||||
|
||||
Lena
|
||||
|
||||
------=_Part_9021_1471903384.1756800720
|
||||
Content-Type: application/pdf;
|
||||
name*0*=utf-8'de'Angebot%20K%C3%BCchenarbeitsplatte%20;
|
||||
name*1*=Eiche%20massiv%202026.pdf
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment;
|
||||
filename*0*=utf-8'de'Angebot%20K%C3%BCchenarbeitsplatte%20;
|
||||
filename*1*=Eiche%20massiv%202026.pdf;
|
||||
size=248
|
||||
|
||||
JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBv
|
||||
Ymo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlw
|
||||
ZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNTk1IDg0Ml0+PmVuZG9iagp0cmFpbGVy
|
||||
PDwvUm9vdCAxIDAgUj4+CiUlRU9GCg==
|
||||
|
||||
------=_Part_9021_1471903384.1756800720--
|
||||
@@ -0,0 +1,18 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 09:48:12 -0500
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Re: Life insurance quote
|
||||
From: Todd Markham <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Hello Ms. Young, I hope all is well with you. I wanted to touch base on
|
||||
our conversation from a few weeks ago.
|
||||
|
||||
Following up on the quote I sent through last week for the twenty year
|
||||
term policy. If the premium looks right, I can start the paperwork
|
||||
whenever you are ready.
|
||||
|
||||
Todd
|
||||
@@ -0,0 +1,18 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 1 Sep 2026 19:22:41 -0700
|
||||
Message-ID: <[email protected]>
|
||||
Subject: You have an invitation from Robyn Madison
|
||||
From: Evite on behalf of Robyn Madison <[email protected]>
|
||||
Reply-To: Robyn Madison <[email protected]>
|
||||
Sender: <[email protected]>
|
||||
To: [email protected]
|
||||
Precedence: bulk
|
||||
List-Unsubscribe: <https://evite.example/optout/99120>
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
Join us for Jack's 8th birthday! Robyn Madison needs your RSVP.
|
||||
|
||||
Saturday 19 September, from two in the afternoon, at the park by the
|
||||
lake. Bring nothing but a hat.
|
||||
@@ -0,0 +1,16 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 17:05:33 +0200
|
||||
Message-ID: <[email protected]>
|
||||
Subject: The worktop, and one more thing
|
||||
From: Lena Brandt <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<div style="font-family:Arial,sans-serif;color:#1c1c1c">
|
||||
<p style="color:#222222">The worktop is oiled rather than lacquered, as we agreed.</p>
|
||||
<p style="color:#d32f2f">The delivery slot moved to the fourteenth.</p>
|
||||
<p><font color="#000000">Everything else is unchanged.</font></p>
|
||||
<p style="color:#666666">Lena Brandt, Brandt Tischlerei</p>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 06:15:00 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: The autumn box is back
|
||||
From: Harbour Market <[email protected]>
|
||||
To: [email protected]
|
||||
List-Id: Harbour Market <news.harbourmarket.example>
|
||||
Precedence: bulk
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html><body>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-image:url(https://cdn.harbourmarket.example/paper.png)">
|
||||
<tr><td align="center" style="padding:20px 0">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="padding:28px;color:#2b2b2b;font-family:Georgia,serif">
|
||||
<p style="margin:0 0 12px">The autumn box is back, and it is heavier than last year.</p>
|
||||
<p style="margin:0"><a href="https://harbourmarket.example/boxes">See what is in it</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Thu, 3 Sep 2026 07:00:11 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: The weekend edition
|
||||
From: The Long Read <[email protected]>
|
||||
To: [email protected]
|
||||
List-Id: The Long Read <weekly.thelongread.example>
|
||||
List-Unsubscribe: <https://thelongread.example/unsubscribe?u=4471>
|
||||
Precedence: bulk
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html><body style="margin:0;padding:0">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f4f1ea">
|
||||
<tr><td align="center" style="padding:24px 0">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff">
|
||||
<tr><td style="padding:32px">
|
||||
<h1 style="font-family:Georgia,serif;font-size:26px;color:#1a1a1a;margin:0 0 18px">The weekend edition</h1>
|
||||
<p style="font-family:Georgia,serif;font-size:16px;color:#333333;margin:0 0 14px">Three pieces to settle into, and one to argue with.</p>
|
||||
<p style="margin:0 0 14px"><a href="https://thelongread.example/pencil" style="color:#8a5a2b">The pencil, at length</a></p>
|
||||
<p style="margin:0"><a href="https://thelongread.example/bridges" style="color:#8a5a2b">Why bridges hum</a></p>
|
||||
</td></tr>
|
||||
<tr><td bgcolor="#efece4" style="padding:18px 32px;font-size:12px;color:#6b6b6b">
|
||||
You are getting this because you asked for it.
|
||||
<a href="https://thelongread.example/unsubscribe?u=4471">Unsubscribe</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Wed, 2 Sep 2026 11:14:02 +0530
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Three things before Thursday
|
||||
From: Arun Mehta <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
X-Mailer: Mailspring
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<div>
|
||||
<p>Morning Priyanshu,</p>
|
||||
<p>Three things before Thursday, none of them <b>urgent</b>:</p>
|
||||
<ul>
|
||||
<li>The <b>revised lease</b> is with the landlord's solicitor.</li>
|
||||
<li>The service charge reconciliation went out on Monday.</li>
|
||||
<li>Parking bay 12 is yours from the first.</li>
|
||||
</ul>
|
||||
<p>Links, in case you want them now rather than Thursday:
|
||||
<a href="https://meridianproperties.example/lease/8812">the draft</a>,
|
||||
<a href="https://meridianproperties.example/charges/2026">the reconciliation</a>,
|
||||
<a href="https://meridianproperties.example/parking">the bay plan</a> and the
|
||||
<a href="https://meridianproperties.example/contact">office number</a>.</p>
|
||||
<p>Best,<br>Arun</p>
|
||||
<div>
|
||||
<p>Arun Mehta<br>
|
||||
Meridian Properties<br>
|
||||
+91 22 6100 4400</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Fri, 4 Sep 2026 09:30:00 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Tonight at the Station House
|
||||
From: Station House <[email protected]>
|
||||
To: [email protected]
|
||||
List-Id: Station House <news.stationhouse.example>
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html><head><style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.shell { background-color: #12100d !important; }
|
||||
.copy { color: #ece6da !important; }
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<p class="copy" style="color:#1f1f1f">Doors at seven, the band at eight.</p>
|
||||
<p class="copy" style="color:#1f1f1f">Tickets on the door only, cash or card.</p>
|
||||
<p style="color:#d32f2f">Sold out on Saturday.</p>
|
||||
</div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,19 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 1 Sep 2026 08:22:47 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Your receipt from Bramble Coffee
|
||||
From: Bramble Coffee <[email protected]>
|
||||
To: [email protected]
|
||||
Auto-Submitted: auto-generated
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<div style="background-color:#faf7f0;padding:28px;font-family:Helvetica,Arial,sans-serif">
|
||||
<div style="max-width:520px;margin:0 auto">
|
||||
<p style="font-size:18px;margin:0 0 16px">Thanks, that is paid.</p>
|
||||
<p style="margin:0 0 8px">Flat white and a cardamom bun</p>
|
||||
<p style="margin:0 0 16px">£6.40 on the card ending 4417</p>
|
||||
<p style="font-size:12px;color:#7a7a7a;margin:0">Bramble Coffee, 14 Rivington Street</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Mon, 31 Aug 2026 12:02:44 +0530
|
||||
Message-ID: <[email protected]>
|
||||
Subject: The studio, one more thing
|
||||
From: Arun Kulkarni <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
X-HS-Marketing-Email: true
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
<html><body>
|
||||
<p>Priyanshu, the floor plan you asked for is below. The measurements are
|
||||
from the survey in June, not the original drawing.</p>
|
||||
<p><img src="https://meridianproperties.in/img/studio-floorplan.jpg"
|
||||
width="640" height="420" alt="Studio floor plan"></p>
|
||||
<p>Arun</p>
|
||||
<img src="https://track.hubspot.com/__ptq.gif?k=8812&r=pj%4073ai.org"
|
||||
width="1" height="1" alt="" style="border:0;display:block">
|
||||
<img src="https://meridianproperties.in/o/open.png?id=8812" width="1"
|
||||
height="1" alt="">
|
||||
</body></html>
|
||||
@@ -0,0 +1,19 @@
|
||||
Return-Path: <[email protected]>
|
||||
MIME-Version: 1.0
|
||||
Date: Fri, 4 Sep 2026 08:12:00 +0100
|
||||
Message-ID: <[email protected]>
|
||||
Subject: Instruções para a chegada, com um café à espera
|
||||
From: Inês Carvalho <[email protected]>
|
||||
To: Priyanshu Jain <[email protected]>
|
||||
X-Original-Subject-Encoding: 8bit
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
Olá Priyanshu,
|
||||
|
||||
O código da porta é 4417 e o elevador está do lado direito. Deixo café e
|
||||
pão na cozinha para a manhã seguinte.
|
||||
|
||||
Se o comboio atrasar, telefone. Não há problema nenhum.
|
||||
|
||||
Inês
|
||||
@@ -0,0 +1,365 @@
|
||||
// Which Google accounts this install has a token for, and the handful of facts about each one
|
||||
// that are not mail.
|
||||
//
|
||||
// A file, `accounts.json` in the app data directory, rather than a table. Every account owns a
|
||||
// database of its own under `accounts/<id>/`, so a table of accounts would have to live in one of
|
||||
// them and be read before the others could be opened, and the first thing the app does on launch
|
||||
// is ask which accounts there are. A file answers that before any database is open.
|
||||
//
|
||||
// What is not here matters as much as what is. The refresh token is sealed in `google::secrets`
|
||||
// and never written to this file. The storage window and the signature belong to settings and to
|
||||
// the state database, which are the places that already own per account decisions.
|
||||
//
|
||||
// This is the list of accounts that have a token. `Db::on_disk` is the list that have a database,
|
||||
// and a removal takes the entry here before it touches anything else, so the two lists differ for
|
||||
// as long as a removal takes and neither is authoritative on its own.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::dto::{Account, AccountKind, MailConfig};
|
||||
use crate::library::{app_data_dir, atomic_write};
|
||||
|
||||
const FILE: &str = "accounts.json";
|
||||
|
||||
/// The stylesheet defines `--hue-1` to `--hue-8`, so those are the only values worth storing.
|
||||
const HUES: usize = 8;
|
||||
|
||||
/// One row of the registry. `granted_scopes` is what Google said it granted, not what was asked
|
||||
/// for, because granular consent means those are different lists.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Entry {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
/// Absent in a registry written before there was a second kind, which is what the default is
|
||||
/// for: every account that predates IMAP is a Google one, and the file is upgraded in place
|
||||
/// the next time anything writes it.
|
||||
#[serde(default)]
|
||||
pub kind: AccountKind,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub granted_scopes: Vec<String>,
|
||||
/// An IMAP account's servers. `None` on a Google account, where the mailbox is reached over
|
||||
/// the API and there is nothing to configure. The passwords are not here: they are sealed
|
||||
/// alongside the refresh tokens in `google::secrets`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub servers: Option<MailConfig>,
|
||||
pub added_ms: i64,
|
||||
}
|
||||
|
||||
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(app_data_dir(app)?.join(FILE))
|
||||
}
|
||||
|
||||
pub fn list(app: &tauri::AppHandle) -> Result<Vec<Account>, String> {
|
||||
Ok(read(&path(app)?)?
|
||||
.into_iter()
|
||||
.map(|entry| account(app, entry))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn find(app: &tauri::AppHandle, account_id: &str) -> Result<Option<Entry>, String> {
|
||||
Ok(read(&path(app)?)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.id == account_id))
|
||||
}
|
||||
|
||||
/// What Google granted this account, for the features that have to check before they act rather
|
||||
/// than discover it as a 403 halfway through.
|
||||
pub fn granted_scopes(app: &tauri::AppHandle, account_id: &str) -> Result<Vec<String>, String> {
|
||||
find(app, account_id)?
|
||||
.map(|entry| entry.granted_scopes)
|
||||
.ok_or_else(|| format!("Account {account_id} is not connected."))
|
||||
}
|
||||
|
||||
/// A completed consent, arriving from `google::auth::finish`.
|
||||
///
|
||||
/// An account that is already here keeps its name and its colour: consent is re-run to pick up a
|
||||
/// scope, and having the avatar change colour because somebody agreed to a calendar permission
|
||||
/// would be a strange thing to watch. The scopes are replaced wholesale, since the new grant is
|
||||
/// the only one that exists now.
|
||||
pub fn upsert(
|
||||
app: &tauri::AppHandle,
|
||||
id: &str,
|
||||
email: &str,
|
||||
name: &str,
|
||||
granted_scopes: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let path = path(app)?;
|
||||
let mut entries = read(&path)?;
|
||||
match entries.iter_mut().find(|entry| entry.id == id) {
|
||||
Some(entry) => {
|
||||
entry.email = email.to_string();
|
||||
entry.granted_scopes = granted_scopes;
|
||||
}
|
||||
None => {
|
||||
restore_kept(app, id)?;
|
||||
let color = next_hue(&entries);
|
||||
entries.push(Entry {
|
||||
id: id.to_string(),
|
||||
email: email.to_string(),
|
||||
kind: AccountKind::Google,
|
||||
name: name.to_string(),
|
||||
color,
|
||||
granted_scopes,
|
||||
servers: None,
|
||||
added_ms: chrono::Utc::now().timestamp_millis(),
|
||||
});
|
||||
}
|
||||
}
|
||||
write(&path, &entries)
|
||||
}
|
||||
|
||||
/// An IMAP account arriving from `imap::connect`, which is the other way into this registry.
|
||||
///
|
||||
/// Deliberately a second function rather than a wider `upsert`: a Google account has scopes and no
|
||||
/// servers, an IMAP account has servers and no scopes, and one function taking both would be four
|
||||
/// arguments that are each meaningless in half the calls.
|
||||
pub fn upsert_imap(
|
||||
app: &tauri::AppHandle,
|
||||
id: &str,
|
||||
email: &str,
|
||||
name: &str,
|
||||
servers: MailConfig,
|
||||
) -> Result<(), String> {
|
||||
let path = path(app)?;
|
||||
let mut entries = read(&path)?;
|
||||
match entries.iter_mut().find(|entry| entry.id == id) {
|
||||
Some(entry) => {
|
||||
entry.email = email.to_string();
|
||||
entry.servers = Some(servers);
|
||||
}
|
||||
None => {
|
||||
restore_kept(app, id)?;
|
||||
let color = next_hue(&entries);
|
||||
entries.push(Entry {
|
||||
id: id.to_string(),
|
||||
email: email.to_string(),
|
||||
kind: AccountKind::Imap,
|
||||
name: name.to_string(),
|
||||
color,
|
||||
granted_scopes: Vec::new(),
|
||||
servers: Some(servers),
|
||||
added_ms: chrono::Utc::now().timestamp_millis(),
|
||||
});
|
||||
}
|
||||
}
|
||||
write(&path, &entries)
|
||||
}
|
||||
|
||||
/// An account that was removed with its data kept gets that data back on its way in, before the
|
||||
/// first sync pass can write a fresh, empty pair over the place it belongs. `Db` is absent in a
|
||||
/// test and in any build that has not opened one yet, when there is nothing set aside either.
|
||||
fn restore_kept(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||
match app.try_state::<crate::db::Db>() {
|
||||
Some(db) => db.restore(id),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(app: &tauri::AppHandle, account_id: &str) -> Result<(), String> {
|
||||
let path = path(app)?;
|
||||
let mut entries = read(&path)?;
|
||||
let before = entries.len();
|
||||
entries.retain(|entry| entry.id != account_id);
|
||||
if entries.len() == before {
|
||||
return Ok(());
|
||||
}
|
||||
write(&path, &entries)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn accounts_list(app: tauri::AppHandle) -> Result<Vec<Account>, String> {
|
||||
list(&app)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn account_set_color(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
color: String,
|
||||
) -> Result<(), String> {
|
||||
let path = path(&app)?;
|
||||
let mut entries = read(&path)?;
|
||||
let entry = entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.id == account_id)
|
||||
.ok_or_else(|| format!("Account {account_id} is not connected."))?;
|
||||
entry.color = hue(&color)?;
|
||||
write(&path, &entries)?;
|
||||
crate::emit_store_changed(&app, "accounts");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn account_set_name(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
let path = path(&app)?;
|
||||
let mut entries = read(&path)?;
|
||||
let entry = entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.id == account_id)
|
||||
.ok_or_else(|| format!("Account {account_id} is not connected."))?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("An account needs a name.".to_string());
|
||||
}
|
||||
entry.name = name.to_string();
|
||||
write(&path, &entries)?;
|
||||
crate::emit_store_changed(&app, "accounts");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every account in this file has a token, so `connected` is true without going and looking. A
|
||||
/// probe would be a file read and a key derivation on every `store-changed`, which sync emits on
|
||||
/// every pass, for an answer nothing acts on: a secret that has really gone surfaces at the next
|
||||
/// `valid_access_token` as a sync error, which is the honest place to learn it.
|
||||
///
|
||||
/// `window_days` is not this file's to know. The window is a device setting and `settings.json`
|
||||
/// owns it; this registry owns the id, the address, the name, the colour and the grant. The DTO
|
||||
/// carries both, so the value is fetched from the one that owns it on the way past.
|
||||
fn account(app: &tauri::AppHandle, entry: Entry) -> Account {
|
||||
Account {
|
||||
window_days: crate::settings::window_days(app, &entry.id),
|
||||
id: entry.id,
|
||||
email: entry.email,
|
||||
kind: entry.kind,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
connected: true,
|
||||
granted_scopes: entry.granted_scopes,
|
||||
}
|
||||
}
|
||||
|
||||
fn hue(color: &str) -> Result<String, String> {
|
||||
let n = color.strip_prefix("hue-").and_then(|n| n.parse::<usize>().ok());
|
||||
match n {
|
||||
Some(n) if (1..=HUES).contains(&n) => Ok(color.to_string()),
|
||||
// The stylesheet owns the value: a hex stored here would reach the avatar as an unknown
|
||||
// token name and render as nothing at all.
|
||||
_ => Err(format!("{color} is not one of hue-1 to hue-{HUES}.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest hue nobody is using, rather than the next one along, so removing an account and
|
||||
/// adding another reuses the colour that went spare instead of leaving two accounts to collide
|
||||
/// eight connections later. The avatar hue is how the unified inbox says whose mail a row is, so
|
||||
/// two accounts sharing one while a colour is going spare is the thing to avoid.
|
||||
fn next_hue(entries: &[Entry]) -> String {
|
||||
for n in 1..=HUES {
|
||||
let hue = format!("hue-{n}");
|
||||
if !entries.iter().any(|entry| entry.color == hue) {
|
||||
return hue;
|
||||
}
|
||||
}
|
||||
format!("hue-{}", entries.len() % HUES + 1)
|
||||
}
|
||||
|
||||
/// A missing file is an install with no accounts, which is every install once. A malformed one is
|
||||
/// not: it is the only record of which accounts exist, and overwriting it would silently orphan
|
||||
/// every sealed token and every database on disk.
|
||||
fn read(path: &Path) -> Result<Vec<Entry>, String> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
serde_json::from_str(&text).map_err(|e| format!("could not read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn write(path: &Path, entries: &[Entry]) -> Result<(), String> {
|
||||
let text = serde_json::to_string_pretty(entries).map_err(|e| e.to_string())?;
|
||||
atomic_write(path, text.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: &str, color: &str) -> Entry {
|
||||
Entry {
|
||||
id: id.to_string(),
|
||||
email: format!("{id}@example.test"),
|
||||
kind: AccountKind::Google,
|
||||
name: id.to_string(),
|
||||
color: color.to_string(),
|
||||
granted_scopes: vec!["https://www.googleapis.com/auth/gmail.modify".to_string()],
|
||||
servers: None,
|
||||
added_ms: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registry_survives_a_write_and_a_read() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join(FILE);
|
||||
|
||||
let written = vec![entry("11829", "hue-1"), entry("44021", "hue-2")];
|
||||
write(&path, &written).expect("write");
|
||||
|
||||
let read_back = read(&path).expect("read");
|
||||
assert_eq!(read_back.len(), 2);
|
||||
assert_eq!(read_back[1].id, "44021");
|
||||
assert_eq!(read_back[1].email, "[email protected]");
|
||||
assert_eq!(read_back[1].color, "hue-2");
|
||||
assert_eq!(
|
||||
read_back[0].granted_scopes,
|
||||
vec!["https://www.googleapis.com/auth/gmail.modify".to_string()]
|
||||
);
|
||||
assert_eq!(read_back[0].added_ms, 1_700_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_file_yet_is_an_install_with_no_accounts() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
assert!(read(&dir.path().join(FILE)).expect("read").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_registry_is_an_error_rather_than_an_empty_list() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join(FILE);
|
||||
std::fs::write(&path, "{ not json").expect("write");
|
||||
assert!(read(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hues_are_handed_out_in_order() {
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
for n in 1..=HUES {
|
||||
let hue = next_hue(&entries);
|
||||
assert_eq!(hue, format!("hue-{n}"));
|
||||
entries.push(entry(&format!("account-{n}"), &hue));
|
||||
}
|
||||
// A ninth account has to share, and starts the cycle again rather than inventing hue-9.
|
||||
assert_eq!(next_hue(&entries), "hue-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_accounts_hue_is_reused_before_a_new_one() {
|
||||
let mut entries = vec![
|
||||
entry("a", "hue-1"),
|
||||
entry("b", "hue-2"),
|
||||
entry("c", "hue-3"),
|
||||
];
|
||||
entries.retain(|entry| entry.id != "b");
|
||||
assert_eq!(next_hue(&entries), "hue-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_eight_token_names_are_a_colour() {
|
||||
assert_eq!(hue("hue-1").expect("hue-1"), "hue-1");
|
||||
assert_eq!(hue("hue-8").expect("hue-8"), "hue-8");
|
||||
assert!(hue("hue-9").is_err());
|
||||
assert!(hue("hue-0").is_err());
|
||||
assert!(hue("#8c6f4a").is_err());
|
||||
assert!(hue("").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
// Bytes fetched because somebody asked for them: the pictures in a message, and the files hanging
|
||||
// off it.
|
||||
//
|
||||
// Nothing here runs during a sync. A message arrives as headers, its body arrives when the thread
|
||||
// is opened, and everything in this file happens later still, when a reader presses something. The
|
||||
// two halves sit together because they are the same job under two names: fetch on demand, cap what
|
||||
// is fetched, and never let the webview make a request of its own.
|
||||
//
|
||||
// Remote images are the reason that last clause is written down. The sanitiser has no network by
|
||||
// design, so the only way to show a picture is for Rust to fetch it, without cookies and without a
|
||||
// referrer, and hand the bytes back through `RenderOptions::remote_images`. The sender learns that
|
||||
// somebody asked, once, from an address, and nothing else: no repeat opens, no forwarded reads,
|
||||
// and no correlation with anything else the app does.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||
use rusqlite::Connection;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::dto::MessageView;
|
||||
use crate::mime::{self, RenderOptions, Rendered};
|
||||
use crate::mirror::read::{self, AttachmentRow};
|
||||
use crate::mirror::write;
|
||||
use crate::sync::{self, Remote};
|
||||
|
||||
/// How many remote images one message may have before the answer is a sentence rather than a
|
||||
/// download. A newsletter has a dozen; a body with hundreds is either broken or a probe, and
|
||||
/// either way nobody wants to wait for it.
|
||||
const MAX_IMAGES: usize = 60;
|
||||
/// Per image, and in total. A picture in a message is a picture, not a payload.
|
||||
const MAX_IMAGE_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_IMAGES_TOTAL_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// How many of one message's pictures are in flight together. A newsletter spreads them over two
|
||||
/// or three hosts, and one host that will not answer must not hold the others behind it, which is
|
||||
/// what fetching them one after another did: a dead CDN was a full timeout per picture, in a row,
|
||||
/// while the reader watched a button that had not changed.
|
||||
const IMAGES_AT_ONCE: usize = 8;
|
||||
|
||||
/// The ceiling on a `data:` URI. Base64 costs a third on top, the whole string crosses the IPC
|
||||
/// boundary as JSON and is then held in the webview, so a preview of something enormous is a hang
|
||||
/// with a progress bar in front of it. Above this the answer is Save.
|
||||
const MAX_DATA_URL_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// What the cache is allowed to hold when the settings file cannot be read. It matches
|
||||
/// `settings::defaults`, so an unreadable file behaves like a fresh install rather than like no
|
||||
/// cache at all.
|
||||
const DEFAULT_CACHE_MB: u64 = 512;
|
||||
|
||||
const CACHE_DIR: &str = "attachments";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Remote images
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// No cookie store is compiled into this build at all, so there is nothing to send even by
|
||||
/// accident, and `referer(false)` keeps a redirect from naming the URL it came from. The timeouts
|
||||
/// are short: this runs while somebody is looking at the message, and a picture that has not
|
||||
/// arrived in ten seconds is a picture that is not coming.
|
||||
fn image_client() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.referer(false)
|
||||
.redirect(reqwest::redirect::Policy::limited(3))
|
||||
.build()
|
||||
.expect("could not build the image client")
|
||||
})
|
||||
}
|
||||
|
||||
/// The remote images this body would like, or a refusal. Rendering with images off is what names
|
||||
/// them, so the list is the sanitiser's own and not a second scan of the markup.
|
||||
fn wanted(raw: &[u8], options: &RenderOptions) -> Result<Vec<String>, String> {
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
for url in mime::render(raw, options)?.blocked_urls {
|
||||
if !urls.contains(&url) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
if urls.len() > MAX_IMAGES {
|
||||
return Err(format!(
|
||||
"This message asks for {} remote images, which is more than Margin Mail will fetch at \
|
||||
once ({MAX_IMAGES}). None were fetched.",
|
||||
urls.len()
|
||||
));
|
||||
}
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
/// The same body again with the fetched bytes in hand. The trackers stay removed: that rule lives
|
||||
/// in the sanitiser and this passes through it rather than around it, because "show me the
|
||||
/// pictures" is not "file an open report".
|
||||
fn shown(
|
||||
raw: &[u8],
|
||||
options: &RenderOptions,
|
||||
fetched: HashMap<String, Vec<u8>>,
|
||||
) -> Result<Rendered, String> {
|
||||
let options = RenderOptions {
|
||||
allow_remote_images: true,
|
||||
remote_images: fetched,
|
||||
..options.clone()
|
||||
};
|
||||
mime::render(raw, &options)
|
||||
}
|
||||
|
||||
/// One image, capped. Anything that fails, redirects too far or runs over the cap is left blocked
|
||||
/// rather than reported: a picture that would not come is a picture that is not there.
|
||||
async fn fetch_image(url: &str) -> Option<Vec<u8>> {
|
||||
let mut response = image_client().get(url).send().await.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match response.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
if bytes.len() + chunk.len() > MAX_IMAGE_BYTES {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
(!bytes.is_empty()).then_some(bytes)
|
||||
}
|
||||
|
||||
/// All of them, `IMAGES_AT_ONCE` at a time, in whatever order they arrive. Dropping the stream
|
||||
/// once the total cap is reached is what cancels the fetches still in flight.
|
||||
async fn fetch_images(urls: &[String]) -> HashMap<String, Vec<u8>> {
|
||||
let mut fetched = HashMap::new();
|
||||
let mut total = 0usize;
|
||||
let mut results = stream::iter(urls.iter().cloned())
|
||||
.map(|url| async move {
|
||||
let bytes = fetch_image(&url).await;
|
||||
(url, bytes)
|
||||
})
|
||||
.buffer_unordered(IMAGES_AT_ONCE);
|
||||
while let Some((url, bytes)) = results.next().await {
|
||||
if total >= MAX_IMAGES_TOTAL_BYTES {
|
||||
break;
|
||||
}
|
||||
let Some(bytes) = bytes else {
|
||||
continue;
|
||||
};
|
||||
total += bytes.len();
|
||||
fetched.insert(url, bytes);
|
||||
}
|
||||
fetched
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The attachment cache
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn cache_dir(db: &Db, account_id: &str) -> PathBuf {
|
||||
db.account_dir(account_id).join(CACHE_DIR)
|
||||
}
|
||||
|
||||
/// A file name derived from the attachment id, which is already unique within the account. The
|
||||
/// extension is kept so the OS opens the file with the right thing.
|
||||
fn cache_name(row: &AttachmentRow) -> String {
|
||||
let stem: String = row
|
||||
.id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
match row.filename.rsplit_once('.') {
|
||||
Some((_, extension)) if !extension.is_empty() && extension.len() <= 8 => {
|
||||
let extension: String = extension
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
if extension.is_empty() {
|
||||
stem
|
||||
} else {
|
||||
format!("{stem}.{}", extension.to_lowercase())
|
||||
}
|
||||
}
|
||||
_ => stem,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of one part of a message that is already on the device.
|
||||
///
|
||||
/// `mime::render` only carries the bytes of a part small enough to keep beside the row, and the
|
||||
/// mirror keys an attachment by its MIME part path, so this matches on what the row does hold: the
|
||||
/// decoded length and the file name. Body parts are skipped the same way the renderer skips them,
|
||||
/// so a text body the same length as a file cannot be mistaken for it.
|
||||
fn part_bytes(raw: &[u8], row: &AttachmentRow) -> Option<Vec<u8>> {
|
||||
let message = MessageParser::default().parse(raw)?;
|
||||
let bodies: HashSet<usize> = message
|
||||
.html_body
|
||||
.iter()
|
||||
.chain(message.text_body.iter())
|
||||
.map(|index| *index as usize)
|
||||
.collect();
|
||||
|
||||
let mut fallback = None;
|
||||
for (index, part) in message.parts.iter().enumerate() {
|
||||
if bodies.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let bytes = match &part.body {
|
||||
PartType::Binary(bytes) | PartType::InlineBinary(bytes) => bytes.to_vec(),
|
||||
PartType::Text(text) | PartType::Html(text) => text.as_bytes().to_vec(),
|
||||
PartType::Multipart(_) | PartType::Message(_) => continue,
|
||||
};
|
||||
if bytes.len() as u64 != row.size {
|
||||
continue;
|
||||
}
|
||||
if part.attachment_name() == Some(row.filename.as_str()) {
|
||||
return Some(bytes);
|
||||
}
|
||||
fallback.get_or_insert(bytes);
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
/// The bytes of one attachment: from the cache, else out of the message already on the device,
|
||||
/// else from the provider.
|
||||
///
|
||||
/// The provider is last because it is rarely needed and, for Gmail, rarely possible: the mirror
|
||||
/// keys an attachment by its MIME part path and `attachments.get` wants Gmail's own attachment id,
|
||||
/// which nothing stores. A message with an attachment row has a body, and a body is the whole
|
||||
/// message, so the bytes are almost always here already.
|
||||
async fn bytes_of(
|
||||
db: &Db,
|
||||
account_id: &str,
|
||||
remote: Option<&dyn Remote>,
|
||||
row: &AttachmentRow,
|
||||
cap_bytes: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let dir = cache_dir(db, account_id);
|
||||
if let Some(path) = &row.cached_path {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
db.with(account_id, |conn| write::attachment_uncached(conn, &row.id))?;
|
||||
}
|
||||
|
||||
let raw = db.with(account_id, |conn| read::raw_body(conn, &row.message_id))?;
|
||||
let bytes = match raw.as_deref().and_then(|raw| part_bytes(raw, row)) {
|
||||
Some(bytes) => bytes,
|
||||
None => {
|
||||
let remote = remote.ok_or("that file is not on this device")?;
|
||||
remote
|
||||
.fetch_attachment(&row.message_id, &row.part_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
|
||||
put(db, account_id, &dir, row, &bytes, cap_bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Writes the bytes into the cache and keeps the cache inside its cap.
|
||||
fn put(
|
||||
db: &Db,
|
||||
account_id: &str,
|
||||
dir: &Path,
|
||||
row: &AttachmentRow,
|
||||
bytes: &[u8],
|
||||
cap_bytes: u64,
|
||||
) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(cache_name(row));
|
||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
||||
db.with(account_id, |conn| {
|
||||
write::attachment_cached(conn, &row.id, &path.to_string_lossy(), write::now_ms())?;
|
||||
trim(conn, dir, cap_bytes)
|
||||
})
|
||||
}
|
||||
|
||||
/// Keeps the cache honest in both directions.
|
||||
///
|
||||
/// `mirror::evict` deletes an attachment row without touching the file it points at, because
|
||||
/// eviction runs inside one SQL transaction and has no business on the filesystem. A file with no
|
||||
/// row is therefore the expected shape rather than a bug, and this is where it goes. After that,
|
||||
/// least recently fetched first, until what is left fits the cap.
|
||||
///
|
||||
/// The newest is never given up, whatever the cap says. It is the file somebody is waiting on, and
|
||||
/// a cache too small for one file should still hand that file over.
|
||||
fn trim(conn: &Connection, dir: &Path, cap_bytes: u64) -> Result<(), String> {
|
||||
let held = read::cached_attachments(conn)?;
|
||||
let known: HashSet<PathBuf> = held.iter().map(|(_, path, _)| PathBuf::from(path)).collect();
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && !known.contains(&path) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut total: u64 = held.iter().map(|(_, _, size)| *size).sum();
|
||||
let mut index = 0;
|
||||
while total > cap_bytes && index + 1 < held.len() {
|
||||
let (id, path, size) = &held[index];
|
||||
let _ = std::fs::remove_file(path);
|
||||
write::attachment_uncached(conn, id)?;
|
||||
total = total.saturating_sub(*size);
|
||||
index += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The cap the settings screen set, in bytes. A settings file that will not load is not a reason
|
||||
/// to refuse somebody their attachment, so it falls back rather than failing.
|
||||
fn cap_bytes(app: &tauri::AppHandle) -> u64 {
|
||||
crate::settings::load(app)
|
||||
.map(|settings| settings.attachment_cache_mb as u64)
|
||||
.ok()
|
||||
.filter(|megabytes| *megabytes > 0)
|
||||
.unwrap_or(DEFAULT_CACHE_MB)
|
||||
* 1024
|
||||
* 1024
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Finding things
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn db_of(app: &tauri::AppHandle) -> Result<tauri::State<'_, Db>, String> {
|
||||
app.try_state::<Db>()
|
||||
.ok_or_else(|| "the mirror is not open yet".to_string())
|
||||
}
|
||||
|
||||
fn account_holding(db: &Db, sql: &str, id: &str) -> Result<String, String> {
|
||||
for (account_id, _) in sync::accounts(db, None) {
|
||||
let held = db.with(&account_id, |conn| {
|
||||
conn.query_row(sql, [id], |row| row.get::<_, i64>(0))
|
||||
.map_err(|e| e.to_string())
|
||||
})?;
|
||||
if held > 0 {
|
||||
return Ok(account_id);
|
||||
}
|
||||
}
|
||||
Err("that is not on this device".to_string())
|
||||
}
|
||||
|
||||
fn attachment_of(db: &Db, id: &str) -> Result<(String, AttachmentRow), String> {
|
||||
let account_id = account_holding(db, "SELECT COUNT(*) FROM attachments WHERE id = ?1", id)?;
|
||||
let row = db
|
||||
.with(&account_id, |conn| read::attachment_row(conn, id))?
|
||||
.ok_or("that file is not on this device")?;
|
||||
Ok((account_id, row))
|
||||
}
|
||||
|
||||
/// Somewhere in the downloads directory that is not already taken.
|
||||
fn free_path(dir: &Path, filename: &str) -> PathBuf {
|
||||
let safe: String = filename
|
||||
.chars()
|
||||
.map(|c| if std::path::is_separator(c) { '-' } else { c })
|
||||
.collect();
|
||||
let safe = safe.trim().trim_matches('.').to_string();
|
||||
let safe = if safe.is_empty() {
|
||||
"attachment".to_string()
|
||||
} else {
|
||||
safe
|
||||
};
|
||||
let path = dir.join(&safe);
|
||||
if !path.exists() {
|
||||
return path;
|
||||
}
|
||||
let (stem, extension) = match safe.rsplit_once('.') {
|
||||
Some((stem, extension)) => (stem.to_string(), format!(".{extension}")),
|
||||
None => (safe.clone(), String::new()),
|
||||
};
|
||||
for nth in 2..1000 {
|
||||
let candidate = dir.join(format!("{stem} ({nth}){extension}"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
dir.join(format!("{stem} ({}){extension}", write::now_ms()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The reader has asked to see the pictures.
|
||||
///
|
||||
/// The answer is a view and not a stored row on purpose: the `bodies` table has no column saying
|
||||
/// whether images were loaded, so this is per view rather than remembered, and closing the thread
|
||||
/// puts the block back. Inventing a column for it would also be inventing a policy, and the policy
|
||||
/// that roams with a person is the per sender allowance on the contact card, not a flag on a body.
|
||||
#[tauri::command]
|
||||
pub async fn message_show_images(
|
||||
app: tauri::AppHandle,
|
||||
message_id: String,
|
||||
) -> Result<MessageView, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = account_holding(
|
||||
db.inner(),
|
||||
"SELECT COUNT(*) FROM messages WHERE id = ?1",
|
||||
&message_id,
|
||||
)?;
|
||||
|
||||
let (raw, options) = db.with(&account_id, |conn| {
|
||||
let raw = read::raw_body(conn, &message_id)?
|
||||
.ok_or("that message has not been fetched yet")?;
|
||||
Ok((raw, sync::hydrate::render_options(conn)?))
|
||||
})?;
|
||||
|
||||
let urls = wanted(&raw, &options)?;
|
||||
let rendered = shown(&raw, &options, fetch_images(&urls).await)?;
|
||||
|
||||
let mut view = db.with(&account_id, |conn| {
|
||||
read::message_view(conn, &account_id, &message_id, &options.own_addresses)
|
||||
})?;
|
||||
view.html = rendered.html;
|
||||
view.quoted_html = rendered.quoted_html;
|
||||
view.trackers = rendered.trackers;
|
||||
view.blocked_images = rendered.blocked_images;
|
||||
view.images_loaded = true;
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// The inline preview. Refuses anything too big to be a data URI before it fetches a byte, so the
|
||||
/// answer to a hundred megabyte video is a sentence rather than a spinner.
|
||||
#[tauri::command]
|
||||
pub async fn attachment_data_url(
|
||||
app: tauri::AppHandle,
|
||||
attachment_id: String,
|
||||
) -> Result<String, String> {
|
||||
let db = db_of(&app)?;
|
||||
let (account_id, row) = attachment_of(db.inner(), &attachment_id)?;
|
||||
if let Some(refusal) = too_big_to_preview(&row) {
|
||||
return Err(refusal);
|
||||
}
|
||||
let remote = sync::remote_for(&account_id);
|
||||
let bytes = bytes_of(
|
||||
db.inner(),
|
||||
&account_id,
|
||||
remote.as_deref(),
|
||||
&row,
|
||||
cap_bytes(&app),
|
||||
)
|
||||
.await?;
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
row.mime_type,
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes)
|
||||
))
|
||||
}
|
||||
|
||||
/// Writes it to the downloads directory and hands back the path, which is what the toast names.
|
||||
#[tauri::command]
|
||||
pub async fn attachment_save(
|
||||
app: tauri::AppHandle,
|
||||
attachment_id: String,
|
||||
) -> Result<String, String> {
|
||||
let db = db_of(&app)?;
|
||||
let (account_id, row) = attachment_of(db.inner(), &attachment_id)?;
|
||||
let remote = sync::remote_for(&account_id);
|
||||
let bytes = bytes_of(
|
||||
db.inner(),
|
||||
&account_id,
|
||||
remote.as_deref(),
|
||||
&row,
|
||||
cap_bytes(&app),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let downloads = app.path().download_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
|
||||
let path = free_path(&downloads, &row.filename);
|
||||
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Hands the cached file to the OS. The cache is where it opens from, so opening the same file
|
||||
/// twice costs nothing the second time.
|
||||
#[tauri::command]
|
||||
pub async fn attachment_open(app: tauri::AppHandle, attachment_id: String) -> Result<(), String> {
|
||||
let db = db_of(&app)?;
|
||||
let (account_id, row) = attachment_of(db.inner(), &attachment_id)?;
|
||||
let remote = sync::remote_for(&account_id);
|
||||
bytes_of(
|
||||
db.inner(),
|
||||
&account_id,
|
||||
remote.as_deref(),
|
||||
&row,
|
||||
cap_bytes(&app),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let path = db
|
||||
.with(&account_id, |conn| read::attachment_row(conn, &row.id))?
|
||||
.and_then(|row| row.cached_path)
|
||||
.ok_or("that file could not be put on the disk")?;
|
||||
app.opener()
|
||||
.open_path(path, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Decided from the row rather than from the bytes, so an enormous file is refused before anything
|
||||
/// is fetched and the answer arrives at once.
|
||||
fn too_big_to_preview(row: &AttachmentRow) -> Option<String> {
|
||||
(row.size > MAX_DATA_URL_BYTES).then(|| {
|
||||
format!(
|
||||
"{} is {}, which is too big to preview here. Save it instead.",
|
||||
row.filename,
|
||||
megabytes(row.size)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn megabytes(bytes: u64) -> String {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tauri::async_runtime::block_on;
|
||||
|
||||
use crate::db;
|
||||
use crate::provider::fake::FakeProvider;
|
||||
use crate::sync::{hydrate, Store};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Memory(Mutex<Connection>);
|
||||
|
||||
impl Store for Memory {
|
||||
fn with<T, F: FnOnce(&Connection) -> Result<T, String>>(&self, f: F) -> Result<T, String> {
|
||||
let conn = self.0.lock().map_err(|e| e.to_string())?;
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn read<T>(&self, f: impl FnOnce(&Connection) -> Result<T, String>) -> T {
|
||||
self.with(f).expect("the mirror")
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> Memory {
|
||||
Memory(Mutex::new(db::memory().expect("a pair of in-memory databases")))
|
||||
}
|
||||
|
||||
fn eml(id: &str, headers: &str, body: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"Message-ID: <{id}@example.test>\r\nFrom: Ana <[email protected]>\r\n\
|
||||
To: You <[email protected]>\r\nSubject: {id}\r\n\
|
||||
Date: Wed, 2 Sep 2026 10:00:00 +0000\r\n{headers}\r\n{body}"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// A body with one ordinary remote image and one tracking pixel from a named vendor.
|
||||
fn with_images() -> Vec<u8> {
|
||||
eml(
|
||||
"images",
|
||||
"Content-Type: text/html; charset=utf-8\r\n",
|
||||
"<html><body><p>Hello</p>\
|
||||
<img src=\"https://shop.example/photo.jpg\" width=\"640\" height=\"420\">\
|
||||
<img src=\"https://track.hubspot.com/__ptq.gif?a=1\" width=\"1\" height=\"1\">\
|
||||
</body></html>\r\n",
|
||||
)
|
||||
}
|
||||
|
||||
fn options() -> RenderOptions {
|
||||
RenderOptions {
|
||||
allow_remote_images: false,
|
||||
link_cleaning: true,
|
||||
remote_images: HashMap::new(),
|
||||
own_addresses: vec!["[email protected]".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Eight bytes of PNG magic, which is all the sanitiser sniffs before it will inline anything.
|
||||
fn png() -> Vec<u8> {
|
||||
b"\x89PNG\r\n\x1a\nrest".to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_images_inlines_what_was_fetched_and_leaves_the_trackers_removed() {
|
||||
let raw = with_images();
|
||||
let urls = wanted(&raw, &options()).expect("the blocked images");
|
||||
assert_eq!(urls, vec!["https://shop.example/photo.jpg".to_string()]);
|
||||
|
||||
let blocked = mime::render(&raw, &options()).expect("a render");
|
||||
assert_eq!(blocked.blocked_images, 1);
|
||||
assert_eq!(blocked.trackers.len(), 1);
|
||||
|
||||
let mut fetched = HashMap::new();
|
||||
fetched.insert(urls[0].clone(), png());
|
||||
let rendered = shown(&raw, &options(), fetched).expect("a second render");
|
||||
|
||||
assert!(
|
||||
rendered.html.contains("data:image/png;base64,"),
|
||||
"the fetched bytes are inlined: {}",
|
||||
rendered.html
|
||||
);
|
||||
assert_eq!(rendered.blocked_images, 0);
|
||||
assert_eq!(
|
||||
rendered.trackers.len(),
|
||||
1,
|
||||
"the pixel is still named and still gone"
|
||||
);
|
||||
assert_eq!(rendered.trackers[0].vendor, "HubSpot");
|
||||
assert!(
|
||||
!rendered.html.contains("track.hubspot.com"),
|
||||
"showing the pictures is not filing an open report: {}",
|
||||
rendered.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_with_more_images_than_the_cap_is_refused_before_anything_is_fetched() {
|
||||
let mut body = String::from("<html><body>");
|
||||
for index in 0..(MAX_IMAGES + 1) {
|
||||
body.push_str(&format!(
|
||||
"<img src=\"https://shop.example/{index}.jpg\" width=\"400\" height=\"300\">"
|
||||
));
|
||||
}
|
||||
body.push_str("</body></html>\r\n");
|
||||
let raw = eml("many", "Content-Type: text/html; charset=utf-8\r\n", &body);
|
||||
|
||||
let refused = wanted(&raw, &options()).expect_err("a refusal");
|
||||
assert!(refused.contains("more than Margin Mail will fetch"), "{refused}");
|
||||
assert!(refused.contains("None were fetched."), "{refused}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_images_changes_nothing_that_is_stored() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let raw = with_images();
|
||||
fake.add_eml("m1", "t1", &["INBOX"], &raw);
|
||||
block_on(hydrate::headers(&store, &fake, &["m1".to_string()], false)).expect("headers");
|
||||
block_on(hydrate::body(&store, &fake, "m1")).expect("a body");
|
||||
|
||||
let before = store.read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT html, blocked_images FROM bodies WHERE message_id = 'm1'",
|
||||
[],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
assert_eq!(before.1, 1, "the stored render has the image blocked");
|
||||
|
||||
let held = store.read(|conn| read::raw_body(conn, "m1")).expect("the bytes");
|
||||
let urls = wanted(&held, &options()).expect("the blocked images");
|
||||
let mut fetched = HashMap::new();
|
||||
fetched.insert(urls[0].clone(), png());
|
||||
let rendered = shown(&held, &options(), fetched).expect("a render with images");
|
||||
assert!(rendered.html.contains("data:image/png;base64,"));
|
||||
|
||||
let after = store.read(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT html, blocked_images FROM bodies WHERE message_id = 'm1'",
|
||||
[],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
assert_eq!(after, before, "the row is untouched, so the next open blocks again");
|
||||
}
|
||||
|
||||
// -- the attachment cache -------------------------------------------------------------------
|
||||
|
||||
/// A message with one attached file, built as multipart so the parts are real parts.
|
||||
fn with_attachment(id: &str, filename: &str, bytes: &[u8]) -> Vec<u8> {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mut body = String::from("--sep\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n");
|
||||
body.push_str("Here it is.\r\n");
|
||||
body.push_str(&format!(
|
||||
"--sep\r\nContent-Type: application/octet-stream\r\n\
|
||||
Content-Disposition: attachment; filename=\"{filename}\"\r\n\
|
||||
Content-Transfer-Encoding: base64\r\n\r\n"
|
||||
));
|
||||
for chunk in encoded.as_bytes().chunks(76) {
|
||||
body.push_str(&String::from_utf8_lossy(chunk));
|
||||
body.push_str("\r\n");
|
||||
}
|
||||
body.push_str("--sep--\r\n");
|
||||
eml(
|
||||
id,
|
||||
"Content-Type: multipart/mixed; boundary=\"sep\"\r\n",
|
||||
&body,
|
||||
)
|
||||
}
|
||||
|
||||
fn only_attachment(store: &Memory, message_id: &str) -> AttachmentRow {
|
||||
let rows = store.read(|conn| read::attachments(conn, message_id));
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
store
|
||||
.read(|conn| read::attachment_row(conn, &rows[0].id))
|
||||
.expect("the row")
|
||||
}
|
||||
|
||||
/// The cache without the Tauri handle in front of it: the same order of preference, against a
|
||||
/// directory a test owns.
|
||||
fn fetch(
|
||||
store: &Memory,
|
||||
dir: &Path,
|
||||
remote: Option<&dyn Remote>,
|
||||
row: &AttachmentRow,
|
||||
cap: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let row = store
|
||||
.read(|conn| read::attachment_row(conn, &row.id))
|
||||
.expect("the row");
|
||||
if let Some(path) = &row.cached_path {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
store.read(|conn| write::attachment_uncached(conn, &row.id));
|
||||
}
|
||||
let raw = store.read(|conn| read::raw_body(conn, &row.message_id));
|
||||
let bytes = match raw.as_deref().and_then(|raw| part_bytes(raw, &row)) {
|
||||
Some(bytes) => bytes,
|
||||
None => {
|
||||
let remote = remote.ok_or("that file is not on this device")?;
|
||||
block_on(remote.fetch_attachment(&row.message_id, &row.part_id))
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(cache_name(&row));
|
||||
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
|
||||
store.read(|conn| {
|
||||
write::attachment_cached(conn, &row.id, &path.to_string_lossy(), write::now_ms())?;
|
||||
trim(conn, dir, cap)
|
||||
});
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn a_message_with_a_file(
|
||||
store: &Memory,
|
||||
fake: &FakeProvider,
|
||||
id: &str,
|
||||
filename: &str,
|
||||
bytes: &[u8],
|
||||
) -> AttachmentRow {
|
||||
fake.add_eml(id, id, &["INBOX"], &with_attachment(id, filename, bytes));
|
||||
block_on(hydrate::headers(store, fake, &[id.to_string()], false)).expect("headers");
|
||||
block_on(hydrate::body(store, fake, id)).expect("a body");
|
||||
only_attachment(store, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_already_on_the_device_is_served_without_asking_the_provider() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let dir = tempfile::tempdir().expect("a cache directory");
|
||||
let row = a_message_with_a_file(&store, &fake, "m1", "notes.pdf", b"the file itself");
|
||||
|
||||
let bytes = fetch(&store, dir.path(), Some(&fake), &row, 1 << 30).expect("the bytes");
|
||||
assert_eq!(bytes, b"the file itself");
|
||||
assert!(
|
||||
!fake.calls().iter().any(|call| call.starts_with("fetch_attachment")),
|
||||
"the whole message was already here: {:?}",
|
||||
fake.calls()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attachment_is_fetched_once_and_served_from_the_cache_the_second_time() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let dir = tempfile::tempdir().expect("a cache directory");
|
||||
let row = a_message_with_a_file(&store, &fake, "m1", "notes.pdf", b"the file itself");
|
||||
fake.set_attachment(&row.part_id, b"the file itself".to_vec());
|
||||
|
||||
// The message is gone but its rows are not, which is the one shape that has to reach the
|
||||
// provider.
|
||||
store.read(|conn| {
|
||||
conn.execute("UPDATE bodies SET raw = NULL WHERE message_id = 'm1'", [])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
let first = fetch(&store, dir.path(), Some(&fake), &row, 1 << 30).expect("the bytes");
|
||||
let second = fetch(&store, dir.path(), Some(&fake), &row, 1 << 30).expect("the bytes again");
|
||||
assert_eq!(first, b"the file itself");
|
||||
assert_eq!(second, first);
|
||||
|
||||
let fetches = fake
|
||||
.calls()
|
||||
.into_iter()
|
||||
.filter(|call| call.starts_with("fetch_attachment"))
|
||||
.count();
|
||||
assert_eq!(fetches, 1, "the second time came off the disk");
|
||||
assert!(
|
||||
store
|
||||
.read(|conn| read::attachment_row(conn, &row.id))
|
||||
.expect("the row")
|
||||
.cached_path
|
||||
.is_some(),
|
||||
"and the row says where"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_too_big_for_a_data_uri_is_refused_with_a_reason() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let row = a_message_with_a_file(&store, &fake, "m1", "film.mov", b"small in the test");
|
||||
assert_eq!(too_big_to_preview(&row), None, "an ordinary file previews");
|
||||
|
||||
store.read(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET size = ?1 WHERE id = ?2",
|
||||
rusqlite::params![(MAX_DATA_URL_BYTES + 1) as i64, row.id],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let row = store
|
||||
.read(|conn| read::attachment_row(conn, &row.id))
|
||||
.expect("the row");
|
||||
|
||||
assert_eq!(
|
||||
too_big_to_preview(&row).as_deref(),
|
||||
Some("film.mov is 8.0 MB, which is too big to preview here. Save it instead."),
|
||||
"and it says which file and how big before it fetches a byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_gives_up_the_oldest_file_to_stay_inside_its_cap() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let dir = tempfile::tempdir().expect("a cache directory");
|
||||
let first = a_message_with_a_file(&store, &fake, "m1", "one.bin", &vec![b'a'; 4_000]);
|
||||
let second = a_message_with_a_file(&store, &fake, "m2", "two.bin", &vec![b'b'; 4_000]);
|
||||
|
||||
// Room for one of them, and the older one is the one that goes.
|
||||
fetch(&store, dir.path(), Some(&fake), &first, 5_000).expect("the first");
|
||||
fetch(&store, dir.path(), Some(&fake), &second, 5_000).expect("the second");
|
||||
|
||||
let held: Vec<String> = store
|
||||
.read(read::cached_attachments)
|
||||
.into_iter()
|
||||
.map(|(id, _, _)| id)
|
||||
.collect();
|
||||
assert_eq!(held, vec![second.id.clone()]);
|
||||
|
||||
let files: Vec<String> = std::fs::read_dir(dir.path())
|
||||
.expect("the cache directory")
|
||||
.flatten()
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "{files:?}");
|
||||
assert!(
|
||||
std::fs::read(dir.path().join(&files[0])).expect("the file") == vec![b'b'; 4_000],
|
||||
"the one that is left is the one that was asked for last"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.read(|conn| read::attachment_row(conn, &first.id))
|
||||
.expect("the row")
|
||||
.cached_path
|
||||
.is_none(),
|
||||
"and the row it dropped says so, so storage_used stays honest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_whose_row_was_evicted_is_swept_off_the_disk() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let dir = tempfile::tempdir().expect("a cache directory");
|
||||
let row = a_message_with_a_file(&store, &fake, "m1", "one.bin", b"contents");
|
||||
fetch(&store, dir.path(), Some(&fake), &row, 1 << 30).expect("the bytes");
|
||||
|
||||
let orphan = dir.path().join("left-behind.bin");
|
||||
std::fs::write(&orphan, b"whatever").expect("an orphan");
|
||||
|
||||
store.read(|conn| trim(conn, dir.path(), 1 << 30));
|
||||
assert!(!orphan.exists(), "eviction cannot reach the disk, so this does");
|
||||
assert!(dir.path().join(cache_name(&row)).exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// What leaves the device, and what the store can and cannot learn from it.
|
||||
//
|
||||
// Everything uploaded is one segment of the state journal, encrypted here before it goes and
|
||||
// decrypted here when it comes back. The key is 32 bytes, derived once from a BIP39 recovery
|
||||
// phrase in `phrase.rs`, sealed on disk beside the OAuth tokens, and uploaded nowhere.
|
||||
//
|
||||
// So what a Google Drive folder or an S3 bucket holds is this:
|
||||
//
|
||||
// It can see how many segments there are, how large each one is, when each was written, and
|
||||
// the names, which carry a hash of the account's address, a device id and a range
|
||||
// of sequence numbers. That is enough to say "somebody made about forty decisions
|
||||
// on Tuesday, on one of their two machines", and it is the whole of it.
|
||||
// It cannot see a note, a rule, a pile, a rename, a snooze, an address, a subject or a thread
|
||||
// key. Every one of those is inside a record, and a record only exists on the
|
||||
// store as ciphertext.
|
||||
// It cannot lie about what it holds. Each segment is authenticated under its own name, so a
|
||||
// store that moves a segment between devices, swaps two of them, replays an old
|
||||
// one into a new name or flips a byte gets a decryption failure here rather than a
|
||||
// wrong answer in somebody's mailbox.
|
||||
//
|
||||
// XChaCha20-Poly1305, one fresh 24 byte nonce per segment from the OS random source. The X is the
|
||||
// reason it can be random rather than counted: 192 bits is wide enough that a collision is not a
|
||||
// thing that happens, and there is no counter two devices could have agreed on without a server.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use chacha20poly1305::aead::{Aead, Payload};
|
||||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::google::secrets;
|
||||
|
||||
const NONCE_LEN: usize = 24;
|
||||
|
||||
/// Four bytes in front of every segment, so a file that is not one of ours says so before the tag
|
||||
/// does, and so a second format later can be told from this one rather than guessed at.
|
||||
const MAGIC: &[u8; 4] = b"MMB1";
|
||||
|
||||
/// The sealed key's name in the token store. It is not an account id and cannot become one: a
|
||||
/// Google `sub` is digits and an email address cannot carry a space.
|
||||
const KEY_ID: &str = "margin-mail backup key";
|
||||
|
||||
/// The key, and a `Debug` that will not print it. Everything that carries one of these ends up in
|
||||
/// an error message eventually.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Key([u8; 32]);
|
||||
|
||||
impl std::fmt::Debug for Key {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Key(not shown)")
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Key {
|
||||
Key(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seals one segment. `name` is authenticated but not encrypted: it is the file name the caller is
|
||||
/// about to write to, and binding it here is what stops the store rearranging the backup.
|
||||
pub fn seal(key: &Key, name: &str, plain: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let cipher = XChaCha20Poly1305::new((&key.0).into());
|
||||
let mut nonce = [0u8; NONCE_LEN];
|
||||
rand::thread_rng().fill_bytes(&mut nonce);
|
||||
let body = cipher
|
||||
.encrypt(
|
||||
(&nonce).into(),
|
||||
Payload {
|
||||
msg: plain,
|
||||
aad: name.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| "could not encrypt a backup segment".to_string())?;
|
||||
|
||||
let mut out = Vec::with_capacity(MAGIC.len() + NONCE_LEN + body.len());
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The other direction, with the name the segment was actually found under. A wrong key, a wrong
|
||||
/// name and a changed byte are all the same failure here, which is the point of an AEAD: there is
|
||||
/// no partial answer to hand back and nothing that half decrypts.
|
||||
pub fn open(key: &Key, name: &str, sealed: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if sealed.len() < MAGIC.len() + NONCE_LEN || &sealed[..MAGIC.len()] != MAGIC {
|
||||
return Err(format!("{name} is not a Margin Mail backup segment"));
|
||||
}
|
||||
let (nonce, body) = sealed[MAGIC.len()..].split_at(NONCE_LEN);
|
||||
let nonce: &XNonce = nonce
|
||||
.try_into()
|
||||
.map_err(|_| format!("{name} is truncated"))?;
|
||||
XChaCha20Poly1305::new((&key.0).into())
|
||||
.decrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: body,
|
||||
aad: name.as_bytes(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| {
|
||||
format!("{name} could not be decrypted. Either the recovery phrase is not the one this backup was made with, or the file has been changed since it was written.")
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The key at rest
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
// `google::secrets` is the OAuth token store: an XChaCha20-Poly1305 blob in the app data
|
||||
// directory, 0600 from creation, keyed from a per install salt mixed with a machine identifier so
|
||||
// a copied home directory does not open it. The backup key is sealed by that code rather than
|
||||
// beside it, through its own front door, because a second implementation of the same idea is a
|
||||
// second thing to get wrong and it would have to be reviewed on five platforms too.
|
||||
//
|
||||
// Losing the sealed key is not losing the backup. The phrase derives it again.
|
||||
|
||||
pub fn stored() -> Result<Option<Key>, String> {
|
||||
let Some(held) = secrets::load(KEY_ID)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw = BASE64
|
||||
.decode(held)
|
||||
.map_err(|e| format!("the stored backup key is malformed: {e}"))?;
|
||||
let bytes: [u8; 32] = raw
|
||||
.try_into()
|
||||
.map_err(|_| "the stored backup key is not 32 bytes".to_string())?;
|
||||
Ok(Some(Key(bytes)))
|
||||
}
|
||||
|
||||
pub fn remember(key: &Key) -> Result<(), String> {
|
||||
secrets::store(KEY_ID, &BASE64.encode(key.0))
|
||||
}
|
||||
|
||||
pub fn forget() -> Result<(), String> {
|
||||
secrets::delete(KEY_ID)
|
||||
}
|
||||
|
||||
/// Whether this install has a key at all, which is the same question as whether the phrase has
|
||||
/// been shown yet.
|
||||
pub fn have_key() -> bool {
|
||||
matches!(stored(), Ok(Some(_)))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// `impl BackupStore for Drive`: three operations onto the folder half of `google::drive`.
|
||||
//
|
||||
// The folder is `margin/mail/<account-hash>/<device-id>/`, inside the same visible `margin` folder
|
||||
// the other two apps in the suite write to. That is the shape the architecture settled on and the
|
||||
// reason it matters is worth repeating here: margin holds `drive.file` and writes into an ordinary
|
||||
// folder rather than the hidden app data space, so a person can open their Drive, see their backup
|
||||
// and delete it without asking anyone. Mail keeps the scope, adds none, and puts encrypted segments
|
||||
// under it.
|
||||
//
|
||||
// `drive.file` also means this app can only see files it created, so listing a folder returns
|
||||
// Margin's own files and nothing else. That is what makes a bare "everything in this folder" query
|
||||
// safe here when it would be a privacy problem under a wider scope.
|
||||
//
|
||||
// Drive has no paths, only folders with parents, so a name is walked segment by segment. The walk
|
||||
// creates what is missing, on a read as well as a write, because the first thing a fresh device
|
||||
// does is list a folder that does not exist yet and the answer to that is an empty folder rather
|
||||
// than an error.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::accounts;
|
||||
use crate::google::auth::{self, AuthState};
|
||||
use crate::google::drive as gdrive;
|
||||
|
||||
use super::store::BackupStore;
|
||||
|
||||
pub struct Drive {
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
pub fn new(app: tauri::AppHandle, account_id: impl Into<String>) -> Drive {
|
||||
Drive {
|
||||
app,
|
||||
account_id: account_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The scope is checked before the token is asked for, so a cleared tick box on the consent
|
||||
/// page reads as the sentence naming that tick box rather than as a 403 from Drive.
|
||||
async fn token(&self) -> Result<String, String> {
|
||||
let granted = accounts::granted_scopes(&self.app, &self.account_id)?;
|
||||
if !gdrive::has_scope(&granted) {
|
||||
return Err(gdrive::REAUTH_MESSAGE.to_string());
|
||||
}
|
||||
let state = self
|
||||
.app
|
||||
.try_state::<AuthState>()
|
||||
.ok_or_else(|| "this app has no Google session store".to_string())?;
|
||||
auth::valid_access_token(&self.app, &state, &self.account_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// A name is `<account-hash>/<device-id>/<file>`, which is the folders it lives under and the file
|
||||
/// itself. Anything else is a caller bug rather than a user's problem, so it says so plainly.
|
||||
fn split(name: &str) -> Result<(&str, &str, &str), String> {
|
||||
let mut parts = name.split('/');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(account), Some(device), Some(file), None)
|
||||
if !account.is_empty() && !device.is_empty() && !file.is_empty() =>
|
||||
{
|
||||
Ok((account, device, file))
|
||||
}
|
||||
_ => Err(format!("{name} is not a backup segment name")),
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupStore for Drive {
|
||||
fn put(&self, name: &str, bytes: &[u8]) -> impl Future<Output = Result<(), String>> + Send {
|
||||
let name = name.to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
async move {
|
||||
let token = self.token().await?;
|
||||
let (account, device, file) = split(&name)?;
|
||||
let folder = gdrive::ensure_path(
|
||||
&token,
|
||||
&[gdrive::FOLDER_NAME, gdrive::MAIL_FOLDER, account, device],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Overwriting in place rather than adding a second file with the same name, which
|
||||
// Drive would allow and which would make the segment ambiguous. A segment is written
|
||||
// once in the ordinary course of things; this is the re-run after an interruption.
|
||||
let existing = gdrive::find_file(&token, &folder, file)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
gdrive::upload(
|
||||
&token,
|
||||
&folder,
|
||||
file,
|
||||
&bytes,
|
||||
existing.as_ref().map(|held| held.id.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, name: &str) -> impl Future<Output = Result<Vec<u8>, String>> + Send {
|
||||
let name = name.to_string();
|
||||
async move {
|
||||
let token = self.token().await?;
|
||||
let (account, device, file) = split(&name)?;
|
||||
let folder = gdrive::ensure_path(
|
||||
&token,
|
||||
&[gdrive::FOLDER_NAME, gdrive::MAIL_FOLDER, account, device],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let held = gdrive::find_file(&token, &folder, file)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("{name} is not in the backup folder any more"))?;
|
||||
gdrive::download(&token, &held.id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A prefix is either one account's folder or one device's folder inside it. There is no third
|
||||
/// depth, so the walk is two loops rather than a recursion, and a listing that came back with
|
||||
/// something at another depth would be somebody else's file rather than a segment.
|
||||
fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<String>, String>> + Send {
|
||||
let prefix = prefix.to_string();
|
||||
async move {
|
||||
let token = self.token().await?;
|
||||
let parts: Vec<&str> = prefix.split('/').filter(|part| !part.is_empty()).collect();
|
||||
let mut names = Vec::new();
|
||||
match parts.as_slice() {
|
||||
[account] => {
|
||||
let folder = gdrive::ensure_path(
|
||||
&token,
|
||||
&[gdrive::FOLDER_NAME, gdrive::MAIL_FOLDER, account],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
for device in gdrive::list_folder(&token, &folder)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
for file in gdrive::list_folder(&token, &device.id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
names.push(format!("{account}/{}/{}", device.name, file.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
[account, device] => {
|
||||
let folder = gdrive::ensure_path(
|
||||
&token,
|
||||
&[gdrive::FOLDER_NAME, gdrive::MAIL_FOLDER, account, device],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
for file in gdrive::list_folder(&token, &folder)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
names.push(format!("{account}/{device}/{}", file.name));
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("{prefix} is not a backup folder")),
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// The backup, which is the state journal encrypted, put somewhere the person chose, and brought
|
||||
// back.
|
||||
//
|
||||
// store.rs put a blob at a name, get a blob by name, list names under a prefix. Three operations
|
||||
// drive.rs that trait over the Drive client in `google::drive`
|
||||
// r2.rs that trait over S3 signature version four, for somebody who runs their own
|
||||
// crypto.rs what leaves the device and what the store can learn from it
|
||||
// phrase.rs the twenty-four words and the key they derive
|
||||
//
|
||||
// Nothing here decides anything. `state::merge` already knows how two logs meet, `state::journal`
|
||||
// already guarantees that replaying a log rebuilds the tables, and this package is the transport
|
||||
// and the encryption around that. What it adds is a rhythm: a device uploads its own segments under
|
||||
// its own device id and downloads every other device's, so two machines that never met still land
|
||||
// on the same tables, and a machine that was off for a month replays what it missed in one pass.
|
||||
//
|
||||
// The one hazard worth naming, because it is a product rule rather than a bug to fix here. A second
|
||||
// device attaches with the phrase; it must never turn backup on by itself. Two devices that each
|
||||
// generate their own phrase for the same account write two backups under two keys into one folder,
|
||||
// and neither can read the other's. That is what the sentence in the Backup section of settings is
|
||||
// for, and a segment that will not decrypt says so by name rather than being skipped, because a
|
||||
// backup that quietly ignores what it cannot read is a backup that quietly loses half of itself.
|
||||
|
||||
pub mod crypto;
|
||||
pub mod drive;
|
||||
pub mod phrase;
|
||||
pub mod r2;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::dto::BackupSettings;
|
||||
use crate::state::{device, journal::Record, merge};
|
||||
|
||||
use crypto::Key;
|
||||
use store::BackupStore;
|
||||
|
||||
/// Events per segment. Small enough that an upload cut off halfway has lost very little work and
|
||||
/// large enough that a month of decisions is a handful of files rather than a directory listing
|
||||
/// nobody can read.
|
||||
const SEGMENT_RECORDS: usize = 500;
|
||||
|
||||
const SUFFIX: &str = ".seg";
|
||||
|
||||
/// A hash rather than the address, because a folder listing in somebody's Drive should not be a
|
||||
/// list of the email addresses they have accounts for. It is the same on every device, which is
|
||||
/// what lets a second one find the first one's folder from the phrase and the account alone.
|
||||
///
|
||||
/// Truncated to 128 bits: this names one folder among a person's handful and is not a secret, and
|
||||
/// a name that fits on one line is worth more here than the other half of the digest.
|
||||
pub fn account_hash(email: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"margin-mail backup account v1");
|
||||
hasher.update(email.trim().to_lowercase().as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
digest[..16].iter().fold(String::new(), |mut out, byte| {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// `<account-hash>/<device-id>/<first>-<last>.seg`.
|
||||
///
|
||||
/// The name carries a range of sequence numbers and nothing else. It cannot say what is in the
|
||||
/// segment, because everything a record holds is inside the ciphertext, and it does say what a
|
||||
/// device needs in order to skip a download: a segment ending at or below what this device already
|
||||
/// holds for that device is a segment it has already absorbed. That is what makes catching up a
|
||||
/// month cost one listing and only the files that were missed.
|
||||
fn segment_name(account_hash: &str, device_id: &str, first: i64, last: i64) -> String {
|
||||
format!("{account_hash}/{device_id}/{first:012}-{last:012}{SUFFIX}")
|
||||
}
|
||||
|
||||
/// What a name says. Anything that does not parse belongs to somebody else and is left alone.
|
||||
fn parse_name(name: &str) -> Option<(String, i64, i64)> {
|
||||
let mut parts = name.split('/');
|
||||
let _account = parts.next()?;
|
||||
let device = parts.next()?;
|
||||
let file = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let (first, last) = file.strip_suffix(SUFFIX)?.split_once('-')?;
|
||||
Some((device.to_string(), first.parse().ok()?, last.parse().ok()?))
|
||||
}
|
||||
|
||||
/// The journal side of a pass, reached through a closure.
|
||||
///
|
||||
/// A `&Connection` may not be held across an await and the transport in the middle of a pass is
|
||||
/// asynchronous, so the database work happens in these closures and the network work happens
|
||||
/// between them. Deliberately not `sync::Store`, which is the same shape: that one means the
|
||||
/// mirror and this one means the journal, and sharing the name would couple two packages through a
|
||||
/// word that means different things in each.
|
||||
pub trait Journal: Sync {
|
||||
fn with<T, F: FnOnce(&Connection) -> Result<T, String>>(&self, f: F) -> Result<T, String>;
|
||||
}
|
||||
|
||||
/// The app's journal: the shared connection pool, narrowed to one account.
|
||||
pub struct Account<'a> {
|
||||
pub db: &'a Db,
|
||||
pub account_id: &'a str,
|
||||
}
|
||||
|
||||
impl Journal for Account<'_> {
|
||||
fn with<T, F: FnOnce(&Connection) -> Result<T, String>>(&self, f: F) -> Result<T, String> {
|
||||
self.db.with(self.account_id, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Pass {
|
||||
pub uploaded: usize,
|
||||
pub downloaded: usize,
|
||||
pub absorbed: usize,
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
/// One account's whole exchange with the store: what is new here goes up, what is new anywhere else
|
||||
/// comes down and is absorbed.
|
||||
///
|
||||
/// Both halves are driven by the listing rather than by a local watermark, so there is one source
|
||||
/// of truth about what the store holds and it is the store. A pass that died halfway through its
|
||||
/// uploads leaves whole segments behind it, and the next pass sees them and carries on from there.
|
||||
///
|
||||
/// Reading happens before writing, which matters on the pass a restore runs: a key that cannot open
|
||||
/// what is already in the folder fails before it has added anything of its own, so a phrase typed
|
||||
/// wrongly cannot leave a second backup's worth of segments that nothing can read.
|
||||
pub async fn pass<J: Journal, S: BackupStore>(
|
||||
journal: &J,
|
||||
store: &S,
|
||||
key: &Key,
|
||||
account_hash: &str,
|
||||
) -> Result<Pass, String> {
|
||||
let held = store.list(&format!("{account_hash}/")).await?;
|
||||
let device_id = journal.with(device::device_id)?;
|
||||
let mut pass = Pass::default();
|
||||
|
||||
let high: HashMap<String, i64> = journal.with(merge::high_water)?.into_iter().collect();
|
||||
let mut wanted: Vec<&String> = held
|
||||
.iter()
|
||||
.filter(|name| match parse_name(name) {
|
||||
Some((device, _, last)) => {
|
||||
device != device_id && last > *high.get(&device).unwrap_or(&0)
|
||||
}
|
||||
None => false,
|
||||
})
|
||||
.collect();
|
||||
wanted.sort();
|
||||
|
||||
let mut records: Vec<Record> = Vec::new();
|
||||
for name in wanted {
|
||||
let sealed = store.get(name).await?;
|
||||
let plain = crypto::open(key, name, &sealed)?;
|
||||
let text = String::from_utf8(plain).map_err(|_| format!("{name} is not a segment"))?;
|
||||
records.extend(merge::decode(&text)?);
|
||||
pass.downloaded += 1;
|
||||
}
|
||||
|
||||
if !records.is_empty() {
|
||||
let report = journal.with(|conn| merge::absorb(conn, &records))?;
|
||||
pass.absorbed = report.applied;
|
||||
pass.skipped = report.skipped;
|
||||
}
|
||||
|
||||
let uploaded_to = held
|
||||
.iter()
|
||||
.filter_map(|name| parse_name(name))
|
||||
.filter(|(device, _, _)| device == &device_id)
|
||||
.map(|(_, _, last)| last)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Only this device's own events, which is why two logs meeting is a union rather than a
|
||||
// conflict: no other device can have written under this sequence.
|
||||
let mine = journal.with(|conn| merge::export(conn, &device_id, uploaded_to))?;
|
||||
for chunk in mine.chunks(SEGMENT_RECORDS) {
|
||||
let (Some(first), Some(last)) = (chunk.first(), chunk.last()) else {
|
||||
continue;
|
||||
};
|
||||
let name = segment_name(account_hash, &device_id, first.seq, last.seq);
|
||||
let body = crypto::seal(key, &name, merge::encode(chunk)?.as_bytes())?;
|
||||
store.put(&name, &body).await?;
|
||||
pass.uploaded += 1;
|
||||
}
|
||||
Ok(pass)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Which store the settings file names, with whatever it needs to reach it.
|
||||
enum Chosen {
|
||||
None,
|
||||
Drive,
|
||||
R2(r2::Config),
|
||||
}
|
||||
|
||||
fn chosen(app: &tauri::AppHandle) -> Result<Chosen, String> {
|
||||
match crate::settings::load(app)?.backup.store.as_str() {
|
||||
"drive" => Ok(Chosen::Drive),
|
||||
"r2" => match r2::stored()? {
|
||||
Some(config) => Ok(Chosen::R2(config)),
|
||||
None => Err(
|
||||
"The R2 credentials are not on this device any more. Enter them again.".to_string(),
|
||||
),
|
||||
},
|
||||
_ => Ok(Chosen::None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the non-secret half of the backup settings back into `settings.json`, through the
|
||||
/// settings module's own patch path so there is one writer of that file.
|
||||
fn record(app: &tauri::AppHandle, patch: serde_json::Value) -> Result<BackupSettings, String> {
|
||||
let settings =
|
||||
crate::settings::settings_set(app.clone(), serde_json::json!({ "backup": patch }))?;
|
||||
Ok(with_key_state(settings.backup))
|
||||
}
|
||||
|
||||
/// `has_phrase` is not a setting, it is whether a key is sealed on this device, so it is answered
|
||||
/// from the key store every time rather than trusted from the file.
|
||||
fn with_key_state(mut settings: BackupSettings) -> BackupSettings {
|
||||
settings.has_phrase = crypto::have_key();
|
||||
settings
|
||||
}
|
||||
|
||||
/// One account's pass against whichever store is turned on. The store is built here rather than
|
||||
/// once for the run because the Drive one holds an account: it signs its requests with that
|
||||
/// account's token, and `drive.file` means it can only see the files it created itself.
|
||||
async fn pass_for_account(
|
||||
app: &tauri::AppHandle,
|
||||
chosen: &Chosen,
|
||||
key: &Key,
|
||||
account_id: &str,
|
||||
email: &str,
|
||||
) -> Result<Pass, String> {
|
||||
let db = app
|
||||
.try_state::<Db>()
|
||||
.ok_or_else(|| "the databases are not open yet".to_string())?;
|
||||
let journal = Account {
|
||||
db: db.inner(),
|
||||
account_id,
|
||||
};
|
||||
let hash = account_hash(email);
|
||||
match chosen {
|
||||
Chosen::None => Err("No backup store is turned on.".to_string()),
|
||||
Chosen::Drive => {
|
||||
let store = drive::Drive::new(app.clone(), account_id);
|
||||
pass(&journal, &store, key, &hash).await
|
||||
}
|
||||
Chosen::R2(config) => {
|
||||
let store = r2::R2::new(config.clone());
|
||||
pass(&journal, &store, key, &hash).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn backup_status(app: tauri::AppHandle) -> Result<BackupSettings, String> {
|
||||
Ok(with_key_state(crate::settings::load(&app)?.backup))
|
||||
}
|
||||
|
||||
/// `store` is "drive", "r2" or "none". R2 takes the four S3 fields and nothing else; Drive takes
|
||||
/// none, because it uses the account that is already connected.
|
||||
#[tauri::command]
|
||||
pub async fn backup_configure(
|
||||
app: tauri::AppHandle,
|
||||
store: String,
|
||||
config: HashMap<String, String>,
|
||||
) -> Result<BackupSettings, String> {
|
||||
match store.as_str() {
|
||||
"drive" => {
|
||||
let accounts = crate::accounts::list(&app)?;
|
||||
if accounts.is_empty() {
|
||||
return Err("Connect an account before turning backup on.".to_string());
|
||||
}
|
||||
// Granular consent means the Drive tick box is the user's to clear, so this is asked
|
||||
// now rather than discovered as a failure during the first backup.
|
||||
if !accounts
|
||||
.iter()
|
||||
.any(|account| crate::google::drive::has_scope(&account.granted_scopes))
|
||||
{
|
||||
return Err(crate::google::drive::REAUTH_MESSAGE.to_string());
|
||||
}
|
||||
record(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"store": "drive",
|
||||
"configured": true,
|
||||
"r2Bucket": serde_json::Value::Null,
|
||||
"r2Endpoint": serde_json::Value::Null
|
||||
}),
|
||||
)
|
||||
}
|
||||
"r2" => {
|
||||
let config = r2::Config::from_fields(&config)?;
|
||||
r2::remember(&config)?;
|
||||
record(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"store": "r2",
|
||||
"configured": true,
|
||||
"r2Bucket": config.bucket,
|
||||
"r2Endpoint": config.endpoint
|
||||
}),
|
||||
)
|
||||
}
|
||||
"none" => {
|
||||
// The key stays. It is what reads the segments already up there, and turning the store
|
||||
// off is not a decision to make those unreadable. The credentials for somebody else's
|
||||
// bucket do not stay, because they are not ours to keep once they are not in use.
|
||||
r2::forget()?;
|
||||
record(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"store": "none",
|
||||
"configured": false,
|
||||
"r2Bucket": serde_json::Value::Null,
|
||||
"r2Endpoint": serde_json::Value::Null
|
||||
}),
|
||||
)
|
||||
}
|
||||
other => Err(format!("{other} is not a backup store")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn backup_now(app: tauri::AppHandle) -> Result<BackupSettings, String> {
|
||||
let key = crypto::stored()?.ok_or(
|
||||
"Backup has no recovery phrase yet. Turn backup on and write the phrase down first.",
|
||||
)?;
|
||||
let chosen = chosen(&app)?;
|
||||
if matches!(chosen, Chosen::None) {
|
||||
return Err("No backup store is turned on.".to_string());
|
||||
}
|
||||
let mut absorbed = 0;
|
||||
for account in crate::accounts::list(&app)? {
|
||||
let pass = pass_for_account(&app, &chosen, &key, &account.id, &account.email).await?;
|
||||
absorbed += pass.absorbed;
|
||||
}
|
||||
|
||||
let settings = record(
|
||||
&app,
|
||||
serde_json::json!({ "lastBackupMs": chrono::Utc::now().timestamp_millis() }),
|
||||
)?;
|
||||
if absorbed > 0 {
|
||||
crate::emit_store_changed(&app, "state threads");
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Shown once, at setup, and never returned again.
|
||||
///
|
||||
/// Literally never: the phrase is generated here, its key is sealed, and the phrase itself is
|
||||
/// dropped at the end of this function. Argon2 does not run backwards, so there is nothing left on
|
||||
/// the device that could print it a second time even if a screen asked. That is the property being
|
||||
/// bought, and it is why the second call is an explanation rather than a repeat.
|
||||
#[tauri::command]
|
||||
pub async fn backup_phrase(app: tauri::AppHandle) -> Result<String, String> {
|
||||
if crypto::have_key() {
|
||||
return Err("The recovery phrase is shown once and is not kept anywhere, so it cannot be shown again. Backup on this device carries on working without it; the phrase is only needed to attach another device or to restore onto a new one.".to_string());
|
||||
}
|
||||
let phrase = phrase::generate()?;
|
||||
crypto::remember(&phrase::derive(&phrase)?)?;
|
||||
record(&app, serde_json::json!({ "hasPhrase": true }))?;
|
||||
Ok(phrase)
|
||||
}
|
||||
|
||||
/// Attaches this device to an existing backup, which is also how a lost device is replaced.
|
||||
///
|
||||
/// The phrase becomes the key, one pass brings down every other device's segments and replays
|
||||
/// them, and only then is the key sealed: a phrase that turns out not to be this backup's leaves
|
||||
/// the device exactly as it was rather than half attached to something it cannot read. There is
|
||||
/// nothing else to a restore. The journal rebuilds the tables and the mirror is derived from the
|
||||
/// provider, so what comes back is every decision and none of the mail, which fills itself in on
|
||||
/// the next sync.
|
||||
#[tauri::command]
|
||||
pub async fn backup_restore(app: tauri::AppHandle, phrase: String) -> Result<(), String> {
|
||||
let key = phrase::derive(&phrase)?;
|
||||
let chosen = chosen(&app)?;
|
||||
if matches!(chosen, Chosen::None) {
|
||||
return Err("Choose where the backup is kept before restoring from it.".to_string());
|
||||
}
|
||||
|
||||
for account in crate::accounts::list(&app)? {
|
||||
pass_for_account(&app, &chosen, &key, &account.id, &account.email).await?;
|
||||
}
|
||||
crypto::remember(&key)?;
|
||||
|
||||
record(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"hasPhrase": true,
|
||||
"lastBackupMs": chrono::Utc::now().timestamp_millis()
|
||||
}),
|
||||
)?;
|
||||
crate::emit_store_changed(&app, "state threads");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// The twenty-four words, and the only way back into a backup.
|
||||
//
|
||||
// The phrase is generated once, on the device, when backup is first turned on. It is shown once
|
||||
// and then it is gone: what stays behind is the key it derives, sealed by `crypto`, and Argon2 is
|
||||
// one way, so this app cannot show the phrase again even if a screen asked it to. That is the
|
||||
// property being bought. A phrase the app can reprint is a phrase the app is storing, and a phrase
|
||||
// the app is storing is one more file that has to be as well defended as the backup itself.
|
||||
//
|
||||
// BIP39 rather than a random string because the words are transcribable. This gets written on
|
||||
// paper, read out over a phone call and typed on a machine that is not the one it came from, and
|
||||
// the wordlist carries a checksum that catches a wrong word at the point of typing rather than as
|
||||
// a decryption failure ten seconds later.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use bip39::Mnemonic;
|
||||
|
||||
use super::crypto::Key;
|
||||
|
||||
/// Twenty-four words, so 256 bits of entropy. Twelve would be past anything anyone can brute force
|
||||
/// too, and the extra dozen words cost one more line on the piece of paper.
|
||||
pub const WORDS: usize = 24;
|
||||
|
||||
/// Argon2id, 64 MiB, three passes, one lane, 32 bytes out.
|
||||
///
|
||||
/// Argon2id because it is the hybrid: 2i alone gives up GPU resistance and 2d alone is open to a
|
||||
/// side channel, and RFC 9106 tells anyone without a specific reason to differ to use the hybrid.
|
||||
///
|
||||
/// The cost is RFC 9106's second recommended configuration, the one written for a machine that
|
||||
/// cannot spare a gigabyte, which is the right shape for something that has to run on a phone. It
|
||||
/// is a fraction of a second on a laptop and under a second on a handset, and it runs exactly
|
||||
/// twice in the life of an installation: once at setup and once at a restore. There is no
|
||||
/// interactive cost to trade it against, so there was no reason to go lower.
|
||||
///
|
||||
/// One lane rather than the four the RFC pairs with 64 MiB because this implementation walks the
|
||||
/// lanes in sequence rather than in threads. Four lanes would quarter the memory each one touches
|
||||
/// and shorten nothing, and the ratio between what this costs us and what it costs an attacker is
|
||||
/// the same either way.
|
||||
const M_COST_KIB: u32 = 64 * 1024;
|
||||
const T_COST: u32 = 3;
|
||||
const LANES: u32 = 1;
|
||||
|
||||
/// A constant, which for a password would be a bug and here is forced. A second device has the
|
||||
/// phrase and nothing else, so every input to the derivation has to be reachable from the phrase
|
||||
/// alone; a random salt would have to be stored somewhere, and the only place to store it is the
|
||||
/// backup, which cannot be read until the key exists. What a per user salt buys is that one
|
||||
/// precomputed table cannot answer for many users, and there is nothing to precompute against 256
|
||||
/// bits from the wordlist. The entropy is doing the work here and Argon2 is the belt to it.
|
||||
const SALT: &[u8] = b"margin-mail backup phrase v1";
|
||||
|
||||
/// A fresh phrase. Never returned twice: the caller shows it, seals the key it derives, and this
|
||||
/// module keeps nothing.
|
||||
pub fn generate() -> Result<String, String> {
|
||||
Mnemonic::generate(WORDS)
|
||||
.map(|mnemonic| mnemonic.to_string())
|
||||
.map_err(|e| format!("could not make a recovery phrase: {e}"))
|
||||
}
|
||||
|
||||
/// The phrase as the derivation sees it: the wordlist's own spelling, single spaced. Typing it in
|
||||
/// a different case, with an extra space or with a line break in the middle is the same phrase, and
|
||||
/// a mistyped word is refused here by name rather than becoming a wrong key and an unreadable
|
||||
/// backup twenty seconds later.
|
||||
///
|
||||
/// The case folding is not politeness. This gets typed on the phone that the backup is being
|
||||
/// restored onto, every soft keyboard capitalises the first word of what looks like a sentence, and
|
||||
/// the wordlist is lower case, so without this the commonest way of entering a phrase correctly
|
||||
/// fails as if the phrase were wrong.
|
||||
pub fn normalise(phrase: &str) -> Result<String, String> {
|
||||
let tidied = phrase
|
||||
.split_whitespace()
|
||||
.map(|word| word.to_lowercase())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ");
|
||||
Mnemonic::parse(&tidied)
|
||||
.map(|mnemonic| mnemonic.to_string())
|
||||
.map_err(|e| format!("that is not a recovery phrase: {e}"))
|
||||
}
|
||||
|
||||
pub fn derive(phrase: &str) -> Result<Key, String> {
|
||||
let normalised = normalise(phrase)?;
|
||||
let params = Params::new(M_COST_KIB, T_COST, LANES, Some(32))
|
||||
.map_err(|e| format!("the key derivation is misconfigured: {e}"))?;
|
||||
let mut out = [0u8; 32];
|
||||
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
|
||||
.hash_password_into(normalised.as_bytes(), SALT, &mut out)
|
||||
.map_err(|e| format!("could not derive the backup key: {e}"))?;
|
||||
Ok(Key::from_bytes(out))
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// `impl BackupStore for R2`: the same three operations over S3 signature version four, for
|
||||
// somebody who would rather their backup went to a bucket they own than to Drive.
|
||||
//
|
||||
// Cloudflare R2 is what this was written against and what the settings screen names, but nothing
|
||||
// below is R2 specific beyond the region: it signs the way S3 does, so any endpoint that speaks
|
||||
// path-style S3 with sigv4 works. The four fields are an endpoint, a bucket, an access key and a
|
||||
// secret, and the secret never crosses the IPC boundary in the reading direction, which is why
|
||||
// `dto::BackupSettings` carries the endpoint and the bucket and neither of the other two.
|
||||
//
|
||||
// The signing is by hand rather than through an SDK. `aws-sdk-s3` is a hundred crates and a
|
||||
// runtime of its own for three verbs, and the signing is a page of code with two published test
|
||||
// vectors to hold it to, which is what `tests.rs` does. Even the HMAC is here: the app does not
|
||||
// depend on the `hmac` crate, and the construction over a hash it already has is six lines.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::google::secrets;
|
||||
|
||||
use super::store::BackupStore;
|
||||
|
||||
/// The sealed credentials' name in the token store. Not an account id and unable to become one: a
|
||||
/// Google `sub` is digits and an email address cannot carry a space.
|
||||
const SECRET_ID: &str = "margin-mail r2 credentials";
|
||||
|
||||
const ALGORITHM: &str = "AWS4-HMAC-SHA256";
|
||||
const SERVICE: &str = "s3";
|
||||
|
||||
/// R2 has one region and calls it `auto`. It is not optional in a sigv4 signature even so, and
|
||||
/// Cloudflare's own SDK signs against this string.
|
||||
const REGION: &str = "auto";
|
||||
|
||||
/// Its own client rather than the Gmail layer's. They share no host, so one pool would never be
|
||||
/// reused, and a backup upload's timeout has nothing to do with a metadata batch's.
|
||||
static HTTP: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("could not build the HTTP client")
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub endpoint: String,
|
||||
pub bucket: String,
|
||||
pub access_key: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// What the settings screen sends. Refused here rather than at the first upload, because a
|
||||
/// typed-in endpoint that is wrong should say so while the person still has the field open.
|
||||
pub fn from_fields(fields: &HashMap<String, String>) -> Result<Config, String> {
|
||||
let field = |camel: &str, snake: &str, label: &str| -> Result<String, String> {
|
||||
let value = fields
|
||||
.get(camel)
|
||||
.or_else(|| fields.get(snake))
|
||||
.map(|value| value.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
if value.is_empty() {
|
||||
return Err(format!("The {label} is missing."));
|
||||
}
|
||||
Ok(value)
|
||||
};
|
||||
|
||||
let endpoint = field("endpoint", "endpoint", "endpoint")?;
|
||||
let endpoint = endpoint.trim_end_matches('/').to_string();
|
||||
// Not https means the request that carries the signature, and everything the bucket policy
|
||||
// rests on, travels in the open. The blob itself is already encrypted; the credentials in
|
||||
// the header are not.
|
||||
if !endpoint.starts_with("https://") {
|
||||
return Err("The endpoint has to start with https://".to_string());
|
||||
}
|
||||
if url::Url::parse(&endpoint)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(|host| host.to_string()))
|
||||
.is_none()
|
||||
{
|
||||
return Err(format!("{endpoint} is not an endpoint address."));
|
||||
}
|
||||
|
||||
let bucket = field("bucket", "bucket", "bucket name")?;
|
||||
if bucket.contains('/') {
|
||||
return Err("A bucket name is one word, with no slashes in it.".to_string());
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
endpoint,
|
||||
bucket,
|
||||
access_key: field("accessKey", "access_key", "access key")?,
|
||||
secret: field("secret", "secret", "secret access key")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sealed the way the OAuth tokens are, and for a stronger reason than the backup key: these are
|
||||
/// credentials to somebody else's paid account, and unlike the key they cannot be derived again
|
||||
/// from anything the person wrote down.
|
||||
pub fn remember(config: &Config) -> Result<(), String> {
|
||||
let json = serde_json::to_string(config).map_err(|e| e.to_string())?;
|
||||
secrets::store(SECRET_ID, &json)
|
||||
}
|
||||
|
||||
pub fn stored() -> Result<Option<Config>, String> {
|
||||
match secrets::load(SECRET_ID)? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("the stored R2 credentials are malformed: {e}")),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forget() -> Result<(), String> {
|
||||
secrets::delete(SECRET_ID)
|
||||
}
|
||||
|
||||
pub struct R2 {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl R2 {
|
||||
pub fn new(config: Config) -> R2 {
|
||||
R2 { config }
|
||||
}
|
||||
|
||||
fn host(&self) -> Result<String, String> {
|
||||
let url = url::Url::parse(&self.config.endpoint).map_err(|e| e.to_string())?;
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("{} is not an endpoint address.", self.config.endpoint))?;
|
||||
// The signed host has to be the host that goes on the wire, port and all, or the signature
|
||||
// covers a request nobody sent.
|
||||
Ok(match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds and sends one signed request. Everything that differs between the three operations is
|
||||
/// a parameter, so there is one place that knows how a request is signed.
|
||||
async fn send(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
body: Vec<u8>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let host = self.host()?;
|
||||
let payload_sha = hex(&Sha256::digest(&body));
|
||||
let amz_date = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
let canonical_query = canonical_query(query);
|
||||
let headers = [
|
||||
("host", host.clone()),
|
||||
("x-amz-content-sha256", payload_sha.clone()),
|
||||
("x-amz-date", amz_date.clone()),
|
||||
];
|
||||
let authorization = authorization(
|
||||
&self.config.access_key,
|
||||
&self.config.secret,
|
||||
REGION,
|
||||
SERVICE,
|
||||
method.as_str(),
|
||||
path,
|
||||
&canonical_query,
|
||||
&headers,
|
||||
&payload_sha,
|
||||
&amz_date,
|
||||
);
|
||||
|
||||
let mut url = format!("{}{path}", self.config.endpoint);
|
||||
if !canonical_query.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(&canonical_query);
|
||||
}
|
||||
|
||||
HTTP.request(method, url)
|
||||
.header("x-amz-content-sha256", &payload_sha)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header(reqwest::header::AUTHORIZATION, authorization)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("could not reach the bucket: {e}"))
|
||||
}
|
||||
|
||||
fn path_for(&self, name: &str) -> String {
|
||||
format!(
|
||||
"/{}/{}",
|
||||
encode(&self.config.bucket, false),
|
||||
encode(name, true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the store said when it said no. The XML body is worth keeping: an S3 error names the code
|
||||
/// in it, and "SignatureDoesNotMatch" is a different afternoon from "NoSuchBucket".
|
||||
async fn refused(context: &str, resp: reqwest::Response) -> String {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let detail = tag(&body, "Message").or_else(|| tag(&body, "Code"));
|
||||
match detail {
|
||||
Some(detail) => format!("{context} failed: {status}, {detail}"),
|
||||
None => format!("{context} failed: {status}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupStore for R2 {
|
||||
fn put(&self, name: &str, bytes: &[u8]) -> impl Future<Output = Result<(), String>> + Send {
|
||||
let path = self.path_for(name);
|
||||
let name = name.to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
async move {
|
||||
let resp = self.send(reqwest::Method::PUT, &path, &[], bytes).await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(refused(&format!("Uploading {name}"), resp).await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, name: &str) -> impl Future<Output = Result<Vec<u8>, String>> + Send {
|
||||
let path = self.path_for(name);
|
||||
let name = name.to_string();
|
||||
async move {
|
||||
let resp = self
|
||||
.send(reqwest::Method::GET, &path, &[], Vec::new())
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(refused(&format!("Downloading {name}"), resp).await);
|
||||
}
|
||||
resp.bytes()
|
||||
.await
|
||||
.map(|bytes| bytes.to_vec())
|
||||
.map_err(|e| format!("could not read {name}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `list-type=2`, paged until the store says it has stopped truncating. The keys come back in
|
||||
/// an XML document; the two fields that matter are scraped out of it rather than parsed,
|
||||
/// because a dependency on an XML crate to read `<Key>` would be a poor trade.
|
||||
fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<String>, String>> + Send {
|
||||
let path = format!("/{}", encode(&self.config.bucket, false));
|
||||
let prefix = prefix.to_string();
|
||||
async move {
|
||||
let mut names = Vec::new();
|
||||
let mut token: Option<String> = None;
|
||||
loop {
|
||||
let mut query = vec![("list-type", "2"), ("prefix", prefix.as_str())];
|
||||
if let Some(token) = &token {
|
||||
query.push(("continuation-token", token.as_str()));
|
||||
}
|
||||
let resp = self
|
||||
.send(reqwest::Method::GET, &path, &query, Vec::new())
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(refused("Listing the bucket", resp).await);
|
||||
}
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("could not read the bucket listing: {e}"))?;
|
||||
names.extend(tags(&body, "Key"));
|
||||
match tag(&body, "NextContinuationToken") {
|
||||
Some(next) if tag(&body, "IsTruncated").as_deref() == Some("true") => {
|
||||
token = Some(next)
|
||||
}
|
||||
_ => return Ok(names),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Signature version four
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().fold(String::new(), |mut out, byte| {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// HMAC-SHA256, RFC 2104, over the hash the app already depends on. The block size is 64 bytes for
|
||||
/// SHA-256 and every key sigv4 uses is shorter than that, but the long-key branch is here because
|
||||
/// leaving it out would make this a function that is right for one caller rather than an HMAC.
|
||||
pub(super) fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
|
||||
let mut block = [0u8; 64];
|
||||
if key.len() > 64 {
|
||||
block[..32].copy_from_slice(&Sha256::digest(key));
|
||||
} else {
|
||||
block[..key.len()].copy_from_slice(key);
|
||||
}
|
||||
let mut inner_pad = [0x36u8; 64];
|
||||
let mut outer_pad = [0x5cu8; 64];
|
||||
for at in 0..64 {
|
||||
inner_pad[at] ^= block[at];
|
||||
outer_pad[at] ^= block[at];
|
||||
}
|
||||
let inner = Sha256::new()
|
||||
.chain_update(inner_pad)
|
||||
.chain_update(message)
|
||||
.finalize();
|
||||
Sha256::new()
|
||||
.chain_update(outer_pad)
|
||||
.chain_update(inner)
|
||||
.finalize()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The four nested HMACs that turn a secret into a key good for one day, one region and one
|
||||
/// service. This is the derivation AWS publishes a test vector for, and `tests.rs` uses it.
|
||||
pub(super) fn signing_key(secret: &str, date: &str, region: &str, service: &str) -> [u8; 32] {
|
||||
let start = format!("AWS4{secret}");
|
||||
let key = hmac_sha256(start.as_bytes(), date.as_bytes());
|
||||
let key = hmac_sha256(&key, region.as_bytes());
|
||||
let key = hmac_sha256(&key, service.as_bytes());
|
||||
hmac_sha256(&key, b"aws4_request")
|
||||
}
|
||||
|
||||
/// Percent encoding as sigv4 defines it, which is stricter than a URL's: everything outside the
|
||||
/// unreserved set is encoded, and the slash survives only where it is separating path segments.
|
||||
pub(super) fn encode(value: &str, keep_slash: bool) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
b'/' if keep_slash => out.push('/'),
|
||||
other => out.push_str(&format!("%{other:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Sorted by name, encoded, joined. Every query this module sends has distinct names, so there is
|
||||
/// no tie to break on the value.
|
||||
fn canonical_query(query: &[(&str, &str)]) -> String {
|
||||
let mut pairs: Vec<String> = query
|
||||
.iter()
|
||||
.map(|(name, value)| format!("{}={}", encode(name, false), encode(value, false)))
|
||||
.collect();
|
||||
pairs.sort();
|
||||
pairs.join("&")
|
||||
}
|
||||
|
||||
/// The whole signature, as the `Authorization` header wants it.
|
||||
///
|
||||
/// Everything it needs is a parameter, including the moment and the header list, so that the
|
||||
/// published test vectors can be run through this exact function rather than through a
|
||||
/// reimplementation of it that might differ in the one place that matters.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn authorization(
|
||||
access_key: &str,
|
||||
secret: &str,
|
||||
region: &str,
|
||||
service: &str,
|
||||
method: &str,
|
||||
uri: &str,
|
||||
canonical_query: &str,
|
||||
headers: &[(&str, String)],
|
||||
payload_sha: &str,
|
||||
amz_date: &str,
|
||||
) -> String {
|
||||
let mut sorted: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.map(|(name, value)| (name.to_lowercase(), value.trim().to_string()))
|
||||
.collect();
|
||||
sorted.sort();
|
||||
let canonical_headers: String = sorted
|
||||
.iter()
|
||||
.map(|(name, value)| format!("{name}:{value}\n"))
|
||||
.collect();
|
||||
let signed_headers: Vec<&str> = sorted.iter().map(|(name, _)| name.as_str()).collect();
|
||||
let signed_headers = signed_headers.join(";");
|
||||
|
||||
let canonical_request = format!(
|
||||
"{method}\n{uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_sha}"
|
||||
);
|
||||
let date = &amz_date[..8.min(amz_date.len())];
|
||||
let scope = format!("{date}/{region}/{service}/aws4_request");
|
||||
let to_sign = format!(
|
||||
"{ALGORITHM}\n{amz_date}\n{scope}\n{}",
|
||||
hex(&Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
let signature = hex(&hmac_sha256(
|
||||
&signing_key(secret, date, region, service),
|
||||
to_sign.as_bytes(),
|
||||
));
|
||||
format!(
|
||||
"{ALGORITHM} Credential={access_key}/{scope}, SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Just enough XML
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn tags(xml: &str, name: &str) -> Vec<String> {
|
||||
let open = format!("<{name}>");
|
||||
let close = format!("</{name}>");
|
||||
let mut out = Vec::new();
|
||||
let mut rest = xml;
|
||||
while let Some(start) = rest.find(&open) {
|
||||
let after = &rest[start + open.len()..];
|
||||
let Some(end) = after.find(&close) else {
|
||||
break;
|
||||
};
|
||||
out.push(after[..end].to_string());
|
||||
rest = &after[end + close.len()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tag(xml: &str, name: &str) -> Option<String> {
|
||||
tags(xml, name).into_iter().next()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Three operations, and the reason there are only three.
|
||||
//
|
||||
// A journal segment is a whole blob written once under a name that never changes meaning, so
|
||||
// nothing above this trait needs a rename, a delete, a directory, a lock, a range read or a
|
||||
// conditional write. What is left is put, get and list, which is the intersection of Drive's REST
|
||||
// API and S3, and small enough that the second implementation was an afternoon and the one in
|
||||
// `tests.rs` is thirty lines. A backup whose store trait needs a transaction is a backup that
|
||||
// cannot be pointed at somebody's own bucket.
|
||||
//
|
||||
// A name is a path with forward slashes: `<account-hash>/<device-id>/<first>-<last>.seg`. Drive
|
||||
// has no paths, so it maps them onto folders; S3 has no folders, so it takes the name as the key.
|
||||
// Neither is allowed to invent a layout of its own, because two implementations that disagree
|
||||
// about where a segment lives are two backups that cannot be swapped.
|
||||
//
|
||||
// Async in the `Provider` trait's shape: `impl Future + Send` on a `Sync` trait, so there is no
|
||||
// boxing and no `async_trait`.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
pub trait BackupStore: Sync {
|
||||
/// Whole, or not at all. Both implementations write a blob in one request, so a name either
|
||||
/// does not exist or names every byte of a segment; a pass cut off halfway leaves the segments
|
||||
/// it finished and nothing else. `pass` depends on that and `tests.rs` holds it to it.
|
||||
fn put(&self, name: &str, bytes: &[u8]) -> impl Future<Output = Result<(), String>> + Send;
|
||||
|
||||
fn get(&self, name: &str) -> impl Future<Output = Result<Vec<u8>, String>> + Send;
|
||||
|
||||
/// Every name under a prefix, in no particular order. The caller sorts what it needs sorted.
|
||||
fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<String>, String>> + Send;
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
// The backup over a store that is a map in memory, which is the whole reason the store trait is
|
||||
// three operations.
|
||||
//
|
||||
// Nothing here touches a network, a Google account or a bucket. What is being tested is the part
|
||||
// that would be wrong in a way nobody notices: that two devices which never met land on the same
|
||||
// tables, that the ciphertext is ciphertext, and that a pass cut off halfway leaves the store in a
|
||||
// state the next pass can carry on from.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tauri::async_runtime::block_on;
|
||||
|
||||
use crate::db;
|
||||
use crate::dto::{Destination, Person, Pile};
|
||||
use crate::state::journal::{self, Payload};
|
||||
use crate::state::{device, merge, write};
|
||||
|
||||
use super::crypto::{self, Key};
|
||||
use super::store::BackupStore;
|
||||
use super::{account_hash, pass, phrase, r2, Journal, Pass};
|
||||
|
||||
// -- the harness -------------------------------------------------------------------------------
|
||||
|
||||
/// A data directory: one pair of in-memory databases, with a device id of its own.
|
||||
struct Device(Mutex<Connection>);
|
||||
|
||||
impl Journal for Device {
|
||||
fn with<T, F: FnOnce(&Connection) -> Result<T, String>>(&self, f: F) -> Result<T, String> {
|
||||
let conn = self.0.lock().map_err(|e| e.to_string())?;
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
impl Device {
|
||||
fn new() -> Device {
|
||||
Device(Mutex::new(
|
||||
db::memory().expect("a pair of in-memory databases"),
|
||||
))
|
||||
}
|
||||
|
||||
fn on<T>(&self, f: impl FnOnce(&Connection) -> T) -> T {
|
||||
let conn = self.0.lock().expect("the connection");
|
||||
f(&conn)
|
||||
}
|
||||
|
||||
fn id(&self) -> String {
|
||||
self.on(|conn| device::device_id(conn).expect("a device id"))
|
||||
}
|
||||
|
||||
/// Every materialised table, row by row, in an order that does not depend on how they were
|
||||
/// written. Two devices have converged when these are equal.
|
||||
fn snapshot(&self) -> Vec<String> {
|
||||
self.on(|conn| {
|
||||
let mut out = Vec::new();
|
||||
for table in TABLES {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!("SELECT * FROM state.{table}"))
|
||||
.expect("prepare");
|
||||
let columns = stmt.column_count();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let mut cells = Vec::new();
|
||||
for at in 0..columns {
|
||||
cells.push(format!("{:?}", row.get::<_, rusqlite::types::Value>(at)?));
|
||||
}
|
||||
Ok(cells.join("|"))
|
||||
})
|
||||
.expect("query");
|
||||
let mut table_rows: Vec<String> = rows
|
||||
.map(|row| format!("{table}: {}", row.expect("row")))
|
||||
.collect();
|
||||
table_rows.sort();
|
||||
out.extend(table_rows);
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const TABLES: [&str; 11] = [
|
||||
"sender_rules",
|
||||
"piles",
|
||||
"snoozes",
|
||||
"notes",
|
||||
"renames",
|
||||
"merges",
|
||||
"clips",
|
||||
"thread_flags",
|
||||
"contacts",
|
||||
"prefs",
|
||||
"markers",
|
||||
];
|
||||
|
||||
/// A store that is a map. `put` replaces a whole value under a lock, so a name never exists with
|
||||
/// half a segment behind it, which is the property both real stores get from writing a blob in one
|
||||
/// request.
|
||||
#[derive(Default)]
|
||||
struct Memory {
|
||||
blobs: Mutex<BTreeMap<String, Vec<u8>>>,
|
||||
/// How many more puts will be accepted before the store starts refusing, for the pass that gets
|
||||
/// cut off halfway.
|
||||
allowed: Mutex<Option<usize>>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn names(&self) -> Vec<String> {
|
||||
self.blobs.lock().expect("blobs").keys().cloned().collect()
|
||||
}
|
||||
|
||||
fn blob(&self, name: &str) -> Vec<u8> {
|
||||
self.blobs
|
||||
.lock()
|
||||
.expect("blobs")
|
||||
.get(name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{name} is not in the store"))
|
||||
}
|
||||
|
||||
fn breaks_after(&self, puts: usize) {
|
||||
*self.allowed.lock().expect("allowed") = Some(puts);
|
||||
}
|
||||
|
||||
fn mended(&self) {
|
||||
*self.allowed.lock().expect("allowed") = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupStore for Memory {
|
||||
fn put(&self, name: &str, bytes: &[u8]) -> impl Future<Output = Result<(), String>> + Send {
|
||||
let name = name.to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
async move {
|
||||
let mut allowed = self.allowed.lock().map_err(|e| e.to_string())?;
|
||||
match allowed.as_mut() {
|
||||
Some(0) => return Err("the network went away".to_string()),
|
||||
Some(left) => *left -= 1,
|
||||
None => {}
|
||||
}
|
||||
self.blobs
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.insert(name, bytes);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, name: &str) -> impl Future<Output = Result<Vec<u8>, String>> + Send {
|
||||
let name = name.to_string();
|
||||
async move {
|
||||
self.blobs
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.get(&name)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("{name} is not in the store"))
|
||||
}
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &str) -> impl Future<Output = Result<Vec<String>, String>> + Send {
|
||||
let prefix = prefix.to_string();
|
||||
async move {
|
||||
Ok(self
|
||||
.blobs
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.keys()
|
||||
.filter(|name| name.starts_with(&prefix))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ACCOUNT: &str = "[email protected]";
|
||||
|
||||
fn hash() -> String {
|
||||
account_hash(ACCOUNT)
|
||||
}
|
||||
|
||||
fn key() -> Key {
|
||||
Key::from_bytes([7u8; 32])
|
||||
}
|
||||
|
||||
fn run(device: &Device, store: &Memory, key: &Key) -> Pass {
|
||||
block_on(pass(device, store, key, &hash())).expect("a pass")
|
||||
}
|
||||
|
||||
fn ana() -> Person {
|
||||
Person {
|
||||
name: Some("Ana".to_string()),
|
||||
address: ACCOUNT.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- what goes up ------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn what_goes_up_comes_back_byte_for_byte_after_decryption() {
|
||||
let device = Device::new();
|
||||
let store = Memory::default();
|
||||
device.on(|conn| {
|
||||
write::set_rule(
|
||||
conn,
|
||||
"[email protected]",
|
||||
false,
|
||||
Destination::Inbox,
|
||||
None,
|
||||
)
|
||||
.expect("a rule");
|
||||
write::add_note(conn, "thread-1", "the roof is leaking", None).expect("a note");
|
||||
});
|
||||
|
||||
let pass = run(&device, &store, &key());
|
||||
assert_eq!(pass.uploaded, 1);
|
||||
assert_eq!(pass.downloaded, 0);
|
||||
|
||||
let names = store.names();
|
||||
assert_eq!(names.len(), 1);
|
||||
let expected = device.on(|conn| {
|
||||
let id = device::device_id(conn).expect("device");
|
||||
merge::encode(&merge::export(conn, &id, 0).expect("export")).expect("encode")
|
||||
});
|
||||
let plain = crypto::open(&key(), &names[0], &store.blob(&names[0])).expect("decrypt");
|
||||
assert_eq!(String::from_utf8(plain).expect("utf8"), expected);
|
||||
|
||||
// And a second pass finds nothing new to send, because the store is what says what it holds.
|
||||
assert_eq!(run(&device, &store, &key()).uploaded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_store_holds_ciphertext_and_names_that_say_nothing() {
|
||||
let device = Device::new();
|
||||
let store = Memory::default();
|
||||
device.on(|conn| {
|
||||
write::set_rule(conn, "[email protected]", false, Destination::Feed, None)
|
||||
.expect("a rule");
|
||||
write::add_note(conn, "thread-roof", "the roof is leaking", None).expect("a note");
|
||||
write::add_clip(
|
||||
conn,
|
||||
"thread-roof",
|
||||
"message-1",
|
||||
"the plumber comes on Thursday",
|
||||
&ana(),
|
||||
"About the roof",
|
||||
)
|
||||
.expect("a clip");
|
||||
});
|
||||
run(&device, &store, &key());
|
||||
|
||||
let secrets = [
|
||||
"[email protected]",
|
||||
"the roof is leaking",
|
||||
"the plumber comes on Thursday",
|
||||
"About the roof",
|
||||
"thread-roof",
|
||||
"note",
|
||||
"clip",
|
||||
"sender-rule",
|
||||
ACCOUNT,
|
||||
];
|
||||
for name in store.names() {
|
||||
let blob = store.blob(&name);
|
||||
let text = String::from_utf8_lossy(&blob).to_string();
|
||||
for secret in secrets {
|
||||
assert!(
|
||||
!text.contains(secret),
|
||||
"{secret} is readable in the segment at {name}"
|
||||
);
|
||||
assert!(!name.contains(secret), "{secret} is readable in {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_moved_to_another_devices_folder_does_not_open() {
|
||||
let device = Device::new();
|
||||
let store = Memory::default();
|
||||
device.on(|conn| write::set_pile(conn, "thread-1", Pile::ReplyLater).expect("a pile"));
|
||||
run(&device, &store, &key());
|
||||
|
||||
let name = store.names().into_iter().next().expect("a segment");
|
||||
let blob = store.blob(&name);
|
||||
assert!(crypto::open(&key(), &name, &blob).is_ok());
|
||||
|
||||
let moved = name.replace(&device.id(), "somebody-elses-device");
|
||||
assert!(
|
||||
crypto::open(&key(), &moved, &blob).is_err(),
|
||||
"a segment is authenticated under the name it was written to"
|
||||
);
|
||||
}
|
||||
|
||||
// -- two devices -------------------------------------------------------------------------------
|
||||
|
||||
/// The done check for this package. Two data directories, each with its own device id, each holding
|
||||
/// decisions the other never saw, meeting only through a store, in both orders.
|
||||
#[test]
|
||||
fn two_devices_converge_on_identical_tables_in_either_order() {
|
||||
for reversed in [false, true] {
|
||||
let one = Device::new();
|
||||
let two = Device::new();
|
||||
let store = Memory::default();
|
||||
|
||||
one.on(|conn| {
|
||||
write::set_rule(
|
||||
conn,
|
||||
"[email protected]",
|
||||
false,
|
||||
Destination::Inbox,
|
||||
None,
|
||||
)
|
||||
.expect("a rule");
|
||||
write::add_note(conn, "thread-roof", "the roof is leaking", None).expect("a note");
|
||||
write::set_pile(conn, "thread-roof", Pile::ReplyLater).expect("a pile");
|
||||
// The same key on both devices, decided at two different moments, so that convergence
|
||||
// here means agreeing about a conflict rather than merely taking a union.
|
||||
journal::append_at(
|
||||
conn,
|
||||
5_000,
|
||||
"thread-shared",
|
||||
&Payload::Rename {
|
||||
name: Some("what one called it".into()),
|
||||
},
|
||||
)
|
||||
.expect("a rename");
|
||||
});
|
||||
two.on(|conn| {
|
||||
write::set_rule(
|
||||
conn,
|
||||
"[email protected]",
|
||||
false,
|
||||
Destination::PaperTrail,
|
||||
None,
|
||||
)
|
||||
.expect("a rule");
|
||||
write::set_snooze(
|
||||
conn,
|
||||
"thread-bill",
|
||||
9_000,
|
||||
crate::dto::SnoozeKind::Tomorrow,
|
||||
0,
|
||||
)
|
||||
.expect("a snooze");
|
||||
journal::append_at(
|
||||
conn,
|
||||
9_000,
|
||||
"thread-shared",
|
||||
&Payload::Rename {
|
||||
name: Some("what two called it".into()),
|
||||
},
|
||||
)
|
||||
.expect("a rename");
|
||||
});
|
||||
|
||||
let (first, second) = if reversed { (&two, &one) } else { (&one, &two) };
|
||||
run(first, &store, &key());
|
||||
run(second, &store, &key());
|
||||
// The first device has not seen the second's yet: it uploaded before there was anything to
|
||||
// fetch. One more pass each way is what "converged" means.
|
||||
run(first, &store, &key());
|
||||
|
||||
assert_eq!(
|
||||
one.snapshot(),
|
||||
two.snapshot(),
|
||||
"the two devices disagree, reversed = {reversed}"
|
||||
);
|
||||
assert!(!one.snapshot().is_empty());
|
||||
// The later decision owns the shared key on both of them, whichever order they met in.
|
||||
assert!(one
|
||||
.snapshot()
|
||||
.iter()
|
||||
.any(|row| row.contains("what two called it")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_that_missed_a_month_catches_up_in_one_pass() {
|
||||
let store = Memory::default();
|
||||
let busy = Device::new();
|
||||
let kept_up = Device::new();
|
||||
let away = Device::new();
|
||||
|
||||
// Four weeks of decisions, with the device that was on backing up after each of them.
|
||||
for week in 0..4 {
|
||||
busy.on(|conn| {
|
||||
write::set_rule(
|
||||
conn,
|
||||
&format!("sender-{week}@example.com"),
|
||||
false,
|
||||
Destination::Feed,
|
||||
None,
|
||||
)
|
||||
.expect("a rule");
|
||||
write::add_note(conn, &format!("thread-{week}"), "worth remembering", None)
|
||||
.expect("a note");
|
||||
});
|
||||
run(&busy, &store, &key());
|
||||
run(&kept_up, &store, &key());
|
||||
}
|
||||
|
||||
let pass = run(&away, &store, &key());
|
||||
assert_eq!(pass.downloaded, 4, "one pass, four segments, nothing else");
|
||||
assert_eq!(away.snapshot(), kept_up.snapshot());
|
||||
assert_eq!(away.snapshot(), busy.snapshot());
|
||||
|
||||
// And having caught up, it asks for nothing the next time.
|
||||
assert_eq!(run(&away, &store, &key()).downloaded, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_device_attaches_with_the_phrase_and_reads_what_the_first_wrote() {
|
||||
let written = phrase::generate().expect("a phrase");
|
||||
assert_eq!(written.split_whitespace().count(), phrase::WORDS);
|
||||
let key = phrase::derive(&written).expect("the key");
|
||||
|
||||
let store = Memory::default();
|
||||
let first = Device::new();
|
||||
first.on(|conn| {
|
||||
write::add_note(conn, "thread-roof", "the roof is leaking", None).expect("a note");
|
||||
write::set_rule(
|
||||
conn,
|
||||
"[email protected]",
|
||||
false,
|
||||
Destination::Inbox,
|
||||
None,
|
||||
)
|
||||
.expect("a rule");
|
||||
});
|
||||
run(&first, &store, &key);
|
||||
|
||||
// The second device has the words off a piece of paper, typed the way somebody types.
|
||||
let second = Device::new();
|
||||
let typed = format!(" {} ", written.to_uppercase());
|
||||
let second_key = phrase::derive(&typed).expect("the same key");
|
||||
run(&second, &store, &second_key);
|
||||
|
||||
assert_eq!(second.snapshot(), first.snapshot());
|
||||
assert!(!second.snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_phrase_fails_clearly_rather_than_producing_garbage() {
|
||||
let store = Memory::default();
|
||||
let first = Device::new();
|
||||
let right = phrase::derive(&phrase::generate().expect("a phrase")).expect("a key");
|
||||
first.on(|conn| {
|
||||
write::add_note(conn, "thread-roof", "the roof is leaking", None).expect("a note");
|
||||
});
|
||||
run(&first, &store, &right);
|
||||
|
||||
let second = Device::new();
|
||||
let wrong = phrase::derive(&phrase::generate().expect("another phrase")).expect("a key");
|
||||
let refused = block_on(pass(&second, &store, &wrong, &hash())).expect_err("the wrong key");
|
||||
assert!(
|
||||
refused.contains("recovery phrase"),
|
||||
"the failure has to say what went wrong: {refused}"
|
||||
);
|
||||
assert!(
|
||||
second.snapshot().is_empty(),
|
||||
"nothing half decrypted landed in the tables"
|
||||
);
|
||||
|
||||
// A mistyped word is caught by the wordlist before any of that, and names itself.
|
||||
let mistyped = phrase::normalise(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zzzz",
|
||||
);
|
||||
assert!(mistyped
|
||||
.expect_err("not a phrase")
|
||||
.contains("recovery phrase"));
|
||||
}
|
||||
|
||||
// -- a pass that does not finish -----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_upload_leaves_whole_segments_and_the_next_pass_carries_on() {
|
||||
let store = Memory::default();
|
||||
let device = Device::new();
|
||||
// Two segments' worth, so there is a second put to refuse.
|
||||
device.on(|conn| {
|
||||
for at in 0..600 {
|
||||
write::set_marker(conn, &format!("place-{at}"), at as i64).expect("a marker");
|
||||
}
|
||||
});
|
||||
|
||||
store.breaks_after(1);
|
||||
let cut_off = block_on(pass(&device, &store, &key(), &hash())).expect_err("the network went");
|
||||
assert!(cut_off.contains("network"));
|
||||
|
||||
// What is on the store is one whole segment, not one and a half.
|
||||
let names = store.names();
|
||||
assert_eq!(names.len(), 1);
|
||||
let records = {
|
||||
let plain = crypto::open(&key(), &names[0], &store.blob(&names[0])).expect("decrypt");
|
||||
merge::decode(&String::from_utf8(plain).expect("utf8")).expect("decode")
|
||||
};
|
||||
assert_eq!(records.len(), super::SEGMENT_RECORDS);
|
||||
assert_eq!(records.first().expect("first").seq, 1);
|
||||
|
||||
store.mended();
|
||||
let mended = run(&device, &store, &key());
|
||||
assert_eq!(
|
||||
mended.uploaded, 1,
|
||||
"the segment that landed is not sent again"
|
||||
);
|
||||
assert_eq!(store.names().len(), 2);
|
||||
|
||||
let other = Device::new();
|
||||
run(&other, &store, &key());
|
||||
assert_eq!(other.snapshot(), device.snapshot());
|
||||
}
|
||||
|
||||
// -- the names -----------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_account_folder_is_named_by_a_hash_rather_than_by_an_address() {
|
||||
let hash = account_hash(ACCOUNT);
|
||||
assert_eq!(hash.len(), 32);
|
||||
assert!(!hash.contains('@'));
|
||||
assert_eq!(
|
||||
hash,
|
||||
account_hash(" [email protected] "),
|
||||
"one address, one folder, whatever the casing"
|
||||
);
|
||||
assert_ne!(hash, account_hash("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_name_carries_a_range_and_nothing_else() {
|
||||
let name = super::segment_name("abc", "device-1", 1, 500);
|
||||
assert_eq!(name, "abc/device-1/000000000001-000000000500.seg");
|
||||
assert_eq!(
|
||||
super::parse_name(&name),
|
||||
Some(("device-1".to_string(), 1, 500))
|
||||
);
|
||||
// Sorting names sorts the segments, which is why the numbers are padded.
|
||||
let mut names = vec![
|
||||
super::segment_name("abc", "device-1", 1001, 1500),
|
||||
super::segment_name("abc", "device-1", 1, 500),
|
||||
super::segment_name("abc", "device-1", 501, 1000),
|
||||
];
|
||||
names.sort();
|
||||
assert_eq!(names[0], super::segment_name("abc", "device-1", 1, 500));
|
||||
assert_eq!(names[2], super::segment_name("abc", "device-1", 1001, 1500));
|
||||
|
||||
assert_eq!(super::parse_name("abc/device-1/notes.txt"), None);
|
||||
assert_eq!(
|
||||
super::parse_name("abc/device-1/deeper/000000000001-000000000002.seg"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
// -- signature version four ------------------------------------------------------------------------
|
||||
|
||||
/// AWS publishes the signing key for these inputs, so this is the vector rather than a value read
|
||||
/// off this implementation and pasted back in.
|
||||
#[test]
|
||||
fn the_signing_key_matches_the_published_derivation() {
|
||||
let key = r2::signing_key(
|
||||
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
"20120215",
|
||||
"us-east-1",
|
||||
"iam",
|
||||
);
|
||||
let hex = key.iter().fold(String::new(), |mut out, byte| {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
out
|
||||
});
|
||||
assert_eq!(
|
||||
hex,
|
||||
"f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
|
||||
);
|
||||
}
|
||||
|
||||
/// `get-vanilla` from AWS's own signature version four test suite, end to end through the same
|
||||
/// function the uploads go through.
|
||||
#[test]
|
||||
fn the_authorization_header_matches_the_published_test_suite() {
|
||||
let header = r2::authorization(
|
||||
"AKIDEXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
"us-east-1",
|
||||
"service",
|
||||
"GET",
|
||||
"/",
|
||||
"",
|
||||
&[
|
||||
("host", "example.amazonaws.com".to_string()),
|
||||
("x-amz-date", "20150830T123600Z".to_string()),
|
||||
],
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"20150830T123600Z",
|
||||
);
|
||||
assert_eq!(
|
||||
header,
|
||||
"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \
|
||||
SignedHeaders=host;x-amz-date, \
|
||||
Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bucket_listing_is_read_out_of_the_xml_it_arrives_in() {
|
||||
let body = "<?xml version=\"1.0\"?><ListBucketResult><IsTruncated>false</IsTruncated>\
|
||||
<Contents><Key>abc/device-1/000000000001-000000000500.seg</Key><Size>10</Size></Contents>\
|
||||
<Contents><Key>abc/device-2/000000000001-000000000004.seg</Key></Contents></ListBucketResult>";
|
||||
assert_eq!(
|
||||
r2::tags(body, "Key"),
|
||||
vec![
|
||||
"abc/device-1/000000000001-000000000500.seg".to_string(),
|
||||
"abc/device-2/000000000001-000000000004.seg".to_string()
|
||||
]
|
||||
);
|
||||
assert!(r2::tags(body, "NextContinuationToken").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_fields_are_checked_while_the_person_still_has_the_form_open() {
|
||||
let fields = |pairs: &[(&str, &str)]| {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect::<std::collections::HashMap<_, _>>()
|
||||
};
|
||||
|
||||
let good = r2::Config::from_fields(&fields(&[
|
||||
("endpoint", "https://example.r2.cloudflarestorage.com/"),
|
||||
("bucket", "margin"),
|
||||
("accessKey", "key"),
|
||||
("secret", "shh"),
|
||||
]))
|
||||
.expect("four fields");
|
||||
assert_eq!(good.endpoint, "https://example.r2.cloudflarestorage.com");
|
||||
|
||||
assert!(r2::Config::from_fields(&fields(&[
|
||||
("endpoint", "http://example.com"),
|
||||
("bucket", "margin"),
|
||||
("accessKey", "key"),
|
||||
("secret", "shh")
|
||||
]))
|
||||
.is_err());
|
||||
assert!(r2::Config::from_fields(&fields(&[
|
||||
("endpoint", "https://example.com"),
|
||||
("bucket", "margin"),
|
||||
("accessKey", "key")
|
||||
]))
|
||||
.expect_err("no secret")
|
||||
.contains("secret"));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// The number on the dock icon.
|
||||
//
|
||||
// One number, one icon, every account summed, and it is the count of unseen threads in the Inbox
|
||||
// rather than the mailbox's unread count. Mailspring shows 999+ on a Gmail account because it
|
||||
// counts every unread message carrying the `INBOX` label, and that number is exactly the noise
|
||||
// this app was written to remove: what it is supposed to say is how many things are actually
|
||||
// waiting for a decision.
|
||||
//
|
||||
// The count itself is `mirror::read::inbox_unseen`, which is the list's own definition of the
|
||||
// group counted rather than selected. Nothing in this file knows what the Inbox is, on purpose.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::mirror::read;
|
||||
|
||||
/// Long enough for a sync pass to finish landing what it landed, short enough that nobody sees the
|
||||
/// lag on a number they read with their eyes.
|
||||
const SETTLE_MS: u64 = 200;
|
||||
|
||||
/// True while a recount is already on its way, so a burst of changes costs one query rather than
|
||||
/// one each.
|
||||
static QUEUED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// macOS and Linux, and nowhere else.
|
||||
///
|
||||
/// `set_badge_count` compiles everywhere, so this is about honesty rather than about the build: on
|
||||
/// Windows the call does nothing and the taskbar wants `set_overlay_icon` with an image drawn for
|
||||
/// it, which is not written here, and Android has no badge at all. A platform with no badge to
|
||||
/// paint has no reason to run the count either, so this gates the whole of it rather than the last
|
||||
/// line of it.
|
||||
const CARRIES_A_BADGE: bool = cfg!(any(target_os = "macos", target_os = "linux"));
|
||||
|
||||
/// Which `store-changed` scopes can move the number.
|
||||
///
|
||||
/// `emit_store_changed` fires for everything the app does and most of it cannot touch this count. A
|
||||
/// body landing is scoped `thread`, a clip is `state`, a drained outbox is `outbox`, and none of
|
||||
/// the three adds or removes a thread from New for you. Reading the scope here is what keeps the
|
||||
/// badge a query per real change rather than a query per event.
|
||||
///
|
||||
/// `settings` is in the list because the toggle itself has to be obeyed at once, and `accounts`
|
||||
/// because an account added or removed changes what there is to sum over.
|
||||
pub fn moves_the_count(reason: &str) -> bool {
|
||||
reason
|
||||
.split_whitespace()
|
||||
.any(|scope| matches!(scope, "threads" | "accounts" | "settings"))
|
||||
}
|
||||
|
||||
/// What the dock should show, which is nothing at all rather than a `0`.
|
||||
///
|
||||
/// Separate from the call that puts it there so that the rule can be tested without a running app,
|
||||
/// and because `Some(0)` and `None` mean the same thing to the platform while only one of them is
|
||||
/// what is meant here.
|
||||
pub fn to_show(on: bool, count: i64) -> Option<i64> {
|
||||
(on && count > 0).then_some(count)
|
||||
}
|
||||
|
||||
/// Every account's New for you, added up.
|
||||
pub fn count(db: &Db) -> Result<i64, String> {
|
||||
let mut total = 0;
|
||||
for account_id in db.on_disk() {
|
||||
total += db.with(&account_id, read::inbox_unseen)?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// The hook on the one notification path there is. `lib.rs` calls this from `emit_store_changed`,
|
||||
/// so the badge follows the same signal the frontend does rather than needing its own.
|
||||
pub fn on_store_changed(app: &tauri::AppHandle, reason: &str) {
|
||||
if moves_the_count(reason) {
|
||||
refresh(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recounts and repaints, once, shortly.
|
||||
///
|
||||
/// Deferred rather than done here because a first sync emits as it goes and thirty-nine counts on
|
||||
/// the way to the fortieth are thirty-nine wasted, and because the emit sits on the return path of
|
||||
/// whatever command made the change, which has no business waiting on a query nobody asked it for.
|
||||
pub fn refresh(app: &tauri::AppHandle) {
|
||||
if !CARRIES_A_BADGE || QUEUED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(SETTLE_MS)).await;
|
||||
QUEUED.store(false, Ordering::SeqCst);
|
||||
apply(&app);
|
||||
});
|
||||
}
|
||||
|
||||
fn apply(app: &tauri::AppHandle) {
|
||||
let on = crate::settings::load(app)
|
||||
.map(|settings| settings.badge)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Turning it off is a clear, and it does not need the count to know that.
|
||||
if !on {
|
||||
show(app, None);
|
||||
return;
|
||||
}
|
||||
let Some(db) = app.try_state::<Db>() else {
|
||||
return;
|
||||
};
|
||||
// A badge cleared because a query failed reads as "nothing is waiting", which is the one thing
|
||||
// it must never say wrongly. Leave what is on the dock and try again at the next change.
|
||||
if let Ok(count) = count(db.inner()) {
|
||||
show(app, to_show(true, count));
|
||||
}
|
||||
}
|
||||
|
||||
/// The window rather than the app, because that is where tauri hangs the call. On macOS it reaches
|
||||
/// the dock tile, which is the app's and not the window's, so a window that has been closed to the
|
||||
/// menu bar still carries the right number.
|
||||
fn show(app: &tauri::AppHandle, count: Option<i64>) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_badge_count(count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// Clips, and the All files place.
|
||||
//
|
||||
// Two libraries built from what is already on the device. A clip is a sentence somebody kept, held
|
||||
// in the state database with the thread and the sender it came from, so it survives the mirror
|
||||
// being thrown away. All files is the other way round: it is entirely derived, it fetches nothing,
|
||||
// and opening a card is what puts a byte on the network.
|
||||
//
|
||||
// The exclusion in `files_list` is the difference between a library and a wall of logos. Every
|
||||
// newsletter carries a signature image, every one of them is inline and a few kilobytes, and a
|
||||
// files place that lists them buries the contract somebody is looking for under two hundred of
|
||||
// them.
|
||||
|
||||
use rusqlite::{params_from_iter, types::Value, Connection, OptionalExtension};
|
||||
|
||||
use crate::decisions::{account_holding, db_of};
|
||||
use crate::dto::{Attachment, Clip, FileCard, Person, Undo};
|
||||
use crate::state::{self, journal::Payload};
|
||||
use crate::sync;
|
||||
use crate::undo::Stack;
|
||||
|
||||
static UNDO: Stack<UndoClip> = Stack::new("clip");
|
||||
|
||||
/// Enough that nobody's library is truncated, and a ceiling rather than none at all.
|
||||
const MAX_CLIPS: u32 = 5_000;
|
||||
|
||||
/// Signature junk, as `docs/features.md` section 10 defines it: an image under ten kilobytes that
|
||||
/// the body refers to rather than lists.
|
||||
const SIGNATURE_BYTES: u64 = 10 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Clips
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn clip_by_id(conn: &Connection, account_id: &str, id: &str) -> Result<Option<Clip>, String> {
|
||||
Ok(state::read::clips(conn, account_id, MAX_CLIPS)?
|
||||
.into_iter()
|
||||
.find(|clip| clip.id == id))
|
||||
}
|
||||
|
||||
/// Who wrote the message the clip was taken from, and what the thread is called. Kept on the clip
|
||||
/// rather than looked up later, because a clip outlives the message: the window moves on and the
|
||||
/// card still has to say where the words came from.
|
||||
fn provenance(conn: &Connection, message_id: &str) -> Result<(Person, String), String> {
|
||||
conn.query_row(
|
||||
"SELECT m.from_name, m.from_address, COALESCE(rn.name, m.subject)
|
||||
FROM messages m
|
||||
LEFT JOIN state.renames rn ON rn.thread_key = m.thread_key
|
||||
WHERE m.id = ?1",
|
||||
[message_id],
|
||||
|row| {
|
||||
Ok((
|
||||
Person {
|
||||
name: row.get(0)?,
|
||||
address: row.get(1)?,
|
||||
},
|
||||
row.get(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "that message is not on this device".to_string())
|
||||
}
|
||||
|
||||
pub fn save(
|
||||
conn: &Connection,
|
||||
account_id: &str,
|
||||
thread_key: &str,
|
||||
message_id: &str,
|
||||
text: &str,
|
||||
) -> Result<Clip, String> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("a clip needs some words in it".into());
|
||||
}
|
||||
let (sender, subject) = provenance(conn, message_id)?;
|
||||
let id = state::write::add_clip(conn, thread_key, message_id, text, &sender, &subject)?;
|
||||
clip_by_id(conn, account_id, &id)?.ok_or_else(|| "that clip did not land".to_string())
|
||||
}
|
||||
|
||||
/// Puts a deleted clip back. Deletion is a flag rather than a missing row so that two devices
|
||||
/// resolve it the same way, and that is what makes this possible.
|
||||
fn restore(conn: &Connection, clip: &Clip) -> Result<(), String> {
|
||||
state::journal::append(
|
||||
conn,
|
||||
&clip.id,
|
||||
&Payload::Clip {
|
||||
thread_key: clip.thread_key.clone(),
|
||||
message_id: clip.message_id.clone(),
|
||||
text: clip.text.clone(),
|
||||
sender_name: clip.sender.name.clone(),
|
||||
sender_address: clip.sender.address.clone(),
|
||||
subject: clip.subject.clone(),
|
||||
created_at: clip.created_at_ms,
|
||||
deleted: false,
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// All files
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn extension(filename: &str) -> String {
|
||||
filename
|
||||
.rsplit_once('.')
|
||||
.map(|(_, ext)| ext.to_lowercase())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The eight buckets from section 10, decided from the MIME type first and the extension second.
|
||||
///
|
||||
/// Both, because neither is reliable on its own: half the world sends a spreadsheet as
|
||||
/// `application/octet-stream`, and a file called `notes` with `text/calendar` on it is an invite.
|
||||
/// The order matters in one place, which is that an invite is checked before a document, because
|
||||
/// `text/calendar` would otherwise be caught as text.
|
||||
pub fn category_of(mime_type: &str, filename: &str) -> &'static str {
|
||||
let mime = mime_type.trim().to_lowercase();
|
||||
let mime = mime.split(';').next().unwrap_or("").trim();
|
||||
let ext = extension(filename);
|
||||
|
||||
if mime == "text/calendar" || mime == "application/ics" || ext == "ics" || ext == "ical" {
|
||||
return "invites";
|
||||
}
|
||||
if mime == "application/pdf" || ext == "pdf" {
|
||||
return "pdfs";
|
||||
}
|
||||
if mime.starts_with("image/")
|
||||
|| matches!(
|
||||
ext.as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "heic" | "bmp" | "tiff" | "svg" | "avif"
|
||||
)
|
||||
{
|
||||
return "images";
|
||||
}
|
||||
if SPREADSHEET_TYPES.contains(&mime)
|
||||
|| matches!(ext.as_str(), "xls" | "xlsx" | "xlsm" | "ods" | "csv" | "tsv" | "numbers")
|
||||
{
|
||||
return "spreadsheets";
|
||||
}
|
||||
if PRESENTATION_TYPES.contains(&mime)
|
||||
|| matches!(ext.as_str(), "ppt" | "pptx" | "odp" | "key")
|
||||
{
|
||||
return "presentations";
|
||||
}
|
||||
if ARCHIVE_TYPES.contains(&mime)
|
||||
|| matches!(ext.as_str(), "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "7z" | "rar")
|
||||
{
|
||||
return "archives";
|
||||
}
|
||||
if DOCUMENT_TYPES.contains(&mime)
|
||||
|| mime.starts_with("text/")
|
||||
|| matches!(ext.as_str(), "doc" | "docx" | "odt" | "rtf" | "txt" | "md" | "pages" | "epub")
|
||||
{
|
||||
return "documents";
|
||||
}
|
||||
"other"
|
||||
}
|
||||
|
||||
const SPREADSHEET_TYPES: [&str; 4] = [
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/csv",
|
||||
];
|
||||
|
||||
const PRESENTATION_TYPES: [&str; 3] = [
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
];
|
||||
|
||||
const ARCHIVE_TYPES: [&str; 8] = [
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-gzip",
|
||||
"application/x-7z-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/x-rar-compressed",
|
||||
];
|
||||
|
||||
const DOCUMENT_TYPES: [&str; 4] = [
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/rtf",
|
||||
];
|
||||
|
||||
/// True for the images a signature block hangs off the bottom of a message.
|
||||
fn signature_junk(attachment: &Attachment, category: &str) -> bool {
|
||||
category == "images" && attachment.inline && attachment.size < SIGNATURE_BYTES
|
||||
}
|
||||
|
||||
/// Every file on the device as a card, newest first, filtered by type and by sender.
|
||||
///
|
||||
/// Trash and spam are left out. The place is a library and the mail is still there to be found in
|
||||
/// Trash; a library that lists the attachments of deleted mail is a library nobody trusts.
|
||||
pub fn files(conn: &Connection, category: &str, sender: &str) -> Result<Vec<FileCard>, String> {
|
||||
let chain = state::read::CHAIN;
|
||||
let sql = format!(
|
||||
"{chain}
|
||||
SELECT a.id, a.message_id, a.filename, a.mime_type, a.size, a.inline, a.content_id,
|
||||
a.cached_path IS NOT NULL AS cached,
|
||||
COALESCE((SELECT key FROM chain
|
||||
WHERE source = t.thread_key
|
||||
AND key NOT IN (SELECT thread_key FROM state.merges) LIMIT 1),
|
||||
t.thread_key) AS thread_key,
|
||||
COALESCE(rn.name, t.subject) AS subject,
|
||||
m.from_name AS from_name, m.from_address AS from_address, m.date_ms AS date_ms
|
||||
FROM attachments a
|
||||
JOIN messages m ON m.id = a.message_id
|
||||
JOIN threads t ON t.provider_thread_id = m.provider_thread_id
|
||||
LEFT JOIN state.renames rn ON rn.thread_key = t.thread_key
|
||||
WHERE t.trashed = 0 AND t.spam = 0
|
||||
AND (? = '' OR lower(m.from_address) = ?)
|
||||
ORDER BY m.date_ms DESC, a.id ASC"
|
||||
);
|
||||
let sender = sender.trim().to_lowercase();
|
||||
let params = vec![Value::Text(sender.clone()), Value::Text(sender)];
|
||||
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(params_from_iter(params), |row| {
|
||||
Ok((
|
||||
Attachment {
|
||||
id: row.get("id")?,
|
||||
message_id: row.get("message_id")?,
|
||||
filename: row.get("filename")?,
|
||||
mime_type: row.get("mime_type")?,
|
||||
size: row.get::<_, i64>("size")?.max(0) as u64,
|
||||
inline: row.get::<_, i64>("inline")? != 0,
|
||||
content_id: row.get("content_id")?,
|
||||
cached: row.get::<_, i64>("cached")? != 0,
|
||||
},
|
||||
row.get::<_, String>("thread_key")?,
|
||||
row.get::<_, String>("subject")?,
|
||||
Person {
|
||||
name: row.get("from_name")?,
|
||||
address: row.get("from_address")?,
|
||||
},
|
||||
row.get::<_, i64>("date_ms")?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let wanted = category.trim();
|
||||
let mut out = Vec::new();
|
||||
for (attachment, thread_key, subject, sender, date_ms) in rows {
|
||||
let category = category_of(&attachment.mime_type, &attachment.filename);
|
||||
if signature_junk(&attachment, category) {
|
||||
continue;
|
||||
}
|
||||
if !wanted.is_empty() && wanted != category {
|
||||
continue;
|
||||
}
|
||||
out.push(FileCard {
|
||||
attachment,
|
||||
thread_key,
|
||||
subject,
|
||||
sender,
|
||||
date_ms,
|
||||
category: category.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Undo
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
pub struct UndoClip {
|
||||
pub account_id: String,
|
||||
pub clip: Clip,
|
||||
}
|
||||
|
||||
pub fn owns(token: &str) -> bool {
|
||||
UNDO.owns(token)
|
||||
}
|
||||
|
||||
pub fn undo_apply(app: &tauri::AppHandle, token: &str) -> Result<(), String> {
|
||||
let entry = UNDO
|
||||
.take(token)
|
||||
.ok_or("that clip can no longer be brought back")?;
|
||||
let db = db_of(app)?;
|
||||
db.with(&entry.account_id, |conn| restore(conn, &entry.clip))?;
|
||||
crate::emit_store_changed(app, "state");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn clip_save(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
thread_key: String,
|
||||
message_id: String,
|
||||
text: String,
|
||||
) -> Result<Clip, String> {
|
||||
let db = db_of(&app)?;
|
||||
let clip = db.with(&account_id, |conn| {
|
||||
save(conn, &account_id, &thread_key, &message_id, &text)
|
||||
})?;
|
||||
crate::emit_store_changed(&app, "state");
|
||||
Ok(clip)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn clips_list(
|
||||
app: tauri::AppHandle,
|
||||
account_id: Option<String>,
|
||||
) -> Result<Vec<Clip>, String> {
|
||||
let db = db_of(&app)?;
|
||||
let mut all = Vec::new();
|
||||
for (id, _) in sync::accounts(db.inner(), account_id.as_deref()) {
|
||||
all.extend(db.with(&id, |conn| state::read::clips(conn, &id, MAX_CLIPS))?);
|
||||
}
|
||||
all.sort_by(|a, b| b.created_at_ms.cmp(&a.created_at_ms).then(b.id.cmp(&a.id)));
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn clip_delete(app: tauri::AppHandle, id: String) -> Result<Undo, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = account_holding(
|
||||
db.inner(),
|
||||
"SELECT COUNT(*) FROM state.clips WHERE id = ?1 AND deleted = 0",
|
||||
&id,
|
||||
)
|
||||
.map_err(|_| "that clip is not here".to_string())?;
|
||||
let clip = db.with(&account_id, |conn| {
|
||||
let held = clip_by_id(conn, &account_id, &id)?
|
||||
.ok_or_else(|| "that clip is not here".to_string())?;
|
||||
state::write::delete_clip(conn, &id)?;
|
||||
Ok(held)
|
||||
})?;
|
||||
crate::emit_store_changed(&app, "state");
|
||||
|
||||
let token = UNDO.push(UndoClip { account_id, clip }, "Clip deleted");
|
||||
Ok(Undo {
|
||||
token,
|
||||
label: "Clip deleted".to_string(),
|
||||
undo_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The All files place. Built from the local index and fetches nothing: the card knows the name,
|
||||
/// the type, the size, the sender and the thread without a byte leaving the machine.
|
||||
#[tauri::command(async)]
|
||||
pub fn files_list(
|
||||
app: tauri::AppHandle,
|
||||
account_id: Option<String>,
|
||||
category: String,
|
||||
sender: String,
|
||||
) -> Result<Vec<FileCard>, String> {
|
||||
let db = db_of(&app)?;
|
||||
let mut all = Vec::new();
|
||||
for (id, _) in sync::accounts(db.inner(), account_id.as_deref()) {
|
||||
all.extend(db.with(&id, |conn| files(conn, &category, &sender))?);
|
||||
}
|
||||
all.sort_by(|a, b| {
|
||||
b.date_ms
|
||||
.cmp(&a.date_ms)
|
||||
.then(a.attachment.id.cmp(&b.attachment.id))
|
||||
});
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,250 @@
|
||||
// Clips and All files, over a pair of in-memory databases. Nothing here fetches anything, which is
|
||||
// the point of the place: the cards are built from the index the sync already wrote.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db;
|
||||
use crate::state;
|
||||
|
||||
fn open() -> Connection {
|
||||
db::memory().expect("a pair of in-memory databases")
|
||||
}
|
||||
|
||||
/// One file, and the message and thread it hangs off. A struct rather than nine arguments, which
|
||||
/// is what `screener::tests` does with a sender for the same reason.
|
||||
struct File<'a> {
|
||||
id: &'a str,
|
||||
address: &'a str,
|
||||
subject: &'a str,
|
||||
at: i64,
|
||||
filename: &'a str,
|
||||
mime_type: &'a str,
|
||||
size: i64,
|
||||
/// Referenced from the body by `cid:` rather than listed as a chip.
|
||||
inline: bool,
|
||||
}
|
||||
|
||||
fn with_file(conn: &Connection, file: &File<'_>) -> String {
|
||||
let File {
|
||||
id,
|
||||
address,
|
||||
subject,
|
||||
at,
|
||||
filename,
|
||||
mime_type,
|
||||
size,
|
||||
inline,
|
||||
} = *file;
|
||||
let key = format!("<{id}@example>");
|
||||
let tid = format!("t-{id}");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (provider_thread_id, thread_key, latest_ms, message_count, unseen,
|
||||
subject, snippet, from_name, from_address, in_inbox, has_attachment)
|
||||
VALUES (?1, ?2, ?3, 1, 0, ?4, 'snippet', 'Someone', ?5, 1, 1)",
|
||||
rusqlite::params![tid, key, at, subject, address],
|
||||
)
|
||||
.expect("a thread");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
||||
from_name, from_address, subject, snippet, hydrated,
|
||||
has_attachment, labels)
|
||||
VALUES (?1, ?2, ?3, ?3, ?4, 'Someone', ?5, ?6, 'snippet', 1, 1, '[\"INBOX\"]')",
|
||||
rusqlite::params![format!("m-{id}"), tid, key, at, address, subject],
|
||||
)
|
||||
.expect("a message");
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (id, message_id, part_id, filename, mime_type, size, inline)
|
||||
VALUES (?1, ?2, '2', ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
format!("a-{id}"),
|
||||
format!("m-{id}"),
|
||||
filename,
|
||||
mime_type,
|
||||
size,
|
||||
inline as i64
|
||||
],
|
||||
)
|
||||
.expect("a file");
|
||||
key
|
||||
}
|
||||
|
||||
fn pdf<'a>(id: &'a str, address: &'a str, subject: &'a str, at: i64) -> File<'a> {
|
||||
File {
|
||||
id,
|
||||
address,
|
||||
subject,
|
||||
at,
|
||||
filename: "quote.pdf",
|
||||
mime_type: "application/pdf",
|
||||
size: 90_000,
|
||||
inline: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn image<'a>(
|
||||
id: &'a str,
|
||||
address: &'a str,
|
||||
subject: &'a str,
|
||||
at: i64,
|
||||
filename: &'a str,
|
||||
size: i64,
|
||||
inline: bool,
|
||||
) -> File<'a> {
|
||||
File {
|
||||
id,
|
||||
address,
|
||||
subject,
|
||||
at,
|
||||
filename,
|
||||
mime_type: "image/png",
|
||||
size,
|
||||
inline,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Clips
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_clip_keeps_the_words_and_where_they_came_from() {
|
||||
let conn = open();
|
||||
let key = with_file(&conn, &pdf("one", "[email protected]", "The kitchen quote", 300));
|
||||
|
||||
let clip = super::save(&conn, "acct", &key, "m-one", " the price holds until March ")
|
||||
.expect("a clip");
|
||||
assert_eq!(clip.text, "the price holds until March");
|
||||
assert_eq!(clip.sender.address, "[email protected]");
|
||||
assert_eq!(clip.subject, "The kitchen quote");
|
||||
assert_eq!(clip.thread_key, key);
|
||||
|
||||
assert_eq!(
|
||||
state::read::clips(&conn, "acct", 50).expect("the clips").len(),
|
||||
1
|
||||
);
|
||||
|
||||
state::write::delete_clip(&conn, &clip.id).expect("deleted");
|
||||
assert!(state::read::clips(&conn, "acct", 50).expect("the clips").is_empty());
|
||||
|
||||
super::restore(&conn, &clip).expect("put back");
|
||||
let held = state::read::clips(&conn, "acct", 50).expect("the clips");
|
||||
assert_eq!(held.len(), 1);
|
||||
assert_eq!(held[0].text, "the price holds until March");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// All files
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_file_is_categorised_from_its_type_and_its_extension() {
|
||||
for (mime_type, filename, expected) in [
|
||||
("application/pdf", "quote.pdf", "pdfs"),
|
||||
("image/png", "plan.png", "images"),
|
||||
("text/calendar", "invite.ics", "invites"),
|
||||
(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"costs.xlsx",
|
||||
"spreadsheets",
|
||||
),
|
||||
("application/vnd.ms-powerpoint", "deck.ppt", "presentations"),
|
||||
("application/msword", "letter.doc", "documents"),
|
||||
("application/zip", "photos.zip", "archives"),
|
||||
("application/octet-stream", "notes.md", "documents"),
|
||||
// The type is nothing and the extension is everything, which is most of what arrives.
|
||||
("application/octet-stream", "costs.csv", "spreadsheets"),
|
||||
("application/octet-stream", "backup.tar.gz", "archives"),
|
||||
("application/octet-stream", "firmware.bin", "other"),
|
||||
] {
|
||||
assert_eq!(
|
||||
super::category_of(mime_type, filename),
|
||||
expected,
|
||||
"{filename} as {mime_type}"
|
||||
);
|
||||
}
|
||||
// A charset on the header does not make it a different type.
|
||||
assert_eq!(
|
||||
super::category_of("text/calendar; charset=utf-8", "invite"),
|
||||
"invites"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_library_lists_every_file_newest_first_and_filters_by_type_and_sender() {
|
||||
let conn = open();
|
||||
with_file(&conn, &pdf("pdf", "[email protected]", "Quote", 300));
|
||||
with_file(&conn, &image("img", "[email protected]", "Plans", 200, "plan.png", 400_000, false));
|
||||
with_file(
|
||||
&conn,
|
||||
&File {
|
||||
id: "csv",
|
||||
address: "[email protected]",
|
||||
subject: "Costs",
|
||||
at: 100,
|
||||
filename: "costs.csv",
|
||||
mime_type: "text/csv",
|
||||
size: 2_000,
|
||||
inline: false,
|
||||
},
|
||||
);
|
||||
|
||||
let all = super::files(&conn, "", "").expect("the library");
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].attachment.filename, "quote.pdf");
|
||||
assert_eq!(all[0].category, "pdfs");
|
||||
assert_eq!(all[0].sender.address, "[email protected]");
|
||||
assert_eq!(all[0].subject, "Quote");
|
||||
assert_eq!(all[2].attachment.filename, "costs.csv");
|
||||
|
||||
let images = super::files(&conn, "images", "").expect("the images");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(images[0].attachment.filename, "plan.png");
|
||||
|
||||
let hers = super::files(&conn, "", "[email protected]").expect("one sender");
|
||||
assert_eq!(hers.len(), 2);
|
||||
assert!(hers.iter().all(|card| card.sender.address == "[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_image_is_not_in_the_library() {
|
||||
let conn = open();
|
||||
with_file(&conn, &image("sig", "[email protected]", "The week", 300, "logo.png", 6_144, true));
|
||||
with_file(&conn, &image("real", "[email protected]", "Plans", 200, "plan.png", 400_000, false));
|
||||
// Inline and an image, but far too big to be a signature: an embedded photograph is a file.
|
||||
with_file(&conn, &image("big", "[email protected]", "Photo", 100, "photo.png", 60_000, true));
|
||||
|
||||
let images = super::files(&conn, "images", "").expect("the images");
|
||||
let names: Vec<&str> = images
|
||||
.iter()
|
||||
.map(|card| card.attachment.filename.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["plan.png", "photo.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_library_leaves_out_trash_and_spam() {
|
||||
let conn = open();
|
||||
let key = with_file(&conn, &pdf("gone", "[email protected]", "Quote", 300));
|
||||
conn.execute("UPDATE threads SET trashed = 1 WHERE thread_key = ?1", [&key])
|
||||
.expect("trashed");
|
||||
|
||||
assert!(super::files(&conn, "", "").expect("the library").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_on_a_merged_thread_opens_the_thread_it_shows_under() {
|
||||
let conn = open();
|
||||
let merged = with_file(&conn, &pdf("one", "[email protected]", "Quote", 300));
|
||||
let source = with_file(
|
||||
&conn,
|
||||
&image("two", "[email protected]", "Re: quote", 200, "plan.png", 400_000, false),
|
||||
);
|
||||
state::write::merge_threads(&conn, std::slice::from_ref(&source), &merged).expect("a merge");
|
||||
|
||||
let cards = super::files(&conn, "", "").expect("the library");
|
||||
assert_eq!(cards.len(), 2);
|
||||
assert!(
|
||||
cards.iter().all(|card| card.thread_key == merged),
|
||||
"a card opens the thread the list shows, not the one the message arrived in"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
// The person behind an address: where their mail delivers, whether it notifies, the private note,
|
||||
// and the threads and the files they have sent.
|
||||
//
|
||||
// Every field of the card is a journalled event through `state::write`, so a decision made here
|
||||
// roams with the state database and survives the mirror being thrown away. The destination is the
|
||||
// one that looks different and is not: it is a sender rule, so it goes through the same
|
||||
// `screener::decide` the Screener's own keys use, and it therefore applies to the sender's existing
|
||||
// threads at once. A move that only affected future mail would read as a move that did not work.
|
||||
//
|
||||
// The card is also the only way to reverse a screening decision, which is why screening somebody
|
||||
// out is one of the four destinations rather than a verb of its own.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::dto::{
|
||||
Attachment, ContactCard, ContactPatch, Destination, Person, Place, SenderRule, ThreadQuery,
|
||||
ThreadSummary, Unsubscribe,
|
||||
};
|
||||
use crate::mirror;
|
||||
use crate::routing;
|
||||
use crate::screener;
|
||||
use crate::state;
|
||||
use crate::sync;
|
||||
|
||||
/// As many as the popover has room for without becoming a list of its own.
|
||||
const RECENT_THREADS: u32 = 5;
|
||||
const RECENT_FILES: usize = 6;
|
||||
const SUGGESTIONS: usize = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reading one person
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The name to print. The state database holds decisions about an address and never a name, because
|
||||
/// a name is something the mail said rather than something the person chose, so this is the
|
||||
/// mirror's answer: the address book's spelling first, then the latest message's.
|
||||
fn name_of(conn: &Connection, address: &str) -> Result<Option<String>, String> {
|
||||
let found: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT name FROM correspondents WHERE lower(address) = ?1 AND name IS NOT NULL",
|
||||
[address],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(name) = found.filter(|name: &String| !name.trim().is_empty()) {
|
||||
return Ok(Some(name));
|
||||
}
|
||||
let latest: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT from_name FROM messages
|
||||
WHERE lower(from_address) = ?1 AND from_name IS NOT NULL AND from_name <> ''
|
||||
ORDER BY date_ms DESC LIMIT 1",
|
||||
[address],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(latest.filter(|name| !name.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// The sender's recent threads, as a list page rather than as a second query.
|
||||
///
|
||||
/// `from:` is the search index's own operator, so this is the query the search bar would run and
|
||||
/// the row that comes back is the row a list draws. The exact address is checked again afterwards
|
||||
/// because the index is tokenised: a phrase match over `ana example org` would also accept
|
||||
/// `[email protected]`, and a card showing somebody else's mail is worse than a card showing less.
|
||||
fn recent(
|
||||
conn: &Connection,
|
||||
account_id: &str,
|
||||
account_color: &str,
|
||||
address: &str,
|
||||
now: i64,
|
||||
) -> Result<Vec<ThreadSummary>, String> {
|
||||
let query = ThreadQuery {
|
||||
account_id: Some(account_id.to_string()),
|
||||
place: Place::Search,
|
||||
label_id: None,
|
||||
query: Some(format!("from:{address}")),
|
||||
// Room to drop the near misses the index let through and still fill the card.
|
||||
limit: RECENT_THREADS * 4,
|
||||
cursor: None,
|
||||
};
|
||||
let page = mirror::read::threads_list(conn, account_id, account_color, &query, now)?;
|
||||
let wanted = address.trim().to_lowercase();
|
||||
Ok(page
|
||||
.threads
|
||||
.into_iter()
|
||||
.filter(|thread| {
|
||||
thread.from.address.to_lowercase() == wanted
|
||||
|| thread
|
||||
.participants
|
||||
.iter()
|
||||
.any(|person| person.address.to_lowercase() == wanted)
|
||||
})
|
||||
.map(|mut thread| {
|
||||
// A group head is a list's answer to "where am I", and a card is not a place: these
|
||||
// threads sit under the card's own Recent threads heading, newest first, with nothing
|
||||
// between them. The empty group is what a view with no grouping already means, and it
|
||||
// is what a list renderer already draws no head for.
|
||||
thread.group = String::new();
|
||||
thread
|
||||
})
|
||||
.take(RECENT_THREADS as usize)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// What this sender has attached, newest first. Inline parts are the body's own images rather than
|
||||
/// files somebody sent, so they are not files.
|
||||
fn files(conn: &Connection, address: &str, limit: usize) -> Result<Vec<Attachment>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT a.id, a.message_id, a.filename, a.mime_type, a.size, a.inline, a.content_id,
|
||||
a.cached_path IS NOT NULL
|
||||
FROM attachments a JOIN messages m ON m.id = a.message_id
|
||||
WHERE lower(m.from_address) = ?1 AND a.inline = 0 AND a.filename <> ''
|
||||
ORDER BY m.date_ms DESC, a.filename ASC
|
||||
LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![address, limit as i64], |row| {
|
||||
Ok(Attachment {
|
||||
id: row.get(0)?,
|
||||
message_id: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
mime_type: row.get(3)?,
|
||||
size: row.get::<_, i64>(4)? as u64,
|
||||
inline: row.get::<_, i64>(5)? != 0,
|
||||
content_id: row.get(6)?,
|
||||
cached: row.get::<_, i64>(7)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `List-Unsubscribe` in the three forms it arrives in, from the latest message that carried one.
|
||||
/// RFC 8058's one-click is the header pair, and without the `POST` half the URL is a page to visit
|
||||
/// rather than a button to press.
|
||||
fn unsubscribe(conn: &Connection, address: &str) -> Result<Option<Unsubscribe>, String> {
|
||||
let held: Option<(String, Option<String>)> = conn
|
||||
.query_row(
|
||||
"SELECT list_unsubscribe, list_unsub_post FROM messages
|
||||
WHERE lower(from_address) = ?1 AND list_unsubscribe IS NOT NULL
|
||||
AND list_unsubscribe <> ''
|
||||
ORDER BY date_ms DESC LIMIT 1",
|
||||
[address],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some((header, post)) = held else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut mailto = None;
|
||||
let mut url = None;
|
||||
for part in header.split(',') {
|
||||
let value = part
|
||||
.trim()
|
||||
.trim_start_matches('<')
|
||||
.trim_end_matches('>')
|
||||
.trim();
|
||||
if value.starts_with("mailto:") {
|
||||
mailto = Some(value.to_string());
|
||||
} else if value.starts_with("http") {
|
||||
url = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
if mailto.is_none() && url.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Unsubscribe {
|
||||
one_click: url.is_some()
|
||||
&& post
|
||||
.map(|value| value.to_ascii_lowercase().contains("one-click"))
|
||||
.unwrap_or(false),
|
||||
mailto,
|
||||
url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Where this sender's mail goes, and when that was decided.
|
||||
///
|
||||
/// Nobody has to have been decided about: a card can be opened on somebody still waiting in the
|
||||
/// Screener, and it says what would happen to them rather than nothing at all. That is the
|
||||
/// suggestion function's answer, which is the same sentence the Screener card would print.
|
||||
fn routed(conn: &Connection, address: &str) -> Result<(Destination, bool, Option<i64>), String> {
|
||||
if let Some(rule) = state::read::rule_for(conn, "", address)? {
|
||||
return Ok((rule.destination, rule.is_domain, Some(rule.decided_at_ms)));
|
||||
}
|
||||
let facts = routing::facts_for_sender(conn, address)?.unwrap_or_default();
|
||||
Ok((routing::suggest(&facts).destination, false, None))
|
||||
}
|
||||
|
||||
/// The whole card for one person, on one account.
|
||||
pub fn card(
|
||||
conn: &Connection,
|
||||
account_id: &str,
|
||||
account_color: &str,
|
||||
address: &str,
|
||||
now: i64,
|
||||
) -> Result<ContactCard, String> {
|
||||
let address = address.trim().to_lowercase();
|
||||
if address.is_empty() {
|
||||
return Err("a contact card needs an address".into());
|
||||
}
|
||||
let held = state::read::contact(conn, &address)?
|
||||
.unwrap_or_else(|| state::read::Contact::unknown(&address));
|
||||
let (destination, domain_rule, screened_at_ms) = routed(conn, &address)?;
|
||||
Ok(ContactCard {
|
||||
person: Person {
|
||||
name: name_of(conn, &address)?,
|
||||
address: address.clone(),
|
||||
},
|
||||
account_id: account_id.to_string(),
|
||||
destination,
|
||||
domain_rule,
|
||||
domain_rule_allowed: domain_rule_allowed(&address),
|
||||
notify: held.notify,
|
||||
screened_at_ms,
|
||||
note: held.note,
|
||||
allow_remote_images: held.allow_remote_images,
|
||||
auto_trash_days: held.auto_trash_days,
|
||||
bundle: held.bundle,
|
||||
recent_threads: recent(conn, account_id, account_color, &address, now)?,
|
||||
files: files(conn, &address, RECENT_FILES)?,
|
||||
unsubscribe: unsubscribe(conn, &address)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether "everyone at this domain" is a group of any kind. Consumer domains are not, which is why
|
||||
/// the toggle is not offered rather than offered and then refused.
|
||||
fn domain_rule_allowed(address: &str) -> bool {
|
||||
match state::write::domain_of(address) {
|
||||
Some(domain) => !state::write::is_consumer_domain(&domain),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Where a sender's mail delivers, by address or by their whole domain.
|
||||
///
|
||||
/// Turning the domain toggle on has to take the address rule away first, because an address rule
|
||||
/// beats a domain rule and the toggle would otherwise read as off the moment the card was asked
|
||||
/// again. Turning it off does the opposite and leaves the domain rule standing: it still decides
|
||||
/// everyone else at that domain, and quietly returning all of them to the Screener is a much larger
|
||||
/// change than the one this card offered.
|
||||
fn set_destination(
|
||||
conn: &Connection,
|
||||
address: &str,
|
||||
destination: Destination,
|
||||
whole_domain: bool,
|
||||
) -> Result<(), String> {
|
||||
if whole_domain {
|
||||
let domain = state::write::domain_of(address)
|
||||
.ok_or_else(|| format!("{address} has no domain to set a rule for"))?;
|
||||
// `set_rule` owns the refusal, so it is asked before anything is undone: a change that is
|
||||
// going to be refused must not leave the sender with no rule at all on the way there.
|
||||
if state::write::is_consumer_domain(&domain) {
|
||||
return state::write::set_rule(conn, &domain, true, destination, None);
|
||||
}
|
||||
state::write::clear_rule(conn, address)?;
|
||||
}
|
||||
screener::decide(conn, address, destination, whole_domain)
|
||||
}
|
||||
|
||||
/// True when the patch touches the contact record rather than the sender rule. Written out rather
|
||||
/// than inferred, because `set_contact` appends the whole record and a patch that only moved
|
||||
/// somebody would otherwise write a second event saying nothing.
|
||||
fn touches_contact(patch: &ContactPatch) -> bool {
|
||||
patch.notify.is_some()
|
||||
|| patch.note.is_some()
|
||||
|| patch.allow_remote_images.is_some()
|
||||
|| patch.auto_trash_days.is_some()
|
||||
|| patch.bundle.is_some()
|
||||
}
|
||||
|
||||
/// Applies the card's patch. Absent is unchanged, in both halves.
|
||||
pub fn update(conn: &Connection, address: &str, patch: &ContactPatch) -> Result<(), String> {
|
||||
let address = address.trim().to_lowercase();
|
||||
if address.is_empty() {
|
||||
return Err("a contact needs an address".into());
|
||||
}
|
||||
if patch.destination.is_some() || patch.domain_rule.is_some() {
|
||||
let (held, held_domain, _) = routed(conn, &address)?;
|
||||
set_destination(
|
||||
conn,
|
||||
&address,
|
||||
patch.destination.unwrap_or(held),
|
||||
patch.domain_rule.unwrap_or(held_domain),
|
||||
)?;
|
||||
}
|
||||
if touches_contact(patch) {
|
||||
state::write::set_contact(conn, &address, patch)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The Contacts place
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Everyone with a rule, searchable.
|
||||
///
|
||||
/// A domain rule decides senders rather than being one, so it contributes the addresses at that
|
||||
/// domain the device has actually heard from rather than a row spelling the domain out: the card is
|
||||
/// about a person and `contact_card` is keyed on an address.
|
||||
///
|
||||
/// Three statements for the whole page rather than a card's worth per row: the rules, the contact
|
||||
/// records and the names, joined in Rust. `ContactCard` is the frozen return type and it carries a
|
||||
/// person's threads, files and unsubscribe header, none of which a row draws, so they are left
|
||||
/// empty here and filled when a card is opened. Answering them per row would be three more queries
|
||||
/// per sender for something nothing reads.
|
||||
pub fn list(conn: &Connection, account_id: &str, query: &str) -> Result<Vec<ContactCard>, String> {
|
||||
let rules = state::read::rules(conn, account_id)?;
|
||||
let mut addresses: Vec<String> = Vec::new();
|
||||
let mut domains: Vec<String> = Vec::new();
|
||||
for rule in &rules {
|
||||
if rule.is_domain {
|
||||
domains.push(rule.subject.clone());
|
||||
} else {
|
||||
addresses.push(rule.subject.clone());
|
||||
}
|
||||
}
|
||||
for domain in &domains {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT DISTINCT lower(address) FROM correspondents
|
||||
WHERE instr(lower(address), '@') > 1
|
||||
AND substr(lower(address), instr(address, '@') + 1) = ?1",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let found = stmt
|
||||
.query_map([domain], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
addresses.extend(found);
|
||||
}
|
||||
addresses.sort();
|
||||
addresses.dedup();
|
||||
|
||||
let records: std::collections::HashMap<String, state::read::Contact> =
|
||||
state::read::contacts(conn)?
|
||||
.into_iter()
|
||||
.map(|record| (record.address.clone(), record))
|
||||
.collect();
|
||||
let names = names(conn)?;
|
||||
let needle = query.trim().to_lowercase();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for address in addresses {
|
||||
let name = names.get(&address).cloned();
|
||||
if !needle.is_empty() {
|
||||
let matched = address.contains(&needle)
|
||||
|| name
|
||||
.as_deref()
|
||||
.map(|name| name.to_lowercase().contains(&needle))
|
||||
.unwrap_or(false);
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let held = records
|
||||
.get(&address)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state::read::Contact::unknown(&address));
|
||||
// Every address here came from a rule, so one decides it. The one that does is found in the
|
||||
// set already in hand rather than asked for again, because a query per row is what a list
|
||||
// must not do.
|
||||
let Some(rule) = deciding(&rules, &address) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ContactCard {
|
||||
person: Person {
|
||||
name,
|
||||
address: address.clone(),
|
||||
},
|
||||
account_id: account_id.to_string(),
|
||||
destination: rule.destination,
|
||||
domain_rule: rule.is_domain,
|
||||
domain_rule_allowed: domain_rule_allowed(&address),
|
||||
notify: held.notify,
|
||||
screened_at_ms: Some(rule.decided_at_ms),
|
||||
note: held.note,
|
||||
allow_remote_images: held.allow_remote_images,
|
||||
auto_trash_days: held.auto_trash_days,
|
||||
bundle: held.bundle,
|
||||
recent_threads: Vec::new(),
|
||||
files: Vec::new(),
|
||||
unsubscribe: None,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The rule deciding an address, out of the set already in hand. An address rule beats a domain
|
||||
/// rule, which is the order `state::read::rule_for` puts them in and the order this repeats.
|
||||
fn deciding<'a>(rules: &'a [SenderRule], address: &str) -> Option<&'a SenderRule> {
|
||||
if let Some(rule) = rules
|
||||
.iter()
|
||||
.find(|rule| !rule.is_domain && rule.subject == address)
|
||||
{
|
||||
return Some(rule);
|
||||
}
|
||||
let domain = state::write::domain_of(address)?;
|
||||
rules
|
||||
.iter()
|
||||
.find(|rule| rule.is_domain && rule.subject == domain)
|
||||
}
|
||||
|
||||
/// Every name the mirror knows, in one statement, so the list above is not a lookup per row.
|
||||
fn names(conn: &Connection) -> Result<std::collections::HashMap<String, String>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT lower(address), name FROM correspondents
|
||||
WHERE name IS NOT NULL AND name <> ''",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
let (address, name) = row.map_err(|e| e.to_string())?;
|
||||
out.insert(address, name);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Autocomplete
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A `LIKE` pattern's own wildcards, so somebody typing a percent sign is typing a percent sign.
|
||||
fn like_escaped(prefix: &str) -> String {
|
||||
prefix
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// Autocomplete, in one local query.
|
||||
///
|
||||
/// Both passes are the same table. `correspondents` is everyone this account has written to or
|
||||
/// heard from, and the sync engine tops it up from the provider's address book with `source` saying
|
||||
/// which is which, so the mirror's own people rank above the provider's and nobody's address is
|
||||
/// ever sent anywhere to be looked up. That last part is what the schema promises and it is the
|
||||
/// reason this is not a network call.
|
||||
///
|
||||
/// Frequency before recency, with a message you sent counting double: writing to somebody is a
|
||||
/// stronger statement that you know them than receiving from them, which is why a mailing list you
|
||||
/// have never answered does not outrank a colleague. Recency breaks the ties.
|
||||
pub fn suggest(conn: &Connection, prefix: &str, limit: usize) -> Result<Vec<Person>, String> {
|
||||
let needle = like_escaped(prefix);
|
||||
if needle.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT address, name FROM correspondents
|
||||
WHERE address <> ''
|
||||
AND (lower(address) LIKE ?1 || '%' ESCAPE '\\'
|
||||
OR lower(name) LIKE ?1 || '%' ESCAPE '\\'
|
||||
OR lower(name) LIKE '% ' || ?1 || '%' ESCAPE '\\')
|
||||
ORDER BY CASE WHEN source = 'mirror' THEN 0 ELSE 1 END ASC,
|
||||
seen_count + 2 * sent_count DESC,
|
||||
last_ms DESC,
|
||||
address ASC
|
||||
LIMIT ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![needle, limit as i64], |row| {
|
||||
Ok(Person {
|
||||
name: row
|
||||
.get::<_, Option<String>>(1)?
|
||||
.filter(|name| !name.trim().is_empty()),
|
||||
address: row.get::<_, String>(0)?.to_lowercase(),
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn db_of(app: &tauri::AppHandle) -> Result<tauri::State<'_, Db>, String> {
|
||||
app.try_state::<Db>()
|
||||
.ok_or_else(|| "the mirror is not open yet".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn contact_card(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
address: String,
|
||||
) -> Result<ContactCard, String> {
|
||||
let db = db_of(&app)?;
|
||||
let color = sync::accounts(db.inner(), Some(&account_id))
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, color)| color)
|
||||
.unwrap_or_else(|| "hue-1".to_string());
|
||||
let now = mirror::write::now_ms();
|
||||
db.with(&account_id, |conn| {
|
||||
card(conn, &account_id, &color, &address, now)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn contact_update(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
address: String,
|
||||
patch: ContactPatch,
|
||||
) -> Result<(), String> {
|
||||
let db = db_of(&app)?;
|
||||
let moved = patch.destination.is_some() || patch.domain_rule.is_some();
|
||||
db.with(&account_id, |conn| update(conn, &address, &patch))?;
|
||||
// A destination is a sender rule, and every routed place is a query over those rules, so the
|
||||
// lists somebody is looking at are already wrong by the time this returns.
|
||||
crate::emit_store_changed(&app, if moved { "threads state" } else { "state" });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn contacts_list(
|
||||
app: tauri::AppHandle,
|
||||
account_id: Option<String>,
|
||||
query: String,
|
||||
) -> Result<Vec<ContactCard>, String> {
|
||||
let db = db_of(&app)?;
|
||||
let mut all = Vec::new();
|
||||
for (id, _) in sync::accounts(db.inner(), account_id.as_deref()) {
|
||||
all.extend(db.with(&id, |conn| list(conn, &id, &query))?);
|
||||
}
|
||||
all.sort_by(|a, b| a.person.address.cmp(&b.person.address));
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn contacts_suggest(
|
||||
app: tauri::AppHandle,
|
||||
account_id: String,
|
||||
prefix: String,
|
||||
) -> Result<Vec<Person>, String> {
|
||||
let db = db_of(&app)?;
|
||||
db.with(&account_id, |conn| suggest(conn, &prefix, SUGGESTIONS))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,556 @@
|
||||
// The contact card, over a pair of in-memory databases.
|
||||
//
|
||||
// Threads and messages go in with SQL rather than through the sync engine, the way the Screener's
|
||||
// tests do it: what is being asserted is what a card says given a mailbox and a set of decisions,
|
||||
// and building the mailbox through a fake provider would test the engine again and hide the answer.
|
||||
//
|
||||
// The one thing that is not raw SQL is `fts::index`. The card's recent threads are a search over
|
||||
// `from:`, which is the same query the search bar runs, so a message that is in the mirror and not
|
||||
// in the index is a message the card cannot see. The engine indexes on headers arriving; these
|
||||
// tests do the same thing by hand.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db;
|
||||
use crate::dto::{ContactPatch, Destination, Place, ThreadQuery};
|
||||
use crate::mirror;
|
||||
use crate::provider::fake::FakeProvider;
|
||||
use crate::state;
|
||||
|
||||
const NOW: i64 = 2_000_000_000_000;
|
||||
|
||||
fn open() -> Connection {
|
||||
db::memory().expect("a pair of in-memory databases")
|
||||
}
|
||||
|
||||
struct Mail<'a> {
|
||||
address: &'a str,
|
||||
name: &'a str,
|
||||
subject: &'a str,
|
||||
list_unsubscribe: Option<&'a str>,
|
||||
attachment: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Mail<'a> {
|
||||
fn from(address: &'a str, name: &'a str) -> Self {
|
||||
Mail {
|
||||
address,
|
||||
name,
|
||||
subject: "Hello",
|
||||
list_unsubscribe: None,
|
||||
attachment: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One thread with one message from one sender, indexed the way the engine indexes it.
|
||||
fn arrive(conn: &Connection, mail: &Mail<'_>, at: i64) -> String {
|
||||
let key = format!("<{}-{at}@example>", mail.address);
|
||||
let tid = format!("t-{}-{at}", mail.address);
|
||||
let mid = format!("m-{tid}");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (provider_thread_id, thread_key, latest_ms, message_count, unseen,
|
||||
subject, snippet, from_name, from_address, in_inbox, has_attachment)
|
||||
VALUES (?1, ?2, ?3, 1, 1, ?4, 'snippet', ?5, ?6, 1, ?7)",
|
||||
rusqlite::params![
|
||||
tid,
|
||||
key,
|
||||
at,
|
||||
mail.subject,
|
||||
mail.name,
|
||||
mail.address,
|
||||
mail.attachment.is_some() as i64
|
||||
],
|
||||
)
|
||||
.expect("a thread");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms, from_name,
|
||||
from_address, subject, snippet, hydrated, labels, list_unsubscribe,
|
||||
has_attachment)
|
||||
VALUES (?1, ?2, ?3, ?3, ?4, ?5, ?6, ?7, 'snippet', 1, '[\"INBOX\"]', ?8, ?9)",
|
||||
rusqlite::params![
|
||||
mid,
|
||||
tid,
|
||||
key,
|
||||
at,
|
||||
mail.name,
|
||||
mail.address,
|
||||
mail.subject,
|
||||
mail.list_unsubscribe,
|
||||
mail.attachment.is_some() as i64,
|
||||
],
|
||||
)
|
||||
.expect("a message");
|
||||
if let Some(filename) = mail.attachment {
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (id, message_id, filename, mime_type, size, inline)
|
||||
VALUES (?1, ?2, ?3, 'application/pdf', 1024, 0)",
|
||||
rusqlite::params![format!("a-{tid}"), mid, filename],
|
||||
)
|
||||
.expect("an attachment");
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO correspondents (address, name, last_ms, seen_count, sent_count, source)
|
||||
VALUES (?1, ?2, ?3, 1, 0, 'mirror')
|
||||
ON CONFLICT(address) DO UPDATE SET
|
||||
last_ms = MAX(correspondents.last_ms, excluded.last_ms),
|
||||
seen_count = correspondents.seen_count + 1",
|
||||
rusqlite::params![mail.address, mail.name, at],
|
||||
)
|
||||
.expect("a correspondent");
|
||||
mirror::fts::index(conn, &mid).expect("the index");
|
||||
key
|
||||
}
|
||||
|
||||
fn card(conn: &Connection, address: &str) -> crate::dto::ContactCard {
|
||||
super::card(conn, "acct", "hue-1", address, NOW).expect("a card")
|
||||
}
|
||||
|
||||
fn keys_in(conn: &Connection, place: Place) -> Vec<String> {
|
||||
let query = ThreadQuery {
|
||||
account_id: None,
|
||||
place,
|
||||
label_id: None,
|
||||
query: None,
|
||||
limit: 50,
|
||||
cursor: None,
|
||||
};
|
||||
mirror::read::threads_list(conn, "acct", "hue-1", &query, NOW)
|
||||
.expect("a page")
|
||||
.threads
|
||||
.into_iter()
|
||||
.map(|thread| thread.key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -- the card ----------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_card_for_somebody_with_no_rule_says_where_they_would_go() {
|
||||
let conn = open();
|
||||
let mut letter = Mail::from("[email protected]", "The Long Read");
|
||||
letter.subject = "The week in review";
|
||||
letter.list_unsubscribe = Some("<https://thelongread.example/u/1>");
|
||||
arrive(&conn, &letter, NOW - 1000);
|
||||
|
||||
let card = card(&conn, "[email protected]");
|
||||
|
||||
assert_eq!(card.person.name.as_deref(), Some("The Long Read"));
|
||||
assert_eq!(card.person.address, "[email protected]");
|
||||
// Nobody has decided about them, so the card says what the Screener would suggest rather than
|
||||
// nothing at all, and says out loud that it is not a decision by carrying no date.
|
||||
assert_eq!(card.destination, Destination::Feed);
|
||||
assert_eq!(card.screened_at_ms, None);
|
||||
assert!(!card.domain_rule);
|
||||
assert!(card.domain_rule_allowed);
|
||||
assert!(card.unsubscribe.is_some());
|
||||
assert_eq!(card.note, None);
|
||||
assert!(!card.notify);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_for_somebody_with_a_rule_carries_the_decision_and_its_date() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Maya"), NOW - 1000);
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::PaperTrail),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a move");
|
||||
|
||||
let card = card(&conn, "[email protected]");
|
||||
|
||||
assert_eq!(card.destination, Destination::PaperTrail);
|
||||
let decided = card.screened_at_ms.expect("a screening date");
|
||||
assert!(decided > 0, "a decision has a moment");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_carries_the_senders_recent_threads_and_files() {
|
||||
let conn = open();
|
||||
let mut with_file = Mail::from("[email protected]", "Sam Okafor");
|
||||
with_file.subject = "Enrolment form";
|
||||
with_file.attachment = Some("Enrolment-form.pdf");
|
||||
arrive(&conn, &with_file, NOW - 3000);
|
||||
let mut later = Mail::from("[email protected]", "Sam Okafor");
|
||||
later.subject = "Piano on Wednesdays";
|
||||
arrive(&conn, &later, NOW - 1000);
|
||||
// Somebody else at the same domain, whose mail is not Sam's.
|
||||
arrive(
|
||||
&conn,
|
||||
&Mail::from("[email protected]", "Sunny Day Music"),
|
||||
NOW - 2000,
|
||||
);
|
||||
|
||||
let card = card(&conn, "[email protected]");
|
||||
|
||||
let subjects: Vec<String> = card
|
||||
.recent_threads
|
||||
.iter()
|
||||
.map(|thread| thread.subject.clone())
|
||||
.collect();
|
||||
assert_eq!(subjects, vec!["Piano on Wednesdays", "Enrolment form"]);
|
||||
// A card is not a place, so its rows sit under the card's own heading and carry no group.
|
||||
assert!(card.recent_threads.iter().all(|thread| thread.group.is_empty()));
|
||||
assert_eq!(card.files.len(), 1);
|
||||
assert_eq!(card.files[0].filename, "Enrolment-form.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_domain_toggle_is_not_offered_on_a_consumer_domain() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Someone"), NOW - 1000);
|
||||
|
||||
assert!(!card(&conn, "[email protected]").domain_rule_allowed);
|
||||
assert!(card(&conn, "[email protected]").domain_rule_allowed);
|
||||
}
|
||||
|
||||
// -- moving somebody -----------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_destination_change_moves_the_mail_that_is_already_here() {
|
||||
// The whole point of the card: a move that only affected future mail reads as a move that did
|
||||
// not work.
|
||||
let conn = open();
|
||||
let mut keys = Vec::new();
|
||||
for at in [NOW - 3000, NOW - 2000, NOW - 1000] {
|
||||
keys.push(arrive(
|
||||
&conn,
|
||||
&Mail::from("[email protected]", "Brand"),
|
||||
at,
|
||||
));
|
||||
}
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::Feed),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a move");
|
||||
|
||||
let feed = keys_in(&conn, Place::Feed);
|
||||
assert_eq!(feed.len(), 3);
|
||||
for key in keys {
|
||||
assert!(feed.contains(&key), "{key} did not move to the Feed");
|
||||
}
|
||||
assert!(keys_in(&conn, Place::Screener).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screening_somebody_out_from_the_card_is_the_block() {
|
||||
let conn = open();
|
||||
let key = arrive(
|
||||
&conn,
|
||||
&Mail::from("[email protected]", "Talent Partners"),
|
||||
NOW - 1000,
|
||||
);
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::ScreenedOut),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a block");
|
||||
|
||||
assert_eq!(keys_in(&conn, Place::ScreenedOut), vec![key]);
|
||||
assert!(keys_in(&conn, Place::Inbox).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_the_domain_toggle_on_decides_everyone_at_the_domain() {
|
||||
let conn = open();
|
||||
let ana = arrive(&conn, &Mail::from("[email protected]", "Ana"), NOW - 2000);
|
||||
let ben = arrive(&conn, &Mail::from("[email protected]", "Ben"), NOW - 1000);
|
||||
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::PaperTrail),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a move");
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
domain_rule: Some(true),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("the toggle");
|
||||
|
||||
let trail = keys_in(&conn, Place::PaperTrail);
|
||||
assert!(trail.contains(&ana) && trail.contains(&ben));
|
||||
// The address rule would beat the domain rule, so turning the toggle on has to take it away or
|
||||
// the card would read as off the moment it was asked again.
|
||||
let card = card(&conn, "[email protected]");
|
||||
assert!(card.domain_rule);
|
||||
assert_eq!(card.destination, Destination::PaperTrail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_domain_rule_on_a_consumer_domain_is_refused_and_changes_nothing() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Someone"), NOW - 1000);
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::Inbox),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a move");
|
||||
|
||||
let refused = super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
domain_rule: Some(true),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
);
|
||||
|
||||
let message = refused.expect_err("everyone at gmail.com is not one sender");
|
||||
assert!(message.contains("gmail.com"), "the message was {message}");
|
||||
// Refused before anything was undone: the address rule they already had is still deciding them.
|
||||
let card = card(&conn, "[email protected]");
|
||||
assert_eq!(card.destination, Destination::Inbox);
|
||||
assert!(!card.domain_rule);
|
||||
}
|
||||
|
||||
// -- the rest of the card ------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_patch_of_one_field_leaves_the_others_alone() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Sam"), NOW - 1000);
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::Inbox),
|
||||
notify: Some(true),
|
||||
note: Some("Cooper's teacher".to_string()),
|
||||
allow_remote_images: Some(true),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("the first patch");
|
||||
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
notify: Some(false),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("one field");
|
||||
|
||||
let card = card(&conn, "[email protected]");
|
||||
assert!(!card.notify);
|
||||
assert_eq!(card.note.as_deref(), Some("Cooper's teacher"));
|
||||
assert!(card.allow_remote_images);
|
||||
assert_eq!(card.destination, Destination::Inbox);
|
||||
assert!(card.bundle, "the default a contact starts with survives a patch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_note_and_the_switches_survive_a_replay_of_the_journal() {
|
||||
// The card's decisions roam, which means they are the log rather than the tables: the same
|
||||
// events landing on an empty database have to produce the same card.
|
||||
let written = open();
|
||||
arrive(&written, &Mail::from("[email protected]", "Sam"), NOW - 1000);
|
||||
super::update(
|
||||
&written,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::PaperTrail),
|
||||
notify: Some(true),
|
||||
note: Some("Cooper's teacher".to_string()),
|
||||
bundle: Some(false),
|
||||
auto_trash_days: Some(Some(30)),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a patch");
|
||||
|
||||
let roamed = open();
|
||||
arrive(&roamed, &Mail::from("[email protected]", "Sam"), NOW - 1000);
|
||||
let log = state::journal::records(&written).expect("the log");
|
||||
let report = state::merge::absorb(&roamed, &log).expect("absorb");
|
||||
assert_eq!(report.applied, log.len());
|
||||
|
||||
let here = card(&written, "[email protected]");
|
||||
let there = card(&roamed, "[email protected]");
|
||||
assert_eq!(there.destination, here.destination);
|
||||
assert_eq!(there.screened_at_ms, here.screened_at_ms);
|
||||
assert_eq!(there.notify, here.notify);
|
||||
assert_eq!(there.note, here.note);
|
||||
assert_eq!(there.bundle, here.bundle);
|
||||
assert_eq!(there.auto_trash_days, here.auto_trash_days);
|
||||
assert_eq!(there.auto_trash_days, Some(30));
|
||||
}
|
||||
|
||||
// -- the Contacts place --------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_list_holds_everyone_with_a_rule_and_nobody_who_is_waiting() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Maya"), NOW - 3000);
|
||||
arrive(&conn, &Mail::from("[email protected]", "Dev Patel"), NOW - 2000);
|
||||
arrive(&conn, &Mail::from("[email protected]", "A Stranger"), NOW - 1000);
|
||||
for address in ["[email protected]", "[email protected]"] {
|
||||
super::update(
|
||||
&conn,
|
||||
address,
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::Inbox),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a decision");
|
||||
}
|
||||
|
||||
let everyone = super::list(&conn, "acct", "").expect("the list");
|
||||
let addresses: Vec<String> = everyone
|
||||
.iter()
|
||||
.map(|card| card.person.address.clone())
|
||||
.collect();
|
||||
assert_eq!(addresses, vec!["[email protected]", "[email protected]"]);
|
||||
assert_eq!(everyone[0].person.name.as_deref(), Some("Dev Patel"));
|
||||
// A row draws none of these, so the list does not answer three more queries per sender for them.
|
||||
assert!(everyone.iter().all(|card| card.recent_threads.is_empty()));
|
||||
assert!(everyone.iter().all(|card| card.files.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_domain_rule_puts_the_people_it_decides_in_the_list_rather_than_the_domain() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Ana"), NOW - 2000);
|
||||
arrive(&conn, &Mail::from("[email protected]", "Ben"), NOW - 1000);
|
||||
super::update(
|
||||
&conn,
|
||||
"[email protected]",
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::Feed),
|
||||
domain_rule: Some(true),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a domain rule");
|
||||
|
||||
let everyone = super::list(&conn, "acct", "").expect("the list");
|
||||
let addresses: Vec<String> = everyone
|
||||
.iter()
|
||||
.map(|card| card.person.address.clone())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
vec!["[email protected]", "[email protected]"]
|
||||
);
|
||||
assert!(everyone.iter().all(|card| card.domain_rule));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_narrows_the_list_by_name_and_by_address() {
|
||||
let conn = open();
|
||||
arrive(&conn, &Mail::from("[email protected]", "Maya Raghunathan"), NOW - 3000);
|
||||
arrive(&conn, &Mail::from("[email protected]", "Dev Patel"), NOW - 2000);
|
||||
for address in ["[email protected]", "[email protected]"] {
|
||||
super::update(
|
||||
&conn,
|
||||
address,
|
||||
&ContactPatch {
|
||||
destination: Some(Destination::Inbox),
|
||||
..ContactPatch::default()
|
||||
},
|
||||
)
|
||||
.expect("a decision");
|
||||
}
|
||||
|
||||
let by_name = super::list(&conn, "acct", "raghu").expect("by name");
|
||||
assert_eq!(by_name.len(), 1);
|
||||
assert_eq!(by_name[0].person.address, "[email protected]");
|
||||
|
||||
let by_domain = super::list(&conn, "acct", "northgate").expect("by address");
|
||||
assert_eq!(by_domain.len(), 1);
|
||||
assert_eq!(by_domain[0].person.address, "[email protected]");
|
||||
|
||||
assert!(super::list(&conn, "acct", "nobody").expect("no match").is_empty());
|
||||
}
|
||||
|
||||
// -- autocomplete ---------------------------------------------------------------------------------
|
||||
|
||||
fn correspondent(conn: &Connection, address: &str, name: &str, source: &str, seen: i64, last: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO correspondents (address, name, last_ms, seen_count, sent_count, source)
|
||||
VALUES (?1, ?2, ?3, ?4, 0, ?5)",
|
||||
rusqlite::params![address, name, last, seen, source],
|
||||
)
|
||||
.expect("a correspondent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_ranks_the_mirror_above_the_provider_and_asks_nobody() {
|
||||
// The provider is here to prove it is not asked. Everyone this account has actually written to
|
||||
// or heard from is already on the device, which is what makes autocomplete local: an address
|
||||
// typed into the compose field is never sent anywhere to be looked up.
|
||||
let provider = FakeProvider::new();
|
||||
provider.set_contacts(vec![crate::dto::Person {
|
||||
name: Some("Anita Desai".to_string()),
|
||||
address: "[email protected]".to_string(),
|
||||
}]);
|
||||
|
||||
let conn = open();
|
||||
correspondent(&conn, "[email protected]", "Ana Ruiz", "mirror", 12, NOW - 5000);
|
||||
correspondent(&conn, "[email protected]", "Andrew Bell", "mirror", 2, NOW - 1000);
|
||||
correspondent(&conn, "[email protected]", "Anita Desai", "people-api", 0, NOW);
|
||||
|
||||
let found = super::suggest(&conn, "an", 8).expect("suggestions");
|
||||
let addresses: Vec<String> = found.iter().map(|person| person.address.clone()).collect();
|
||||
|
||||
assert_eq!(
|
||||
addresses,
|
||||
vec![
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
],
|
||||
"the mirror's own people come before the provider's address book"
|
||||
);
|
||||
assert!(
|
||||
provider.calls().is_empty(),
|
||||
"the mirror answered, so nothing was asked of anybody: {:?}",
|
||||
provider.calls()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocomplete_matches_a_name_as_well_as_an_address_and_nothing_else() {
|
||||
let conn = open();
|
||||
correspondent(&conn, "[email protected]", "Sam Okafor", "mirror", 3, NOW);
|
||||
correspondent(&conn, "[email protected]", "City Power", "mirror", 9, NOW);
|
||||
|
||||
let by_name = super::suggest(&conn, "okaf", 8).expect("by name");
|
||||
assert_eq!(by_name.len(), 1);
|
||||
assert_eq!(by_name[0].address, "[email protected]");
|
||||
|
||||
let by_address = super::suggest(&conn, "billing", 8).expect("by address");
|
||||
assert_eq!(by_address.len(), 1);
|
||||
|
||||
// A wildcard is a character somebody typed, not a pattern.
|
||||
assert!(super::suggest(&conn, "%", 8).expect("a wildcard").is_empty());
|
||||
assert!(super::suggest(&conn, "", 8).expect("nothing typed").is_empty());
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Opening the two databases and handing out the one connection that sees both.
|
||||
//
|
||||
// Each account gets its own pair of files under `accounts/<id>/`: `mirror.db`, which is derived and
|
||||
// disposable, and `state.db`, which is everything the person decided. They are separate files
|
||||
// because they have separate lifetimes: clearing the mirror is deleting a file, exporting the
|
||||
// state is copying one, and the backup uploads segments of the second and never a byte of the
|
||||
// first.
|
||||
//
|
||||
// They are opened on one connection, with the state file attached as `state`, so a view can be one
|
||||
// SQL statement across both rather than two queries joined in Rust. Every join in `mirror::read`
|
||||
// depends on that, and it is the reason this module exists rather than each side opening its own.
|
||||
//
|
||||
// One connection per account, behind a mutex, reached from `#[tauri::command(async)]` handlers as
|
||||
// the calendar does. WAL so a long hydration does not block a read, and a busy timeout because
|
||||
// several accounts in one process on one disk will collide eventually and the alternative is a
|
||||
// spurious "database is locked" in front of somebody's mail.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::library::app_data_dir;
|
||||
use crate::{mirror, state};
|
||||
|
||||
/// Long enough to outlast a hydration batch's write, short enough that a real deadlock is still a
|
||||
/// bug that shows up rather than a hang.
|
||||
const BUSY_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
pub struct Db {
|
||||
root: PathBuf,
|
||||
/// Where a removed account's pair goes when the person chose to keep it: `kept/<id>`, beside
|
||||
/// `accounts/` rather than inside it, so nothing that reads `on_disk` can take it for a live
|
||||
/// mailbox.
|
||||
kept: PathBuf,
|
||||
connections: Mutex<HashMap<String, Connection>>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(app: &tauri::AppHandle) -> Result<Db, String> {
|
||||
let data = app_data_dir(app)?;
|
||||
// The sync log lives beside the account directories, and opening the databases is the
|
||||
// one moment the engine's own code is handed the directory: the engine itself never sees
|
||||
// an app handle, which is what lets it run in a test with nothing behind it.
|
||||
crate::log::init(&data);
|
||||
let root = data.join("accounts");
|
||||
std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
|
||||
Ok(Db {
|
||||
root,
|
||||
kept: data.join("kept"),
|
||||
connections: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn account_dir(&self, account_id: &str) -> PathBuf {
|
||||
self.root.join(account_id)
|
||||
}
|
||||
|
||||
/// Runs a closure against one account's connection. Every read and every write goes through
|
||||
/// here, so there is one place that knows a connection might need opening first.
|
||||
pub fn with<T>(
|
||||
&self,
|
||||
account_id: &str,
|
||||
f: impl FnOnce(&Connection) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let mut open = self.connections.lock().map_err(|e| e.to_string())?;
|
||||
if !open.contains_key(account_id) {
|
||||
let dir = self.account_dir(account_id);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
open.insert(account_id.to_string(), connect(&dir)?);
|
||||
}
|
||||
let conn = open.get(account_id).expect("just inserted");
|
||||
f(conn)
|
||||
}
|
||||
|
||||
/// Closes the connection so the files can be moved or deleted. Opening again is automatic.
|
||||
pub fn close(&self, account_id: &str) -> Result<(), String> {
|
||||
let mut open = self.connections.lock().map_err(|e| e.to_string())?;
|
||||
open.remove(account_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The accounts that have a database on disk, which is not always the same list as the accounts
|
||||
/// that have a token: a removal takes one away before the other.
|
||||
pub fn on_disk(&self) -> Vec<String> {
|
||||
std::fs::read_dir(&self.root)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().is_dir())
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Moves an account's pair out of the live set without deleting it. `on_disk` stops listing
|
||||
/// it, so no list, badge or sync pass sees a mailbox nobody is signed in to, and `restore`
|
||||
/// brings it back the day the account is added again. An older kept copy of the same account
|
||||
/// is replaced: the pair just removed is the one the person was looking at. Call `close`
|
||||
/// first, because a connection open on the old path would keep writing there.
|
||||
pub fn set_aside(&self, account_id: &str) -> Result<(), String> {
|
||||
let live = self.account_dir(account_id);
|
||||
if !live.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&self.kept).map_err(|e| e.to_string())?;
|
||||
let kept = self.kept.join(account_id);
|
||||
if kept.exists() {
|
||||
std::fs::remove_dir_all(&kept).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::rename(&live, &kept).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The other half: an account added again gets the pair that was set aside when it was
|
||||
/// removed, decisions and all. Nothing happens when a live pair already exists, because a pair
|
||||
/// that has been written to since is the newer truth and a rename over it would lose it.
|
||||
pub fn restore(&self, account_id: &str) -> Result<(), String> {
|
||||
let kept = self.kept.join(account_id);
|
||||
let live = self.account_dir(account_id);
|
||||
if !kept.exists() || live.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::rename(&kept, &live).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn connect(dir: &Path) -> Result<Connection, String> {
|
||||
let conn = Connection::open(dir.join("mirror.db")).map_err(|e| e.to_string())?;
|
||||
prepare(&conn, &dir.join("state.db").to_string_lossy())?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn prepare(conn: &Connection, state_path: &str) -> Result<(), String> {
|
||||
// Not a prepared statement: ATTACH takes a literal, and the path is ours rather than anyone's
|
||||
// input. The escaping is still done, because a home directory with an apostrophe in it exists.
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = OFF;
|
||||
ATTACH DATABASE '{}' AS state;",
|
||||
state_path.replace('\'', "''")
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.busy_timeout(std::time::Duration::from_millis(BUSY_TIMEOUT_MS as u64))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
mirror::schema::migrate(conn)?;
|
||||
state::schema::migrate(conn)
|
||||
}
|
||||
|
||||
/// A pair of in-memory databases with both schemas, for tests. Attaching `:memory:` gives a second,
|
||||
/// separate in-memory database, so the boundary the real thing has is the boundary a test has.
|
||||
#[cfg(test)]
|
||||
pub fn memory() -> Result<Connection, String> {
|
||||
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
|
||||
conn.execute_batch("ATTACH DATABASE ':memory:' AS state;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
mirror::schema::migrate(&conn)?;
|
||||
state::schema::migrate(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn on_disk(data: &Path) -> Db {
|
||||
Db {
|
||||
root: data.join("accounts"),
|
||||
kept: data.join("kept"),
|
||||
connections: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_pair_leaves_the_live_set_and_comes_back_on_restore() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let db = on_disk(tmp.path());
|
||||
let dir = db.account_dir("a1");
|
||||
std::fs::create_dir_all(&dir).expect("live dir");
|
||||
std::fs::write(dir.join("state.db"), b"decisions").expect("a file to keep");
|
||||
|
||||
db.set_aside("a1").expect("set aside");
|
||||
assert!(db.on_disk().is_empty(), "a kept account is not a live one");
|
||||
assert!(!dir.exists());
|
||||
|
||||
db.restore("a1").expect("restore");
|
||||
assert_eq!(db.on_disk(), vec!["a1".to_string()]);
|
||||
assert_eq!(std::fs::read(dir.join("state.db")).expect("the file"), b"decisions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_again_replaces_the_older_copy_and_restore_never_overwrites_a_live_pair() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let db = on_disk(tmp.path());
|
||||
let dir = db.account_dir("a1");
|
||||
|
||||
std::fs::create_dir_all(&dir).expect("live dir");
|
||||
std::fs::write(dir.join("state.db"), b"first").expect("write");
|
||||
db.set_aside("a1").expect("first keep");
|
||||
|
||||
std::fs::create_dir_all(&dir).expect("live dir again");
|
||||
std::fs::write(dir.join("state.db"), b"second").expect("write");
|
||||
db.set_aside("a1").expect("second keep");
|
||||
assert_eq!(std::fs::read(tmp.path().join("kept/a1/state.db")).expect("kept"), b"second");
|
||||
|
||||
std::fs::create_dir_all(&dir).expect("a live pair in the way");
|
||||
std::fs::write(dir.join("state.db"), b"live").expect("write");
|
||||
db.restore("a1").expect("restore is a no-op here");
|
||||
assert_eq!(std::fs::read(dir.join("state.db")).expect("live"), b"live");
|
||||
assert!(tmp.path().join("kept/a1").exists(), "the kept copy is not thrown away either");
|
||||
|
||||
// Nothing to do when nothing was kept.
|
||||
db.set_aside("nobody").expect("no live dir is fine");
|
||||
db.restore("nobody").expect("no kept dir is fine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_pair_has_both_schemas_and_one_connection_sees_both() {
|
||||
let conn = memory().expect("open");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO threads (provider_thread_id, thread_key, latest_ms) VALUES ('t1', 'k1', 10)",
|
||||
[],
|
||||
)
|
||||
.expect("mirror write");
|
||||
conn.execute(
|
||||
"INSERT INTO state.piles (thread_key, pile) VALUES ('k1', 'reply-later')",
|
||||
[],
|
||||
)
|
||||
.expect("state write");
|
||||
|
||||
let pile: String = conn
|
||||
.query_row(
|
||||
"SELECT p.pile FROM threads t JOIN state.piles p ON p.thread_key = t.thread_key",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("the join across both databases");
|
||||
assert_eq!(pile, "reply-later");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrating_twice_changes_nothing() {
|
||||
let conn = memory().expect("open");
|
||||
mirror::schema::migrate(&conn).expect("mirror again");
|
||||
state::schema::migrate(&conn).expect("state again");
|
||||
assert_eq!(mirror::schema::version(&conn).expect("version"), mirror::schema::VERSION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
// The decisions that are about a thread rather than about a sender: the note, the name, the merge,
|
||||
// the ignore and the notify switch.
|
||||
//
|
||||
// Every one of them is a thin command over a function in `state::write` that already exists and
|
||||
// already journals, which is what makes them roam. What is here and not there is the part that
|
||||
// needs an app: which account's state file the decision belongs in, what the toast says, and how to
|
||||
// put it back.
|
||||
//
|
||||
// The two lookups at the top are shared with `piles`, `snooze` and `clips`. They are here because a
|
||||
// pile and a snooze are decisions about a thread too, and every one of the five command modules
|
||||
// asks the same two questions before it can write anything.
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::dto::{Note, Undo};
|
||||
use crate::state::{self, journal::Payload, read::ThreadFlags};
|
||||
use crate::sync;
|
||||
use crate::undo::Stack;
|
||||
|
||||
static UNDO: Stack<UndoDecision> = Stack::new("decision");
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Which account a decision belongs in
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn db_of(app: &tauri::AppHandle) -> Result<tauri::State<'_, Db>, String> {
|
||||
app.try_state::<Db>()
|
||||
.ok_or_else(|| "the mirror is not open yet".to_string())
|
||||
}
|
||||
|
||||
/// Which of these keys one account holds. A merged thread answers to its own key, so a source key
|
||||
/// finds its account through the merge as well as through the mirror.
|
||||
fn held_keys(conn: &Connection, keys: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut out = Vec::new();
|
||||
for key in keys {
|
||||
let held: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM threads
|
||||
WHERE thread_key = ?1
|
||||
OR thread_key IN (SELECT thread_key FROM state.merges WHERE merged_key = ?1)",
|
||||
[key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if held > 0 {
|
||||
out.push(key.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The keys grouped by the account that holds them, so a decision is written into the state file
|
||||
/// beside the mail it is about and into no other. A key claimed by one account is not offered to
|
||||
/// the next, because a thread lives in one mailbox and a second row for it would roam as a second
|
||||
/// decision.
|
||||
pub(crate) fn holders(db: &Db, keys: &[String]) -> Result<Vec<(String, Vec<String>)>, String> {
|
||||
let mut out: Vec<(String, Vec<String>)> = Vec::new();
|
||||
let mut claimed: Vec<String> = Vec::new();
|
||||
for (account_id, _) in sync::accounts(db, None) {
|
||||
let wanted: Vec<String> = keys
|
||||
.iter()
|
||||
.filter(|key| !claimed.contains(key))
|
||||
.cloned()
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mine = db.with(&account_id, |conn| held_keys(conn, &wanted))?;
|
||||
if mine.is_empty() {
|
||||
continue;
|
||||
}
|
||||
claimed.extend(mine.iter().cloned());
|
||||
out.push((account_id, mine));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The account whose state file holds a row, for the commands the frontend names by id alone.
|
||||
/// `attachments` asks the mirror the same question the same way.
|
||||
pub(crate) fn account_holding(db: &Db, sql: &str, id: &str) -> Result<String, String> {
|
||||
for (account_id, _) in sync::accounts(db, None) {
|
||||
let held = db.with(&account_id, |conn| {
|
||||
conn.query_row(sql, [id], |row| row.get::<_, i64>(0))
|
||||
.map_err(|e| e.to_string())
|
||||
})?;
|
||||
if held > 0 {
|
||||
return Ok(account_id);
|
||||
}
|
||||
}
|
||||
Err("that is not on this device".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn plural(count: usize, one: &str) -> String {
|
||||
if count == 1 {
|
||||
one.to_string()
|
||||
} else {
|
||||
format!("{one} {count} threads")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The message that is latest in the thread right now, which is where the pane puts a new note.
|
||||
fn latest_message(conn: &Connection, thread_key: &str) -> Result<Option<String>, String> {
|
||||
conn.query_row(
|
||||
"SELECT m.id FROM messages m
|
||||
WHERE m.provider_thread_id IN (
|
||||
SELECT provider_thread_id FROM threads
|
||||
WHERE thread_key = ?1
|
||||
OR thread_key IN (SELECT thread_key FROM state.merges WHERE merged_key = ?1))
|
||||
ORDER BY m.date_ms DESC, m.id DESC LIMIT 1",
|
||||
[thread_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// One note by its id, deleted or not, which is what a reversal needs and what `state::read` does
|
||||
/// not answer: a note is read by its thread everywhere else.
|
||||
fn note_row(conn: &Connection, id: &str) -> Result<Option<(Note, bool)>, String> {
|
||||
conn.query_row(
|
||||
"SELECT id, thread_key, body, created_at, after_message_id, deleted FROM state.notes
|
||||
WHERE id = ?1",
|
||||
[id],
|
||||
|row| {
|
||||
Ok((
|
||||
Note {
|
||||
id: row.get(0)?,
|
||||
thread_key: row.get(1)?,
|
||||
body: row.get(2)?,
|
||||
created_at_ms: row.get(3)?,
|
||||
after_message_id: row.get(4)?,
|
||||
},
|
||||
row.get::<_, i64>(5)? != 0,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn add_note(conn: &Connection, thread_key: &str, body: &str) -> Result<Note, String> {
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return Err("a note needs something in it".into());
|
||||
}
|
||||
let after = latest_message(conn, thread_key)?;
|
||||
let id = state::write::add_note(conn, thread_key, body, after.as_deref())?;
|
||||
note_row(conn, &id)?
|
||||
.map(|(note, _)| note)
|
||||
.ok_or_else(|| "that note did not land".to_string())
|
||||
}
|
||||
|
||||
/// Puts a deleted note back. There is no un-delete in `state::write` because nothing but a reversal
|
||||
/// wants one, and deletion is a flag rather than a missing row precisely so this is possible.
|
||||
fn restore_note(conn: &Connection, note: &Note) -> Result<(), String> {
|
||||
state::journal::append(
|
||||
conn,
|
||||
¬e.id,
|
||||
&Payload::Note {
|
||||
thread_key: note.thread_key.clone(),
|
||||
body: note.body.clone(),
|
||||
after_message_id: note.after_message_id.clone(),
|
||||
created_at: note.created_at_ms,
|
||||
deleted: false,
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Renames and merges
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn subject_of(conn: &Connection, thread_key: &str) -> Result<Option<String>, String> {
|
||||
conn.query_row(
|
||||
"SELECT subject FROM threads WHERE thread_key = ?1 ORDER BY latest_ms DESC LIMIT 1",
|
||||
[thread_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A merge with no name typed takes the longest of the subjects it swallowed, which is section 10's
|
||||
/// rule and is usually the one that says the most about the conversation.
|
||||
fn longest_subject(conn: &Connection, keys: &[String]) -> Result<Option<String>, String> {
|
||||
let mut best: Option<String> = None;
|
||||
for key in keys {
|
||||
let Some(subject) = subject_of(conn, key)? else {
|
||||
continue;
|
||||
};
|
||||
let longer = best
|
||||
.as_ref()
|
||||
.map(|held| subject.chars().count() > held.chars().count())
|
||||
.unwrap_or(true);
|
||||
if longer {
|
||||
best = Some(subject);
|
||||
}
|
||||
}
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// The threads pointing directly at this one. `state::read::merge_sources` walks the whole chain,
|
||||
/// which is the right answer for a view and the wrong one for a reversal: putting a chain back
|
||||
/// means putting each of its links back, not flattening it.
|
||||
fn direct_sources(conn: &Connection, merged_key: &str) -> Result<Vec<String>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT thread_key FROM state.merges WHERE merged_key = ?1 ORDER BY thread_key")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([merged_key], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Takes one source back out of a merge. `state::write::unmerge` breaks the whole thread apart,
|
||||
/// which is what the banner offers; taking back a merge of three into a thread that already held
|
||||
/// two is not that.
|
||||
fn unmerge_one(conn: &Connection, source: &str) -> Result<(), String> {
|
||||
state::journal::append(conn, source, &Payload::Merge { merged_key: None }).map(|_| ())
|
||||
}
|
||||
|
||||
fn set_name(conn: &Connection, thread_key: &str, name: Option<&str>) -> Result<(), String> {
|
||||
match name.map(str::trim).filter(|name| !name.is_empty()) {
|
||||
Some(name) => state::write::set_rename(conn, thread_key, name),
|
||||
None => state::write::clear_rename(conn, thread_key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes several threads show as one. The first key is the thread the rest join, which is the key
|
||||
/// the banner's Unmerge undoes from and the key every list shows the merged thread under.
|
||||
pub fn merge(
|
||||
conn: &Connection,
|
||||
keys: &[String],
|
||||
name: Option<&str>,
|
||||
) -> Result<(String, Vec<String>, Option<String>), String> {
|
||||
let merged_key = keys.first().ok_or("a merge needs at least two threads")?.clone();
|
||||
let sources: Vec<String> = keys
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|key| **key != merged_key)
|
||||
.cloned()
|
||||
.collect();
|
||||
if sources.is_empty() {
|
||||
return Err("a merge needs at least two threads".into());
|
||||
}
|
||||
let rename_before = state::read::rename_of(conn, &merged_key)?;
|
||||
state::write::merge_threads(conn, &sources, &merged_key)?;
|
||||
|
||||
let typed = name.map(str::trim).filter(|name| !name.is_empty());
|
||||
let chosen = match typed {
|
||||
Some(name) => Some(name.to_string()),
|
||||
None => longest_subject(conn, keys)?,
|
||||
};
|
||||
// A name that says exactly what the subject already says would make the pane print
|
||||
// "renamed, was ..." about nothing.
|
||||
if chosen.is_some() && chosen != subject_of(conn, &merged_key)? {
|
||||
set_name(conn, &merged_key, chosen.as_deref())?;
|
||||
}
|
||||
Ok((merged_key, sources, rename_before))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Undo
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// What each of these reverses is a row in the state database rather than a set of provider labels,
|
||||
/// so they share a stack with each other and not with `mirror`.
|
||||
///
|
||||
/// Every variant but the last names one account, because a note, a name and a merge are all about a
|
||||
/// single thread. Ignore and notify take a selection, and a selection can span two mailboxes.
|
||||
pub enum What {
|
||||
Note {
|
||||
account_id: String,
|
||||
note: Note,
|
||||
},
|
||||
Rename {
|
||||
account_id: String,
|
||||
before: Vec<(String, Option<String>)>,
|
||||
},
|
||||
Merge {
|
||||
account_id: String,
|
||||
merged_key: String,
|
||||
sources: Vec<String>,
|
||||
rename_before: Option<String>,
|
||||
},
|
||||
Unmerge {
|
||||
account_id: String,
|
||||
merged_key: String,
|
||||
sources: Vec<String>,
|
||||
},
|
||||
Flags {
|
||||
before: Vec<(String, Vec<(String, ThreadFlags)>)>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct UndoDecision {
|
||||
pub label: String,
|
||||
pub what: What,
|
||||
}
|
||||
|
||||
pub fn owns(token: &str) -> bool {
|
||||
UNDO.owns(token)
|
||||
}
|
||||
|
||||
pub fn undo_apply(app: &tauri::AppHandle, token: &str) -> Result<(), String> {
|
||||
let entry = UNDO
|
||||
.take(token)
|
||||
.ok_or("that change can no longer be taken back")?;
|
||||
let db = db_of(app)?;
|
||||
match &entry.what {
|
||||
What::Note { account_id, note } => db.with(account_id, |conn| restore_note(conn, note))?,
|
||||
What::Rename { account_id, before } => db.with(account_id, |conn| {
|
||||
for (key, name) in before {
|
||||
set_name(conn, key, name.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
What::Merge {
|
||||
account_id,
|
||||
merged_key,
|
||||
sources,
|
||||
rename_before,
|
||||
} => db.with(account_id, |conn| {
|
||||
for source in sources {
|
||||
unmerge_one(conn, source)?;
|
||||
}
|
||||
set_name(conn, merged_key, rename_before.as_deref())
|
||||
})?,
|
||||
What::Unmerge {
|
||||
account_id,
|
||||
merged_key,
|
||||
sources,
|
||||
} => db.with(account_id, |conn| {
|
||||
state::write::merge_threads(conn, sources, merged_key)
|
||||
})?,
|
||||
What::Flags { before } => {
|
||||
for (account_id, held) in before {
|
||||
db.with(account_id, |conn| {
|
||||
for (key, flags) in held {
|
||||
state::write::set_thread_flags(
|
||||
conn,
|
||||
key,
|
||||
Some(flags.ignored),
|
||||
Some(flags.notify),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::emit_store_changed(app, "threads state");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handed_back(label: String, what: What) -> Undo {
|
||||
let token = UNDO.push(
|
||||
UndoDecision {
|
||||
label: label.clone(),
|
||||
what,
|
||||
},
|
||||
&label,
|
||||
);
|
||||
Undo {
|
||||
token,
|
||||
label,
|
||||
undo_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn note_add(app: tauri::AppHandle, thread_key: String, body: String) -> Result<Note, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = holders(db.inner(), std::slice::from_ref(&thread_key))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(account_id, _)| account_id)
|
||||
.ok_or("that thread is not on this device")?;
|
||||
let note = db.with(&account_id, |conn| add_note(conn, &thread_key, &body))?;
|
||||
crate::emit_store_changed(&app, &format!("threads thread:{thread_key} state"));
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn note_update(app: tauri::AppHandle, id: String, body: String) -> Result<(), String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = account_holding(
|
||||
db.inner(),
|
||||
"SELECT COUNT(*) FROM state.notes WHERE id = ?1",
|
||||
&id,
|
||||
)
|
||||
.map_err(|_| "that note is not here".to_string())?;
|
||||
let thread_key = db.with(&account_id, |conn| {
|
||||
state::write::edit_note(conn, &id, &body)?;
|
||||
Ok(note_row(conn, &id)?.map(|(note, _)| note.thread_key))
|
||||
})?;
|
||||
let scope = thread_key
|
||||
.map(|key| format!("threads thread:{key} state"))
|
||||
.unwrap_or_else(|| "threads state".to_string());
|
||||
crate::emit_store_changed(&app, &scope);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn note_delete(app: tauri::AppHandle, id: String) -> Result<Undo, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = account_holding(
|
||||
db.inner(),
|
||||
"SELECT COUNT(*) FROM state.notes WHERE id = ?1",
|
||||
&id,
|
||||
)
|
||||
.map_err(|_| "that note is not here".to_string())?;
|
||||
let note = db.with(&account_id, |conn| {
|
||||
let held = note_row(conn, &id)?
|
||||
.filter(|(_, deleted)| !deleted)
|
||||
.map(|(note, _)| note)
|
||||
.ok_or_else(|| "that note is not here".to_string())?;
|
||||
state::write::delete_note(conn, &id)?;
|
||||
Ok(held)
|
||||
})?;
|
||||
let scope = format!("threads thread:{} state", note.thread_key);
|
||||
crate::emit_store_changed(&app, &scope);
|
||||
Ok(handed_back(
|
||||
"Note deleted".to_string(),
|
||||
What::Note { account_id, note },
|
||||
))
|
||||
}
|
||||
|
||||
/// A null name is back to the real subject, which is what the pane's "renamed · was …" is offering.
|
||||
#[tauri::command(async)]
|
||||
pub fn thread_rename(
|
||||
app: tauri::AppHandle,
|
||||
key: String,
|
||||
name: Option<String>,
|
||||
) -> Result<Undo, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = holders(db.inner(), std::slice::from_ref(&key))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(account_id, _)| account_id)
|
||||
.ok_or("that thread is not on this device")?;
|
||||
let before = db.with(&account_id, |conn| {
|
||||
let before = state::read::rename_of(conn, &key)?;
|
||||
set_name(conn, &key, name.as_deref())?;
|
||||
Ok(before)
|
||||
})?;
|
||||
let label = match name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(_) => "Renamed".to_string(),
|
||||
None => "Name put back".to_string(),
|
||||
};
|
||||
crate::emit_store_changed(&app, &format!("threads thread:{key} state"));
|
||||
Ok(handed_back(
|
||||
label,
|
||||
What::Rename {
|
||||
account_id,
|
||||
before: vec![(key, before)],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn thread_merge(
|
||||
app: tauri::AppHandle,
|
||||
keys: Vec<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<Undo, String> {
|
||||
let db = db_of(&app)?;
|
||||
// One account, and it is the first key's: a merge across two mailboxes would be a thread whose
|
||||
// messages no single provider holds, and neither list could show it.
|
||||
let first = keys
|
||||
.first()
|
||||
.ok_or("a merge needs at least two threads")?
|
||||
.clone();
|
||||
let (account_id, held) = holders(db.inner(), &keys)?
|
||||
.into_iter()
|
||||
.find(|(_, held)| held.contains(&first))
|
||||
.ok_or("those threads are not on this device")?;
|
||||
let ordered: Vec<String> = keys.iter().filter(|key| held.contains(key)).cloned().collect();
|
||||
let (merged_key, sources, rename_before) =
|
||||
db.with(&account_id, |conn| merge(conn, &ordered, name.as_deref()))?;
|
||||
|
||||
let label = format!("Merged {} threads", sources.len() + 1);
|
||||
crate::emit_store_changed(&app, &format!("threads thread:{merged_key} state"));
|
||||
Ok(handed_back(
|
||||
label,
|
||||
What::Merge {
|
||||
account_id,
|
||||
merged_key,
|
||||
sources,
|
||||
rename_before,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn thread_unmerge(app: tauri::AppHandle, key: String) -> Result<Undo, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = holders(db.inner(), std::slice::from_ref(&key))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(account_id, _)| account_id)
|
||||
.ok_or("that thread is not on this device")?;
|
||||
let sources = db.with(&account_id, |conn| {
|
||||
let sources = direct_sources(conn, &key)?;
|
||||
if sources.is_empty() {
|
||||
return Err("that thread is not a merge".to_string());
|
||||
}
|
||||
state::write::unmerge(conn, &key)?;
|
||||
Ok(sources)
|
||||
})?;
|
||||
crate::emit_store_changed(&app, "threads state");
|
||||
Ok(handed_back(
|
||||
"Unmerged".to_string(),
|
||||
What::Unmerge {
|
||||
account_id,
|
||||
merged_key: key,
|
||||
sources,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// An ignored thread still receives its messages and still appends them. What changes is that it
|
||||
/// stops coming back to New, which `mirror::read` decides when it decides the group.
|
||||
#[tauri::command(async)]
|
||||
pub fn thread_ignore(app: tauri::AppHandle, keys: Vec<String>, on: bool) -> Result<Undo, String> {
|
||||
flags(&app, &keys, Some(on), None, |count| {
|
||||
plural(count, if on { "Ignoring" } else { "No longer ignoring" })
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn thread_notify(app: tauri::AppHandle, keys: Vec<String>, on: bool) -> Result<Undo, String> {
|
||||
flags(&app, &keys, None, Some(on), |count| {
|
||||
plural(
|
||||
count,
|
||||
if on {
|
||||
"Notifications on"
|
||||
} else {
|
||||
"Notifications off"
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn flags(
|
||||
app: &tauri::AppHandle,
|
||||
keys: &[String],
|
||||
ignored: Option<bool>,
|
||||
notify: Option<bool>,
|
||||
label: impl Fn(usize) -> String,
|
||||
) -> Result<Undo, String> {
|
||||
let db = db_of(app)?;
|
||||
let mut before: Vec<(String, Vec<(String, ThreadFlags)>)> = Vec::new();
|
||||
let mut touched = 0usize;
|
||||
for (account_id, held) in holders(db.inner(), keys)? {
|
||||
let held = db.with(&account_id, |conn| {
|
||||
let mut before = Vec::new();
|
||||
for key in &held {
|
||||
before.push((key.clone(), state::read::flags_of(conn, key)?));
|
||||
state::write::set_thread_flags(conn, key, ignored, notify)?;
|
||||
}
|
||||
Ok(before)
|
||||
})?;
|
||||
touched += held.len();
|
||||
before.push((account_id, held));
|
||||
}
|
||||
let label = label(touched);
|
||||
crate::emit_store_changed(app, "threads state");
|
||||
Ok(handed_back(label, What::Flags { before }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,270 @@
|
||||
// Notes, names, merges and the two switches, over a pair of in-memory databases.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db;
|
||||
use crate::dto::{Destination, Place, ThreadQuery, ThreadSummary};
|
||||
use crate::mirror;
|
||||
use crate::state;
|
||||
|
||||
const NOW: i64 = 2_000_000_000_000;
|
||||
|
||||
fn open() -> Connection {
|
||||
db::memory().expect("a pair of in-memory databases")
|
||||
}
|
||||
|
||||
fn arrive(conn: &Connection, address: &str, subject: &str, at: i64) -> String {
|
||||
let key = format!("<{address}-{at}@example>");
|
||||
let tid = format!("t-{address}-{at}");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (provider_thread_id, thread_key, latest_ms, message_count, unseen,
|
||||
subject, snippet, from_name, from_address, in_inbox)
|
||||
VALUES (?1, ?2, ?3, 1, 1, ?4, 'snippet', 'Someone', ?5, 1)",
|
||||
rusqlite::params![tid, key, at, subject, address],
|
||||
)
|
||||
.expect("a thread");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
||||
from_address, subject, snippet, hydrated, labels)
|
||||
VALUES (?1, ?2, ?3, ?3, ?4, ?5, ?6, 'snippet', 1, '[\"INBOX\"]')",
|
||||
rusqlite::params![format!("m-{tid}"), tid, key, at, address, subject],
|
||||
)
|
||||
.expect("a message");
|
||||
state::write::set_rule(conn, address, false, Destination::Inbox, None).expect("a rule");
|
||||
key
|
||||
}
|
||||
|
||||
/// A new message in an existing thread, appended the way a sync would append it.
|
||||
fn append(conn: &Connection, key: &str, at: i64) {
|
||||
let tid: String = conn
|
||||
.query_row(
|
||||
"SELECT provider_thread_id FROM threads WHERE thread_key = ?1",
|
||||
[key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("the thread");
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
||||
from_address, subject, snippet, hydrated, labels)
|
||||
VALUES (?1, ?2, ?3, ?1, ?4, '[email protected]', 'Hello', 'snippet', 1, '[\"INBOX\"]')",
|
||||
rusqlite::params![format!("m-{tid}-{at}"), tid, key, at],
|
||||
)
|
||||
.expect("another message");
|
||||
conn.execute(
|
||||
"UPDATE threads SET latest_ms = ?2, message_count = message_count + 1, unseen = 1
|
||||
WHERE thread_key = ?1",
|
||||
rusqlite::params![key, at],
|
||||
)
|
||||
.expect("the thread moved on");
|
||||
}
|
||||
|
||||
fn page(conn: &Connection, place: Place) -> Vec<ThreadSummary> {
|
||||
let query = ThreadQuery {
|
||||
account_id: None,
|
||||
place,
|
||||
label_id: None,
|
||||
query: None,
|
||||
limit: 50,
|
||||
cursor: None,
|
||||
};
|
||||
mirror::read::threads_list(conn, "acct", "hue-1", &query, NOW)
|
||||
.expect("a page")
|
||||
.threads
|
||||
}
|
||||
|
||||
fn keys_in(conn: &Connection, place: Place) -> Vec<String> {
|
||||
page(conn, place).into_iter().map(|t| t.key).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_note_round_trips_and_a_second_one_is_a_second_note() {
|
||||
let conn = open();
|
||||
let key = arrive(&conn, "[email protected]", "The kitchen", 100);
|
||||
|
||||
let first = super::add_note(&conn, &key, "Chase this on Friday").expect("a note");
|
||||
assert_eq!(first.body, "Chase this on Friday");
|
||||
assert_eq!(first.thread_key, key);
|
||||
assert_eq!(
|
||||
first.after_message_id.as_deref(),
|
||||
Some("[email protected]"),
|
||||
"a note sits after the message that was latest when it was written"
|
||||
);
|
||||
|
||||
let second = super::add_note(&conn, &key, "Rang, no answer").expect("a second note");
|
||||
assert_ne!(second.id, first.id);
|
||||
let held = state::read::notes_on(&conn, &key).expect("the notes");
|
||||
assert_eq!(held.len(), 2);
|
||||
assert_eq!(held[0].body, "Chase this on Friday");
|
||||
|
||||
// The list carries the latest one as its single line under the row.
|
||||
assert_eq!(page(&conn, Place::Inbox)[0].note.as_deref(), Some("Rang, no answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_note_is_reversible() {
|
||||
let conn = open();
|
||||
let key = arrive(&conn, "[email protected]", "The kitchen", 100);
|
||||
let note = super::add_note(&conn, &key, "Chase this on Friday").expect("a note");
|
||||
|
||||
state::write::delete_note(&conn, ¬e.id).expect("deleted");
|
||||
assert!(state::read::notes_on(&conn, &key).expect("the notes").is_empty());
|
||||
|
||||
super::restore_note(&conn, ¬e).expect("put back");
|
||||
let held = state::read::notes_on(&conn, &key).expect("the notes");
|
||||
assert_eq!(held.len(), 1);
|
||||
assert_eq!(held[0].id, note.id);
|
||||
assert_eq!(held[0].body, "Chase this on Friday");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Renames
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_rename_shows_in_a_list_page_with_the_real_subject_beside_it() {
|
||||
let conn = open();
|
||||
let key = arrive(&conn, "[email protected]", "Re: Fwd: quote v4 FINAL", 100);
|
||||
|
||||
super::set_name(&conn, &key, Some("Kitchen quote")).expect("renamed");
|
||||
|
||||
let row = &page(&conn, Place::Inbox)[0];
|
||||
assert_eq!(row.subject, "Kitchen quote");
|
||||
assert_eq!(row.original_subject.as_deref(), Some("Re: Fwd: quote v4 FINAL"));
|
||||
|
||||
super::set_name(&conn, &key, None).expect("put back");
|
||||
let row = &page(&conn, Place::Inbox)[0];
|
||||
assert_eq!(row.subject, "Re: Fwd: quote v4 FINAL");
|
||||
assert!(row.original_subject.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Merges
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_merge_of_three_shows_as_one_thread_named_after_the_longest() {
|
||||
let conn = open();
|
||||
let first = arrive(&conn, "[email protected]", "Quote", 300);
|
||||
let second = arrive(&conn, "[email protected]", "The kitchen quote, revised", 200);
|
||||
let third = arrive(&conn, "[email protected]", "Re: quote", 100);
|
||||
|
||||
let keys = vec![first.clone(), second.clone(), third.clone()];
|
||||
let (merged_key, sources, rename_before) = super::merge(&conn, &keys, None).expect("a merge");
|
||||
assert_eq!(merged_key, first);
|
||||
assert_eq!(sources, vec![second.clone(), third.clone()]);
|
||||
assert!(rename_before.is_none());
|
||||
|
||||
let inbox = page(&conn, Place::Inbox);
|
||||
assert_eq!(inbox.len(), 1, "three threads merged show as one");
|
||||
assert_eq!(inbox[0].key, first);
|
||||
assert_eq!(inbox[0].subject, "The kitchen quote, revised");
|
||||
assert!(inbox[0].merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_of_merges_shows_once() {
|
||||
// Two devices can each merge without either meaning to make a chain: c into b, then b into a.
|
||||
let conn = open();
|
||||
let first = arrive(&conn, "[email protected]", "One", 300);
|
||||
let second = arrive(&conn, "[email protected]", "Two", 200);
|
||||
let third = arrive(&conn, "[email protected]", "Three", 100);
|
||||
|
||||
super::merge(&conn, &[second.clone(), third.clone()], Some("Two")).expect("c into b");
|
||||
super::merge(&conn, &[first.clone(), second.clone()], Some("One")).expect("b into a");
|
||||
|
||||
assert_eq!(keys_in(&conn, Place::Inbox), vec![first]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmerge_puts_them_all_back() {
|
||||
let conn = open();
|
||||
let first = arrive(&conn, "[email protected]", "One", 300);
|
||||
let second = arrive(&conn, "[email protected]", "Two", 200);
|
||||
let third = arrive(&conn, "[email protected]", "Three", 100);
|
||||
let keys = vec![first.clone(), second.clone(), third.clone()];
|
||||
super::merge(&conn, &keys, None).expect("a merge");
|
||||
|
||||
assert_eq!(
|
||||
super::direct_sources(&conn, &first).expect("the sources"),
|
||||
vec![third.clone(), second.clone()],
|
||||
"the sources are what putting the merge back is made of"
|
||||
);
|
||||
state::write::unmerge(&conn, &first).expect("unmerged");
|
||||
|
||||
let inbox = keys_in(&conn, Place::Inbox);
|
||||
assert_eq!(inbox.len(), 3);
|
||||
for key in keys {
|
||||
assert!(inbox.contains(&key), "{key} did not come back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taking_back_one_merge_leaves_the_merge_it_joined() {
|
||||
let conn = open();
|
||||
let first = arrive(&conn, "[email protected]", "One", 400);
|
||||
let second = arrive(&conn, "[email protected]", "Two", 300);
|
||||
let third = arrive(&conn, "[email protected]", "Three", 200);
|
||||
super::merge(&conn, &[first.clone(), second.clone()], Some("One")).expect("the first merge");
|
||||
|
||||
let (_, sources, rename_before) =
|
||||
super::merge(&conn, &[first.clone(), third.clone()], Some("One and three")).expect("more");
|
||||
for source in &sources {
|
||||
super::unmerge_one(&conn, source).expect("back out");
|
||||
}
|
||||
super::set_name(&conn, &first, rename_before.as_deref()).expect("the name back");
|
||||
|
||||
let inbox = page(&conn, Place::Inbox);
|
||||
assert_eq!(inbox.len(), 2, "the earlier merge is untouched");
|
||||
assert_eq!(inbox[0].key, first);
|
||||
assert_eq!(inbox[0].subject, "One");
|
||||
assert_eq!(inbox[1].key, third);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Ignore and notify
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn an_ignored_thread_receives_its_messages_and_does_not_return_to_new() {
|
||||
let conn = open();
|
||||
let ignored = arrive(&conn, "[email protected]", "The long thread", 100);
|
||||
let ordinary = arrive(&conn, "[email protected]", "Hello", 90);
|
||||
state::write::set_thread_flags(&conn, &ignored, Some(true), None).expect("ignored");
|
||||
|
||||
append(&conn, &ignored, 300);
|
||||
append(&conn, &ordinary, 200);
|
||||
|
||||
let inbox = page(&conn, Place::Inbox);
|
||||
let held = |key: &str| {
|
||||
inbox
|
||||
.iter()
|
||||
.find(|row| row.key == key)
|
||||
.expect("the row")
|
||||
.clone()
|
||||
};
|
||||
|
||||
let loud = held(&ignored);
|
||||
assert_eq!(loud.message_count, 2, "the message still arrived and still appended");
|
||||
assert!(loud.ignored);
|
||||
assert_eq!(loud.group, "seen", "an ignored thread never returns to New for you");
|
||||
assert_eq!(held(&ordinary).group, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_switches_are_one_row_and_neither_clears_the_other() {
|
||||
let conn = open();
|
||||
let key = arrive(&conn, "[email protected]", "Hello", 100);
|
||||
|
||||
state::write::set_thread_flags(&conn, &key, None, Some(true)).expect("notify");
|
||||
state::write::set_thread_flags(&conn, &key, Some(true), None).expect("ignore");
|
||||
|
||||
let flags = state::read::flags_of(&conn, &key).expect("the flags");
|
||||
assert!(flags.ignored && flags.notify);
|
||||
|
||||
let row = &page(&conn, Place::Inbox)[0];
|
||||
assert!(row.ignored && row.notify);
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
// Drafts: the copy on this machine and the copy that roams.
|
||||
//
|
||||
// Two rhythms, and the reason there are two is the whole of this module. The local write is
|
||||
// synchronous, costs a row, and happens on every save the composer sends; the provider upload is
|
||||
// rate limited and coalesces, because a draft saved on a keystroke would be a request on a
|
||||
// keystroke and Gmail's per-user quota is not a rounding error at that rate. What makes the
|
||||
// coalescing free is that the `drafts` row is itself the queue: the payload carries the version the
|
||||
// composer last wrote and the version the provider last had, and ten keystrokes between two uploads
|
||||
// are one upload rather than ten.
|
||||
//
|
||||
// The size check lives here rather than in the composer because the limit is about encoded bytes
|
||||
// and the frontend has no idea what a base64 part costs. It is an estimate on purpose: building the
|
||||
// message means reading every attachment off the disk, which is not something to do on a keystroke,
|
||||
// and the send builds the real thing and checks again.
|
||||
//
|
||||
// The upload does not go through `sync::outbox`. That queue is drained against `sync::Remote`,
|
||||
// which is `Provider` narrowed to what the engine needs and carries `send` but neither `draft_put`
|
||||
// nor `draft_delete`, so a draft row in it would be a row the drain cannot push and every flag
|
||||
// change behind it would wait. Widening that trait is the sync package's file to widen; until then
|
||||
// the queue is the `drafts` table, which already has the two columns it needs.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::decisions::db_of;
|
||||
use crate::dto::{Draft, DraftAttachment, DraftSaved, Person};
|
||||
use crate::mime::build::{self, Outgoing, OutgoingAttachment};
|
||||
use crate::mirror::{read, write};
|
||||
use crate::sync::{self, Store};
|
||||
|
||||
/// Gmail's ceiling on one message, encoded. Checked on every save so an attachment is refused while
|
||||
/// it can still be taken off again, rather than at the send.
|
||||
pub const MAX_ENCODED_BYTES: u64 = 35 * 1024 * 1024;
|
||||
|
||||
/// How often a draft may reach the provider. The composer's own debounce decides how often a save
|
||||
/// arrives; this decides how often one of them leaves the machine.
|
||||
pub const UPLOAD_EVERY_MS: i64 = 5_000;
|
||||
|
||||
/// 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
|
||||
/// millisecond is not fine enough to tell two keystrokes apart, and a clock is the wrong thing to
|
||||
/// ask anyway: what matters is whether this is the copy that went, not when it went. `uploaded_at`
|
||||
/// is only the rate limit.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Stored {
|
||||
draft: Draft,
|
||||
#[serde(default)]
|
||||
version: i64,
|
||||
#[serde(default)]
|
||||
uploaded_version: i64,
|
||||
#[serde(default)]
|
||||
uploaded_at: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The local copy
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
fn stored(conn: &Connection, id: &str) -> Result<Option<(Stored, Option<String>, i64)>, String> {
|
||||
let held: Option<(String, Option<String>, i64)> = conn
|
||||
.query_row(
|
||||
"SELECT payload, provider_draft_id, updated_at FROM drafts WHERE id = ?1",
|
||||
[id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some((payload, provider_draft_id, updated_at)) = held else {
|
||||
return Ok(None);
|
||||
};
|
||||
let stored: Stored = serde_json::from_str(&payload).map_err(|e| e.to_string())?;
|
||||
Ok(Some((stored, provider_draft_id, updated_at)))
|
||||
}
|
||||
|
||||
/// Writes the draft and answers with what the composer needs to know about its size.
|
||||
///
|
||||
/// The second save of a draft updates the row rather than adding one: the id is the app's own and
|
||||
/// the composer sends it back with every save.
|
||||
pub fn save(conn: &Connection, draft: &Draft) -> Result<DraftSaved, String> {
|
||||
let id = draft
|
||||
.id
|
||||
.clone()
|
||||
.filter(|id| !id.is_empty())
|
||||
.unwrap_or_else(|| write::fresh_id("draft"));
|
||||
let mut draft = draft.clone();
|
||||
draft.id = Some(id.clone());
|
||||
|
||||
let held = stored(conn, &id)?.map(|(held, _, _)| held);
|
||||
let payload = serde_json::to_string(&Stored {
|
||||
draft: draft.clone(),
|
||||
version: held.as_ref().map(|held| held.version).unwrap_or(0) + 1,
|
||||
uploaded_version: held.as_ref().map(|held| held.uploaded_version).unwrap_or(0),
|
||||
uploaded_at: held.map(|held| held.uploaded_at).unwrap_or(0),
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let now = write::now_ms();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO drafts (id, thread_key, in_reply_to, payload, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
thread_key = excluded.thread_key,
|
||||
in_reply_to = excluded.in_reply_to,
|
||||
payload = excluded.payload,
|
||||
updated_at = excluded.updated_at",
|
||||
rusqlite::params![id, draft.thread_key, draft.in_reply_to, payload, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let encoded_size = encoded_size(&draft);
|
||||
Ok(DraftSaved {
|
||||
id,
|
||||
updated_at_ms: now,
|
||||
encoded_size,
|
||||
over_limit: encoded_size > MAX_ENCODED_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(conn: &Connection, id: &str) -> Result<Draft, String> {
|
||||
stored(conn, id)?
|
||||
.map(|(held, _, _)| held.draft)
|
||||
.ok_or_else(|| "that draft is not on this device".to_string())
|
||||
}
|
||||
|
||||
/// Removes the local row and hands back the provider's draft id, when the draft had reached it, so
|
||||
/// the caller can take that copy away too.
|
||||
pub fn delete(conn: &Connection, id: &str) -> Result<Option<String>, String> {
|
||||
let provider_draft_id = stored(conn, id)?.and_then(|(_, provider, _)| provider);
|
||||
conn.execute("DELETE FROM drafts WHERE id = ?1", [id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(provider_draft_id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What it weighs
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Base64 is four characters for every three bytes, with a line ending every 76 of them.
|
||||
fn base64_len(bytes: u64) -> u64 {
|
||||
let encoded = bytes.div_ceil(3) * 4;
|
||||
encoded + encoded.div_ceil(76) * 2
|
||||
}
|
||||
|
||||
/// What this draft will weigh on the wire, near enough to refuse an attachment with.
|
||||
///
|
||||
/// Estimated rather than built. The body is counted twice because every message carries a plain
|
||||
/// text alternative built from the HTML, and each part carries its own headers and boundary.
|
||||
pub fn encoded_size(draft: &Draft) -> u64 {
|
||||
const PART_OVERHEAD: u64 = 512;
|
||||
|
||||
let mut total = PART_OVERHEAD * 2 + draft.body_html.len() as u64 * 2;
|
||||
total += draft.subject.len() as u64;
|
||||
for person in draft.to.iter().chain(&draft.cc).chain(&draft.bcc) {
|
||||
total += person.address.len() as u64 + 32;
|
||||
}
|
||||
for attachment in &draft.attachments {
|
||||
total += base64_len(attachment.size) + PART_OVERHEAD + attachment.filename.len() as u64;
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The bytes
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// One attachment's bytes: a file the composer named, else a part of a message already here, which
|
||||
/// is what a forward carries.
|
||||
pub fn attachment_bytes(
|
||||
conn: &Connection,
|
||||
attachment: &DraftAttachment,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if let Some(path) = attachment.path.as_deref().filter(|path| !path.is_empty()) {
|
||||
return std::fs::read(path)
|
||||
.map_err(|e| format!("{} could not be read: {e}", attachment.filename));
|
||||
}
|
||||
let id = attachment
|
||||
.attachment_id
|
||||
.as_deref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| format!("{} has neither a file nor an attachment", attachment.filename))?;
|
||||
|
||||
let row = read::attachment_row(conn, id)?
|
||||
.ok_or_else(|| format!("{} is not on this device", attachment.filename))?;
|
||||
if let Some(path) = &row.cached_path {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
let raw = read::raw_body(conn, &row.message_id)?
|
||||
.ok_or_else(|| format!("{} is not on this device", attachment.filename))?;
|
||||
part_bytes(&raw, &row.filename, row.size)
|
||||
.ok_or_else(|| format!("{} could not be read out of its message", attachment.filename))
|
||||
}
|
||||
|
||||
/// The bytes of one part of a message on the device, matched on what the attachment row holds: the
|
||||
/// decoded length and the file name. Body parts are skipped, so a text body the same length as a
|
||||
/// file cannot be mistaken for it.
|
||||
fn part_bytes(raw: &[u8], filename: &str, size: u64) -> Option<Vec<u8>> {
|
||||
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||
|
||||
let message = MessageParser::default().parse(raw)?;
|
||||
let bodies: HashSet<usize> = message
|
||||
.html_body
|
||||
.iter()
|
||||
.chain(message.text_body.iter())
|
||||
.map(|index| *index as usize)
|
||||
.collect();
|
||||
|
||||
let mut fallback = None;
|
||||
for (index, part) in message.parts.iter().enumerate() {
|
||||
if bodies.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let bytes = match &part.body {
|
||||
PartType::Binary(bytes) | PartType::InlineBinary(bytes) => bytes.to_vec(),
|
||||
PartType::Text(text) | PartType::Html(text) => text.as_bytes().to_vec(),
|
||||
PartType::Multipart(_) | PartType::Message(_) => continue,
|
||||
};
|
||||
if bytes.len() as u64 != size {
|
||||
continue;
|
||||
}
|
||||
if part.attachment_name() == Some(filename) {
|
||||
return Some(bytes);
|
||||
}
|
||||
fallback.get_or_insert(bytes);
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
/// The draft as RFC 2822 bytes. The threading headers are the caller's to supply, because what a
|
||||
/// reply belongs to is a fact about the thread and not about the draft.
|
||||
pub fn built(
|
||||
conn: &Connection,
|
||||
draft: &Draft,
|
||||
from: &Person,
|
||||
message_id: Option<&str>,
|
||||
in_reply_to: Option<&str>,
|
||||
references: &[String],
|
||||
subject: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut attachments = Vec::new();
|
||||
for attachment in &draft.attachments {
|
||||
attachments.push(OutgoingAttachment {
|
||||
filename: attachment.filename.clone(),
|
||||
mime_type: attachment.mime_type.clone(),
|
||||
bytes: attachment_bytes(conn, attachment)?,
|
||||
});
|
||||
}
|
||||
|
||||
build::build(&Outgoing {
|
||||
from: from.clone(),
|
||||
to: draft.to.clone(),
|
||||
cc: draft.cc.clone(),
|
||||
bcc: draft.bcc.clone(),
|
||||
reply_to: Vec::new(),
|
||||
subject: subject.to_string(),
|
||||
html: draft.body_html.clone(),
|
||||
stylesheet: None,
|
||||
message_id: message_id.map(str::to_string),
|
||||
in_reply_to: in_reply_to.map(str::to_string),
|
||||
references: references.to_vec(),
|
||||
date_ms: Some(write::now_ms()),
|
||||
attachments,
|
||||
inline: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The copy that roams
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The drafts whose local copy is ahead of the provider's and whose moment has come.
|
||||
fn due(conn: &Connection, now: i64) -> Result<Vec<(String, Option<String>, Stored)>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, provider_draft_id, payload FROM drafts")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (id, provider_draft_id, payload) in rows {
|
||||
let Ok(held) = serde_json::from_str::<Stored>(&payload) else {
|
||||
continue;
|
||||
};
|
||||
if held.version > held.uploaded_version && now - held.uploaded_at >= UPLOAD_EVERY_MS {
|
||||
out.push((id, provider_draft_id, held));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Records which version of the draft the provider now has. The payload is read back rather than
|
||||
/// kept, because the composer may have written again while the upload was in the air and that write
|
||||
/// must not be lost to this one.
|
||||
fn uploaded(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
provider_draft_id: &str,
|
||||
version: i64,
|
||||
now: i64,
|
||||
) -> Result<(), String> {
|
||||
let Some((mut held, _, _)) = stored(conn, id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
held.uploaded_version = version;
|
||||
held.uploaded_at = now;
|
||||
let payload = serde_json::to_string(&held).map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE drafts SET provider_draft_id = ?2, payload = ?3 WHERE id = ?1",
|
||||
rusqlite::params![id, provider_draft_id, payload],
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Uploads every draft that is due, and answers with how many went.
|
||||
///
|
||||
/// Generic over the provider rather than taking `sync::Remote`, because the two calls a draft makes
|
||||
/// are the two that trait does not carry.
|
||||
pub async fn upload<S: Store, P: sync::Remote + ?Sized>(
|
||||
store: &S,
|
||||
provider: &P,
|
||||
from: &Person,
|
||||
now: i64,
|
||||
) -> Result<u32, String> {
|
||||
let mut sent = 0u32;
|
||||
for (id, provider_draft_id, held) in store.with(|conn| due(conn, now))? {
|
||||
let subject = held.draft.subject.clone();
|
||||
let from = crate::send::sender(&held.draft, from);
|
||||
let raw = store.with(|conn| built(conn, &held.draft, &from, None, None, &[], &subject))?;
|
||||
let thread_hint = store.with(|conn| thread_hint(conn, held.draft.thread_key.as_deref()))?;
|
||||
|
||||
let put = provider
|
||||
.draft_put(provider_draft_id.as_deref(), &raw, thread_hint.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
store.with(|conn| uploaded(conn, &id, &put, held.version, now))?;
|
||||
sent += 1;
|
||||
}
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
/// The provider's own id for a thread, so a draft or a reply lands in it on their side too.
|
||||
pub fn thread_hint(conn: &Connection, thread_key: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(key) = thread_key.filter(|key| !key.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
conn.query_row(
|
||||
"SELECT provider_thread_id FROM threads WHERE thread_key = ?1 ORDER BY latest_ms DESC
|
||||
LIMIT 1",
|
||||
[key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// One account at a time, so two saves in the same second cannot both upload the same draft.
|
||||
fn uploading() -> &'static Mutex<HashSet<String>> {
|
||||
static UPLOADING: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
UPLOADING.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Puts every draft of one account that is due in front of the provider.
|
||||
///
|
||||
/// Public because the poll loop is the other honest place to call it from: a draft written just
|
||||
/// before the app was quit is uploaded when the app comes back, rather than waiting for somebody to
|
||||
/// open the composer again.
|
||||
pub async fn upload_pending(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
|
||||
if !uploading()
|
||||
.lock()
|
||||
.map(|mut held| held.insert(account_id.to_string()))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
let done = upload_now(app, account_id).await;
|
||||
if let Ok(mut held) = uploading().lock() {
|
||||
held.remove(account_id);
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
async fn upload_now(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
|
||||
let db = db_of(app)?;
|
||||
let from = crate::send::own_person(app, db.inner(), account_id)?;
|
||||
let store = sync::Scoped {
|
||||
db: db.inner(),
|
||||
account_id,
|
||||
};
|
||||
// The handle the engine registered for this account, which is the only thing in this module
|
||||
// that knows the mailbox behind it is Gmail rather than an IMAP server.
|
||||
let Some(provider) = sync::remote_for(account_id) else {
|
||||
return Ok(0);
|
||||
};
|
||||
upload(&store, provider.as_ref(), &from, write::now_ms()).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn draft_save(app: tauri::AppHandle, draft: Draft) -> Result<DraftSaved, String> {
|
||||
let db = db_of(&app)?;
|
||||
let account_id = draft.account_id.clone();
|
||||
let saved = db.with(&account_id, |conn| save(conn, &draft))?;
|
||||
|
||||
// The local write is the save. The upload is behind it on its own clock, so a composer that
|
||||
// saves on every debounce is not a client that uploads on every debounce.
|
||||
let handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(UPLOAD_EVERY_MS as u64)).await;
|
||||
let _ = upload_pending(&handle, &account_id).await;
|
||||
});
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
|
||||
let db = db_of(&app)?;
|
||||
for (account_id, _) in sync::accounts(db.inner(), None) {
|
||||
if let Ok(draft) = db.with(&account_id, |conn| get(conn, &id)) {
|
||||
return Ok(draft);
|
||||
}
|
||||
}
|
||||
Err("that draft is not on this device".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn draft_delete(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
let db = db_of(&app)?;
|
||||
for (account_id, _) in sync::accounts(db.inner(), None) {
|
||||
let held = db.with(&account_id, |conn| stored(conn, &id))?;
|
||||
if held.is_none() {
|
||||
continue;
|
||||
}
|
||||
let provider_draft_id = db.with(&account_id, |conn| delete(conn, &id))?;
|
||||
if let Some(provider_draft_id) = provider_draft_id {
|
||||
// The local row has gone either way. A provider that will not take the delete leaves a
|
||||
// draft in the mailbox's own Drafts, which is visible and fixable, and refusing the
|
||||
// command over it would leave the one on this machine that the person asked to be rid
|
||||
// of.
|
||||
if let Some(provider) = sync::remote_for(&account_id) {
|
||||
let _ = provider.draft_delete(&provider_draft_id).await;
|
||||
}
|
||||
}
|
||||
crate::emit_store_changed(&app, "threads");
|
||||
return Ok(());
|
||||
}
|
||||
Err("that draft is not on this device".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,193 @@
|
||||
// Drafts over a pair of in-memory databases and the fake mailbox.
|
||||
//
|
||||
// The two rhythms are what these hold to: a save is a row and nothing else, and the upload is rate
|
||||
// limited, so the assertions are about how many rows there are and how many calls the provider saw.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tauri::async_runtime::block_on;
|
||||
|
||||
use crate::db;
|
||||
use crate::dto::{Draft, DraftAttachment, Person};
|
||||
use crate::mirror::write;
|
||||
use crate::provider::fake::FakeProvider;
|
||||
use crate::sync::Store;
|
||||
|
||||
use super::{save, upload, MAX_ENCODED_BYTES, UPLOAD_EVERY_MS};
|
||||
|
||||
struct Memory(Mutex<Connection>);
|
||||
|
||||
impl Store for Memory {
|
||||
fn with<T, F: FnOnce(&Connection) -> Result<T, String>>(&self, f: F) -> Result<T, String> {
|
||||
let conn = self.0.lock().map_err(|e| e.to_string())?;
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> Memory {
|
||||
Memory(Mutex::new(db::memory().expect("a pair of in-memory databases")))
|
||||
}
|
||||
|
||||
fn me() -> Person {
|
||||
Person {
|
||||
name: Some("You".to_string()),
|
||||
address: "[email protected]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn draft(body: &str) -> Draft {
|
||||
Draft {
|
||||
id: None,
|
||||
account_id: "acct".to_string(),
|
||||
thread_key: None,
|
||||
in_reply_to: None,
|
||||
from_alias: None,
|
||||
to: vec![Person {
|
||||
name: Some("Ana".to_string()),
|
||||
address: "[email protected]".to_string(),
|
||||
}],
|
||||
cc: Vec::new(),
|
||||
bcc: Vec::new(),
|
||||
subject: "About the lease".to_string(),
|
||||
body_html: format!("<p>{body}</p>"),
|
||||
attachments: Vec::new(),
|
||||
remind_at_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(store: &Memory) -> i64 {
|
||||
store
|
||||
.with(|conn| {
|
||||
conn.query_row("SELECT COUNT(*) FROM drafts", [], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.expect("a count")
|
||||
}
|
||||
|
||||
fn puts(fake: &FakeProvider) -> Vec<String> {
|
||||
fake.calls()
|
||||
.into_iter()
|
||||
.filter(|call| call.starts_with("draft_put"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_save_updates_the_draft_rather_than_adding_one() {
|
||||
let store = store();
|
||||
let first = store
|
||||
.with(|conn| save(conn, &draft("Half a sentence")))
|
||||
.expect("the first save");
|
||||
let mut again = draft("Half a sentence, and the rest of it");
|
||||
again.id = Some(first.id.clone());
|
||||
let second = store.with(|conn| save(conn, &again)).expect("the second save");
|
||||
|
||||
assert_eq!(second.id, first.id, "the composer's id is the draft's id");
|
||||
assert_eq!(rows(&store), 1, "one draft, not two");
|
||||
assert_eq!(
|
||||
store.with(|conn| super::get(conn, &first.id)).expect("the draft").body_html,
|
||||
"<p>Half a sentence, and the rest of it</p>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_upload_coalesces_rather_than_going_once_per_keystroke() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
|
||||
let mut id = None;
|
||||
for keystroke in 0..5 {
|
||||
let mut typing = draft(&format!("Typing {keystroke}"));
|
||||
typing.id = id.clone();
|
||||
id = Some(
|
||||
store
|
||||
.with(|conn| save(conn, &typing))
|
||||
.expect("a save")
|
||||
.id,
|
||||
);
|
||||
}
|
||||
assert_eq!(rows(&store), 1);
|
||||
assert!(puts(&fake).is_empty(), "saving is not uploading");
|
||||
|
||||
let now = write::now_ms();
|
||||
assert_eq!(
|
||||
block_on(upload(&store, &fake, &me(), now)).expect("an upload"),
|
||||
1
|
||||
);
|
||||
assert_eq!(puts(&fake).len(), 1, "five saves, one request");
|
||||
|
||||
// Nothing has changed since, so there is nothing to send.
|
||||
assert_eq!(block_on(upload(&store, &fake, &me(), now)).expect("nothing to do"), 0);
|
||||
|
||||
// And a keystroke inside the interval waits for it rather than going straight out.
|
||||
let mut typing = draft("Typing again");
|
||||
typing.id = id.clone();
|
||||
store.with(|conn| save(conn, &typing)).expect("another save");
|
||||
assert_eq!(block_on(upload(&store, &fake, &me(), now)).expect("too soon"), 0);
|
||||
assert_eq!(puts(&fake).len(), 1);
|
||||
|
||||
assert_eq!(
|
||||
block_on(upload(&store, &fake, &me(), now + UPLOAD_EVERY_MS)).expect("the interval passed"),
|
||||
1
|
||||
);
|
||||
let puts = puts(&fake);
|
||||
assert_eq!(puts.len(), 2);
|
||||
assert!(
|
||||
puts[1].contains("draft-1"),
|
||||
"the second upload updates the draft the first one created: {puts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_over_the_size_limit_says_so_before_a_send_is_attempted() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
|
||||
let mut heavy = draft("The plans are attached");
|
||||
heavy.attachments = vec![DraftAttachment {
|
||||
path: Some("/tmp/plans.pdf".to_string()),
|
||||
attachment_id: None,
|
||||
filename: "plans.pdf".to_string(),
|
||||
mime_type: "application/pdf".to_string(),
|
||||
size: 30 * 1024 * 1024,
|
||||
}];
|
||||
|
||||
let saved = store.with(|conn| save(conn, &heavy)).expect("a save");
|
||||
assert!(
|
||||
saved.encoded_size > 30 * 1024 * 1024,
|
||||
"base64 costs a third on top: {}",
|
||||
saved.encoded_size
|
||||
);
|
||||
assert!(
|
||||
saved.over_limit,
|
||||
"40 MB encoded is over the {} MB one message may be",
|
||||
MAX_ENCODED_BYTES / (1024 * 1024)
|
||||
);
|
||||
assert!(
|
||||
fake.calls().is_empty(),
|
||||
"the refusal is a fact about the draft and costs no request"
|
||||
);
|
||||
|
||||
// The same draft without the attachment is not over anything.
|
||||
let mut light = heavy.clone();
|
||||
light.id = saved.id.clone().into();
|
||||
light.attachments.clear();
|
||||
let saved = store.with(|conn| save(conn, &light)).expect("a save");
|
||||
assert!(!saved.over_limit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_draft_hands_back_the_copy_the_provider_is_holding() {
|
||||
let store = store();
|
||||
let fake = FakeProvider::new();
|
||||
let id = store
|
||||
.with(|conn| save(conn, &draft("A line")))
|
||||
.expect("a save")
|
||||
.id;
|
||||
block_on(upload(&store, &fake, &me(), write::now_ms())).expect("an upload");
|
||||
|
||||
let provider_draft_id = store.with(|conn| super::delete(conn, &id)).expect("the delete");
|
||||
assert_eq!(provider_draft_id.as_deref(), Some("draft-1"));
|
||||
assert_eq!(rows(&store), 0);
|
||||
assert!(store.with(|conn| super::get(conn, &id)).is_err());
|
||||
}
|
||||
+927
-1
@@ -1 +1,927 @@
|
||||
// The IPC contract. Every type here has a matching declaration in src/ipc.ts.
|
||||
// The IPC contract. Every type here has a matching declaration in src/ipc.ts, and both sides are
|
||||
// frozen once written: implementation modules add bodies, not fields.
|
||||
//
|
||||
// Two rules run through the whole file. Nothing that crosses this boundary carries a provider
|
||||
// identifier the frontend could act on, because a view is the mirror joined to the state and the
|
||||
// frontend is not entitled to know which of the two a value came from. And every list the frontend
|
||||
// renders arrives already ordered and already grouped, because the grouping is part of the view
|
||||
// and computing it twice in two languages is how the two drift.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// People, accounts and places
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// One correspondent. `name` is whatever the header carried, which is often nothing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
pub name: Option<String>,
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Account {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
/// Which mailbox is behind this account, and therefore which `Provider` drives it. Read by the
|
||||
/// frontend to know whether a scope list means anything here: an IMAP account has none.
|
||||
pub kind: AccountKind,
|
||||
/// The display name from the provider's profile, editable in settings.
|
||||
pub name: String,
|
||||
/// One of the eight hues, as a token name (`hue-1`), not a hex. The stylesheet owns the value.
|
||||
pub color: String,
|
||||
pub connected: bool,
|
||||
/// What Google actually granted. Granular consent means a user can untick a scope on the
|
||||
/// consent screen, so every feature that needs one checks this rather than assuming.
|
||||
pub granted_scopes: Vec<String>,
|
||||
/// How far back the mirror keeps this account's mail, in days. Zero means everything.
|
||||
pub window_days: u32,
|
||||
}
|
||||
|
||||
/// The two kinds of mailbox, which is the one thing above the `Provider` trait that has to know.
|
||||
///
|
||||
/// Everything else in the app is written against the trait. This exists because a few screens
|
||||
/// genuinely differ: an IMAP account has no scopes to grant, no Google account page to revoke
|
||||
/// from, and a server and port to show in settings that a Google account does not have.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AccountKind {
|
||||
Google,
|
||||
Imap,
|
||||
}
|
||||
|
||||
impl Default for AccountKind {
|
||||
/// Every account that existed before there was a second kind is a Google one.
|
||||
fn default() -> Self {
|
||||
AccountKind::Google
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// IMAP and SMTP configuration
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// How a socket is protected.
|
||||
///
|
||||
/// The three names Thunderbird's autoconfig format uses, because every published configuration in
|
||||
/// the world is written in that vocabulary and translating it twice is how the meanings drift.
|
||||
/// `Tls` is what the format calls SSL: TLS from the first byte, on 993 or 465. `StartTls` is a
|
||||
/// plaintext connection upgraded by a command, on 143 or 587.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Security {
|
||||
Plain,
|
||||
StartTls,
|
||||
Tls,
|
||||
}
|
||||
|
||||
/// Which family of SASL mechanism to authenticate with. The exact mechanism is chosen from what
|
||||
/// the server advertises; this only says which list to choose from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AuthKind {
|
||||
Password,
|
||||
/// Reachable from a discovered configuration, which is why it can be represented. Nothing
|
||||
/// builds one yet: OAuth over IMAP needs a client registered with that provider.
|
||||
OAuth2,
|
||||
}
|
||||
|
||||
/// One end of a mail account: a host, a port, how the socket is protected and who to log in as.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub security: Security,
|
||||
pub auth: AuthKind,
|
||||
/// Already expanded. `%EMAILADDRESS%` and `%EMAILLOCALPART%` are substituted inside discovery,
|
||||
/// so nothing downstream has to know that the format has placeholders in it.
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Both ends, and where they came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MailConfig {
|
||||
pub imap: ServerConfig,
|
||||
pub smtp: ServerConfig,
|
||||
/// Which rung of the ladder answered: `autoconfig`, `ispdb`, `mx`, `probe` or `manual`. The
|
||||
/// connect screen says where the settings came from, because "we found these" and "we guessed
|
||||
/// these" are different promises and a person about to type a password should be told which.
|
||||
pub source: String,
|
||||
/// The provider's own name for itself, when the configuration carried one.
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A certificate the user has to decide about before a connection can be made.
|
||||
///
|
||||
/// Only ever raised for a host that is not the loopback. Proton Bridge listens on 127.0.0.1 with a
|
||||
/// certificate it generated itself, and there is nothing between this process and that socket to
|
||||
/// impersonate anybody, so loopback is trusted without asking. Every other host is a question.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CertQuestion {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// SHA-256 of the DER, uppercase hex in colon-separated pairs, which is the form every other
|
||||
/// tool prints so it can be compared against one.
|
||||
pub fingerprint: String,
|
||||
pub subject: String,
|
||||
pub issuer: String,
|
||||
pub expires_ms: i64,
|
||||
/// `self-signed`, `expired` or `unknown-issuer`. A hostname mismatch is never a question: it
|
||||
/// is the one failure that looks exactly like an interception, so it is refused outright.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// What a connection test found. Not a `Result`, because "the password was wrong" and "the
|
||||
/// certificate needs a decision" are answers the screen renders rather than errors it reports.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectReport {
|
||||
pub ok: bool,
|
||||
/// What kind of refusal it was: `unreachable`, `auth`, `certificate`, `wrong-host` or `other`.
|
||||
///
|
||||
/// A separate field rather than something the screen reads back out of `message`, because the
|
||||
/// screen has real decisions hanging off it (a loopback that is unreachable means the bridge
|
||||
/// is not running, and that is a different sentence from a wrong password) and reading them
|
||||
/// out of prose means a regex over whatever the server happened to say that day.
|
||||
pub kind: Option<String>,
|
||||
/// `imap` or `smtp`, when one leg failed and the other did not.
|
||||
pub failed: Option<String>,
|
||||
/// One sentence, already fit to read. The server's own words when they are usable.
|
||||
pub message: Option<String>,
|
||||
pub cert: Option<CertQuestion>,
|
||||
/// Set when the server refused the password but named a reason a person can act on, such as
|
||||
/// an app password being required.
|
||||
pub advice: Option<String>,
|
||||
}
|
||||
|
||||
/// Every view the app can show. `Label` and `Search` carry an argument in `ThreadQuery`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Place {
|
||||
Inbox,
|
||||
Feed,
|
||||
PaperTrail,
|
||||
ReplyLater,
|
||||
SetAside,
|
||||
Screener,
|
||||
Snoozed,
|
||||
Everything,
|
||||
Sent,
|
||||
Drafts,
|
||||
Starred,
|
||||
ScreenedOut,
|
||||
Spam,
|
||||
Trash,
|
||||
Label,
|
||||
Search,
|
||||
}
|
||||
|
||||
/// Where a sender's mail goes. Exactly one per sender, which is the whole of the routing model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Destination {
|
||||
Inbox,
|
||||
Feed,
|
||||
PaperTrail,
|
||||
ScreenedOut,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Pile {
|
||||
ReplyLater,
|
||||
SetAside,
|
||||
}
|
||||
|
||||
/// The surface a message body renders on, decided from what the sender painted rather than from
|
||||
/// how the bytes arrived. `sanitize::surface` is where it is worked out and why.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Surface {
|
||||
/// The sender painted nothing, so the body takes the app's own paper and follows the theme.
|
||||
#[default]
|
||||
Theme,
|
||||
/// The sender painted an opaque light page across the layout, so the body keeps it in both
|
||||
/// palettes and the pane's chrome goes dark around it.
|
||||
Paper,
|
||||
}
|
||||
|
||||
/// What a list asks for. `account_id` of None means every account, which is the All accounts view.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadQuery {
|
||||
#[serde(default)]
|
||||
pub account_id: Option<String>,
|
||||
pub place: Place,
|
||||
/// The provider's label id, for `Place::Label`.
|
||||
#[serde(default)]
|
||||
pub label_id: Option<String>,
|
||||
/// The query text, for `Place::Search`.
|
||||
#[serde(default)]
|
||||
pub query: Option<String>,
|
||||
pub limit: u32,
|
||||
/// Opaque, from the previous page's `next_cursor`. None starts at the top.
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Threads
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// One row of a list, already grouped and already ordered.
|
||||
///
|
||||
/// `key` is the portable thread key: the first entry of the message's `References` header, else its
|
||||
/// `In-Reply-To`, else its own `Message-ID`. It does not depend on which messages happen to be
|
||||
/// mirrored, so it survives a storage window changing, a second device, and a move to another
|
||||
/// provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadSummary {
|
||||
pub key: String,
|
||||
pub account_id: String,
|
||||
/// The account's hue token, for the coloured edge in All accounts.
|
||||
pub account_color: String,
|
||||
/// What to print. The rename when there is one, the real subject otherwise.
|
||||
pub subject: String,
|
||||
/// Set only when the thread was renamed, so the row can say "renamed · was …".
|
||||
pub original_subject: Option<String>,
|
||||
/// Whose name goes on the row: the latest correspondent who is not the account.
|
||||
pub from: Person,
|
||||
/// Everyone in the thread, for the stacked avatars in the pane.
|
||||
pub participants: Vec<Person>,
|
||||
pub snippet: String,
|
||||
/// Epoch milliseconds of the latest message.
|
||||
pub date_ms: i64,
|
||||
pub message_count: u32,
|
||||
/// At least one message the account has not seen. There is no count anywhere in this app.
|
||||
pub unseen: bool,
|
||||
pub starred: bool,
|
||||
/// In the provider's trash, and in its spam. Flags rather than places: a search result carries
|
||||
/// them into a list that is not Trash or Spam, and neither the row nor the pane can work them
|
||||
/// out from the place it is being shown in.
|
||||
pub trashed: bool,
|
||||
pub spam: bool,
|
||||
pub has_attachment: bool,
|
||||
pub has_draft: bool,
|
||||
pub pile: Option<Pile>,
|
||||
/// Epoch milliseconds: the moment this thread was due back.
|
||||
///
|
||||
/// In the future while it is still waiting, in the past once it has come back and is sitting in
|
||||
/// the Back group, which is how a thread that returned late can say "Due yesterday" rather than
|
||||
/// pretending. The group tells the two apart, so a row never has to guess.
|
||||
pub snoozed_until: Option<i64>,
|
||||
pub ignored: bool,
|
||||
pub notify: bool,
|
||||
pub merged: bool,
|
||||
/// The one line under the row. The whole note is in the pane.
|
||||
pub note: Option<String>,
|
||||
/// Which group head this row sits under: "back", "new", "seen", "this-week", "earlier" or
|
||||
/// "sent-to". The view decides both the grouping and the order the groups come in, and the
|
||||
/// frontend renders a head whenever this changes and never sorts again. Sorting twice is how
|
||||
/// Back ends up in the middle of the Inbox.
|
||||
pub group: String,
|
||||
/// Waiting in the outbox, so the row can say "Waiting to send".
|
||||
pub sending: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadPage {
|
||||
pub threads: Vec<ThreadSummary>,
|
||||
/// Feed the next call. None when this is the end of the list.
|
||||
pub next_cursor: Option<String>,
|
||||
/// The quiet line at the foot: "Showing the last month. Older mail is on Gmail." or the
|
||||
/// search's "Search older mail on Gmail". None when there is nothing to say.
|
||||
pub footer: Option<String>,
|
||||
}
|
||||
|
||||
/// A thread this one was merged from, for the banner that offers Unmerge.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MergedSource {
|
||||
pub key: String,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
/// The whole thread, for the reading pane.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThreadView {
|
||||
pub key: String,
|
||||
pub account_id: String,
|
||||
pub subject: String,
|
||||
pub original_subject: Option<String>,
|
||||
pub participants: Vec<Person>,
|
||||
pub messages: Vec<MessageView>,
|
||||
pub notes: Vec<Note>,
|
||||
pub merged_from: Vec<MergedSource>,
|
||||
pub pile: Option<Pile>,
|
||||
pub snoozed_until: Option<i64>,
|
||||
pub ignored: bool,
|
||||
pub notify: bool,
|
||||
pub starred: bool,
|
||||
/// The same two flags the row carries, so the pane can offer the way back out.
|
||||
pub trashed: bool,
|
||||
pub spam: bool,
|
||||
/// The provider's labels on this thread, for the label list. Never rendered in a row.
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Messages
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub message_id: String,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size: u64,
|
||||
/// Referenced from the body by `cid:` rather than listed as a chip.
|
||||
pub inline: bool,
|
||||
pub content_id: Option<String>,
|
||||
/// Already in the local cache, so opening it costs nothing.
|
||||
pub cached: bool,
|
||||
}
|
||||
|
||||
/// A remote image that was removed before the body was rendered, and who it belonged to.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tracker {
|
||||
/// The vendor from the shipped list, or the bare host when it is not a known one.
|
||||
pub vendor: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// `List-Unsubscribe`, in the three forms it arrives in.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Unsubscribe {
|
||||
/// RFC 8058: the app POSTs and the sender is required to honour it without a round trip.
|
||||
pub one_click: bool,
|
||||
pub mailto: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum InviteResponse {
|
||||
Accepted,
|
||||
Tentative,
|
||||
Declined,
|
||||
NeedsAction,
|
||||
}
|
||||
|
||||
/// A `text/calendar` part with `METHOD:REQUEST`, rendered as a card.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Invite {
|
||||
pub uid: String,
|
||||
pub summary: String,
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub all_day: bool,
|
||||
pub location: Option<String>,
|
||||
pub organizer: Option<Person>,
|
||||
pub description: Option<String>,
|
||||
/// What this account has already answered, when it has.
|
||||
pub my_response: InviteResponse,
|
||||
/// A deep link into Margin Calendar, when the event can be addressed there.
|
||||
pub calendar_link: Option<String>,
|
||||
}
|
||||
|
||||
/// One message, sanitised and ready to render.
|
||||
///
|
||||
/// `html` is what goes into the iframe's `srcdoc`. It has been through the sanitiser, so `cid:`
|
||||
/// images are already `data:` URIs and every remote image is either removed or, once the user has
|
||||
/// asked for them, fetched by Rust and inlined the same way. The frontend never fetches anything.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MessageView {
|
||||
/// The provider's message id. Every command argument named `message_id` is this one, never the
|
||||
/// header below it: the RFC `Message-ID` is what the state database keys on and it never
|
||||
/// crosses this boundary as an argument.
|
||||
pub id: String,
|
||||
/// The RFC `Message-ID`, which is what the state database keys on.
|
||||
pub message_id: String,
|
||||
pub thread_key: String,
|
||||
pub from: Person,
|
||||
pub to: Vec<Person>,
|
||||
pub cc: Vec<Person>,
|
||||
pub bcc: Vec<Person>,
|
||||
pub reply_to: Vec<Person>,
|
||||
pub date_ms: i64,
|
||||
pub subject: String,
|
||||
pub html: String,
|
||||
/// True when this message's body has not been fetched yet, so `html` is empty because there is
|
||||
/// nothing to show rather than because the message was.
|
||||
///
|
||||
/// Opening a thread never waits on the network. The rows come back from the mirror at once and
|
||||
/// anything still missing a body arrives on a later `store-changed`, which is the difference
|
||||
/// between a thread that opens and a thread that loads.
|
||||
pub body_pending: bool,
|
||||
/// The trailing quoted conversation, split off so it can sit behind a pill.
|
||||
pub quoted_html: Option<String>,
|
||||
/// True when the source was HTML. A plain text message has been converted, and the pane sets
|
||||
/// it on a 46em measure in the text face rather than leaving it to the sender's markup.
|
||||
pub is_html: bool,
|
||||
/// Which surface the body reads on. Not the same question as `is_html`: what decides it is
|
||||
/// whether the sender painted a page, and most HTML mail paints nothing.
|
||||
pub surface: Surface,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub trackers: Vec<Tracker>,
|
||||
/// Ordinary remote images that were blocked, which is a different count from the trackers.
|
||||
pub blocked_images: u32,
|
||||
/// Whether the body currently rendered has remote images loaded.
|
||||
pub images_loaded: bool,
|
||||
pub seen: bool,
|
||||
pub draft: bool,
|
||||
pub sent_by_me: bool,
|
||||
pub invite: Option<Invite>,
|
||||
pub unsubscribe: Option<Unsubscribe>,
|
||||
pub list_id: Option<String>,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// The decisions: rules, piles, snoozes, notes, clips
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SenderRule {
|
||||
pub account_id: String,
|
||||
/// An address, or a domain when `is_domain`.
|
||||
pub subject: String,
|
||||
pub is_domain: bool,
|
||||
pub destination: Destination,
|
||||
pub decided_at_ms: i64,
|
||||
/// The suggestion rule that fired when this was set automatically, for the contact card.
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// One waiting sender in the Screener.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScreenerCard {
|
||||
pub account_id: String,
|
||||
pub sender: Person,
|
||||
pub thread_key: String,
|
||||
pub subject: String,
|
||||
pub snippet: String,
|
||||
pub date_ms: i64,
|
||||
pub suggestion: Destination,
|
||||
/// The sentence the card prints, which is the row of the rules table that matched.
|
||||
pub reason: String,
|
||||
/// How many messages this sender has waiting, when it is more than one.
|
||||
pub waiting: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SnoozeKind {
|
||||
LaterToday,
|
||||
Tomorrow,
|
||||
Weekend,
|
||||
NextWeek,
|
||||
Date,
|
||||
/// Comes back only if nobody but the account has written since.
|
||||
IfNoReply,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snooze {
|
||||
pub thread_key: String,
|
||||
pub return_at_ms: i64,
|
||||
pub kind: SnoozeKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub thread_key: String,
|
||||
pub body: String,
|
||||
pub created_at_ms: i64,
|
||||
/// The message that was latest when the note was written, so the pane can place it.
|
||||
pub after_message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Clip {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub thread_key: String,
|
||||
pub message_id: String,
|
||||
pub text: String,
|
||||
pub sender: Person,
|
||||
pub subject: String,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
/// Everything the app knows about one correspondent, for the popover and the Contacts place.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactCard {
|
||||
pub person: Person,
|
||||
pub account_id: String,
|
||||
pub destination: Destination,
|
||||
/// The rule that decides them is on their whole domain rather than their address.
|
||||
pub domain_rule: bool,
|
||||
/// A consumer domain cannot carry a domain rule, so the toggle is not offered.
|
||||
pub domain_rule_allowed: bool,
|
||||
pub notify: bool,
|
||||
pub screened_at_ms: Option<i64>,
|
||||
pub note: Option<String>,
|
||||
pub allow_remote_images: bool,
|
||||
/// Trash this sender's Feed mail after so many days. None is never.
|
||||
pub auto_trash_days: Option<u32>,
|
||||
/// Bundle this sender's Paper Trail rows into one.
|
||||
pub bundle: bool,
|
||||
pub recent_threads: Vec<ThreadSummary>,
|
||||
pub files: Vec<Attachment>,
|
||||
pub unsubscribe: Option<Unsubscribe>,
|
||||
}
|
||||
|
||||
/// The patch the contact card writes back. An absent field means unchanged.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContactPatch {
|
||||
#[serde(default)]
|
||||
pub destination: Option<Destination>,
|
||||
#[serde(default)]
|
||||
pub domain_rule: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub notify: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_remote_images: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub auto_trash_days: Option<Option<u32>>,
|
||||
#[serde(default)]
|
||||
pub bundle: Option<bool>,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Writing
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// A file on the way out, before it has been encoded into a message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DraftAttachment {
|
||||
/// Absent for one that is already in the mirror, which is what a forward carries.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// The mirror's attachment id, for a forward.
|
||||
#[serde(default)]
|
||||
pub attachment_id: Option<String>,
|
||||
pub filename: String,
|
||||
pub mime_type: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Draft {
|
||||
/// The app's own draft id. Absent on the first save.
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
pub account_id: String,
|
||||
/// The thread being replied to, which is what sets the threading headers.
|
||||
#[serde(default)]
|
||||
pub thread_key: Option<String>,
|
||||
/// The message being replied to or forwarded.
|
||||
#[serde(default)]
|
||||
pub in_reply_to: Option<String>,
|
||||
/// A verified alias to send as, when it is not the account's own address.
|
||||
#[serde(default)]
|
||||
pub from_alias: Option<String>,
|
||||
pub to: Vec<Person>,
|
||||
#[serde(default)]
|
||||
pub cc: Vec<Person>,
|
||||
#[serde(default)]
|
||||
pub bcc: Vec<Person>,
|
||||
pub subject: String,
|
||||
/// The editor's HTML. Rust inlines the stylesheet and builds the plain text alternative.
|
||||
pub body_html: String,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<DraftAttachment>,
|
||||
/// Set a reminder on the thread if nobody replies by then.
|
||||
#[serde(default)]
|
||||
pub remind_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DraftSaved {
|
||||
pub id: String,
|
||||
pub updated_at_ms: i64,
|
||||
/// The encoded size, so the composer can refuse an attachment before the send fails.
|
||||
pub encoded_size: u64,
|
||||
pub over_limit: bool,
|
||||
}
|
||||
|
||||
/// A send that is waiting out its undo delay, or waiting for the network.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Outgoing {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub thread_key: Option<String>,
|
||||
/// Who the toast names.
|
||||
pub to: Vec<Person>,
|
||||
pub subject: String,
|
||||
/// Epoch milliseconds. Until then the send can still be taken back.
|
||||
pub hold_until_ms: i64,
|
||||
pub attempts: u32,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Undo
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// What a mutating command hands back so `z` can take it back.
|
||||
///
|
||||
/// The token is a handle on the state before the change, held in a bounded stack in Rust. It is
|
||||
/// there rather than in the frontend because a bulk archive of forty threads with mixed prior
|
||||
/// state cannot be reversed from what the frontend knew, and a reversal that guesses is worse than
|
||||
/// no reversal at all.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Undo {
|
||||
pub token: String,
|
||||
/// What the toast says: "Archived 3 threads", "Sent to Ana".
|
||||
pub label: String,
|
||||
/// Zero for anything but a send. A send's toast counts down.
|
||||
pub undo_ms: u32,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Search, labels, files
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchResult {
|
||||
pub page: ThreadPage,
|
||||
/// The query reached past the storage window, so the provider was asked as a second pass.
|
||||
pub provider_searched: bool,
|
||||
/// A note appended under the results when the two sources differ.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LabelInfo {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub name: String,
|
||||
/// system or user. The system ones are places already and are not offered for applying.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// One card in the All files place.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileCard {
|
||||
pub attachment: Attachment,
|
||||
pub thread_key: String,
|
||||
pub subject: String,
|
||||
pub sender: Person,
|
||||
pub date_ms: i64,
|
||||
/// images, pdfs, documents, spreadsheets, presentations, invites, archives, other
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Settings
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// margin-shared's `FontRef`, which is either one of the six bundled faces or a family off the
|
||||
/// machine. Stored rather than a bare family name, because a system font can be called "Literata"
|
||||
/// and must not come back as the bundled one.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FontRef {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Bundled { id: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
System { family: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSettings {
|
||||
pub account_id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
/// 30, 90, 180, 365, or 0 for everything.
|
||||
pub window_days: u32,
|
||||
pub signature: String,
|
||||
pub aliases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SnoozeTimes {
|
||||
/// Hours from now for Later today.
|
||||
pub later_today_hours: u32,
|
||||
/// Local time of day, in minutes from midnight.
|
||||
pub tomorrow_at: u32,
|
||||
pub weekend_at: u32,
|
||||
pub next_week_at: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupSettings {
|
||||
/// none, drive or r2
|
||||
pub store: String,
|
||||
pub configured: bool,
|
||||
pub last_backup_ms: Option<i64>,
|
||||
/// Shown once, at setup. Never returned again.
|
||||
pub has_phrase: bool,
|
||||
/// R2 only. The secret never crosses this boundary in the reading direction.
|
||||
pub r2_bucket: Option<String>,
|
||||
pub r2_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything the settings screen edits. Device settings live in `settings.json` in the app data
|
||||
/// directory, written atomically; the per account decisions that should roam live in the state
|
||||
/// database and its journal instead, and are edited through the contact card and the account
|
||||
/// sections rather than through this blob.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
/// light, dark or system
|
||||
pub theme: String,
|
||||
pub font_ui: FontRef,
|
||||
pub font_text: FontRef,
|
||||
pub text_size: u32,
|
||||
pub reading_pane: bool,
|
||||
/// comfortable or compact, on the phone
|
||||
pub density: String,
|
||||
|
||||
pub accounts: Vec<AccountSettings>,
|
||||
pub attachment_cache_mb: u32,
|
||||
pub prefetch_bodies: bool,
|
||||
|
||||
/// never, ask or always. The per sender allowances are not here: they live on the contact,
|
||||
/// because they are a decision about a person and they roam with the rest of those.
|
||||
pub remote_images: String,
|
||||
pub link_cleaning: bool,
|
||||
|
||||
pub screener_enabled: bool,
|
||||
/// A reply to a thread you are in is never held. Turning this off holds it anyway.
|
||||
pub hold_replies: bool,
|
||||
pub suggestions: bool,
|
||||
|
||||
pub snooze_times: SnoozeTimes,
|
||||
/// The pile a swipe reaches on the phone: reply-later, set-aside, archive, trash or none.
|
||||
pub swipe_right: String,
|
||||
pub swipe_left: String,
|
||||
pub feed_auto_trash_days: u32,
|
||||
|
||||
pub undo_delay_secs: u32,
|
||||
pub reply_all_default: bool,
|
||||
pub instant_intro: String,
|
||||
|
||||
pub badge: bool,
|
||||
/// The switch over everything below: off, and nothing is posted whatever a thread, a person or
|
||||
/// a place says. On by default, and absent from files written before it existed.
|
||||
#[serde(default = "on")]
|
||||
pub notifications: bool,
|
||||
/// The places that notify without being asked thread by thread. Empty is the default, which is
|
||||
/// the product's position: nothing tells you anything until you say so.
|
||||
pub notify_places: Vec<String>,
|
||||
|
||||
pub backup: BackupSettings,
|
||||
}
|
||||
|
||||
fn on() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the system will show this app's notifications: what System Settings says on macOS, and
|
||||
/// Prompt until the app has asked once. Everywhere else the answer is Granted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotifyPermission {
|
||||
Granted,
|
||||
Denied,
|
||||
Prompt,
|
||||
}
|
||||
|
||||
/// Where a click on a notification goes: the account it was about, the place its thread shows in,
|
||||
/// and the thread itself when the notification was about one. Several messages in one pass are
|
||||
/// one notification, and a click on that goes to the place alone.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotifyTarget {
|
||||
pub account_id: String,
|
||||
pub place: Place,
|
||||
pub thread_key: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageUsed {
|
||||
pub account_id: String,
|
||||
pub messages: u64,
|
||||
pub threads: u64,
|
||||
pub mirror_bytes: u64,
|
||||
pub bodies_bytes: u64,
|
||||
pub attachments_bytes: u64,
|
||||
pub state_bytes: u64,
|
||||
/// The date of the oldest message on the device, which is what the window setting shows.
|
||||
pub oldest_ms: Option<i64>,
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Sync, auth and events
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/// Per account, because one account being signed out must not be reported as the app being broken.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStatus {
|
||||
pub account_id: String,
|
||||
/// idle | syncing | hydrating | caching | backfilling | offline | error | paused
|
||||
pub phase: String,
|
||||
pub last_sync_ms: Option<i64>,
|
||||
pub error: Option<String>,
|
||||
pub pending_writes: u32,
|
||||
/// The line the progress screen and the account chip show, when there is one.
|
||||
pub message: Option<String>,
|
||||
/// Whatever fill is running: the first sync's metadata crawl, and then the body cache behind
|
||||
/// it. Both zero when there is nothing left to bring in.
|
||||
pub hydrated: u32,
|
||||
pub total: u32,
|
||||
/// How far back the mirror actually reaches, which is not the same as the window setting until
|
||||
/// the first sync has finished.
|
||||
pub oldest_ms: Option<i64>,
|
||||
}
|
||||
|
||||
impl SyncStatus {
|
||||
pub fn idle(account_id: &str) -> Self {
|
||||
SyncStatus {
|
||||
account_id: account_id.to_string(),
|
||||
phase: "idle".to_string(),
|
||||
last_sync_ms: None,
|
||||
error: None,
|
||||
pending_writes: 0,
|
||||
message: None,
|
||||
hydrated: 0,
|
||||
total: 0,
|
||||
oldest_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of the `auth` event.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthEvent {
|
||||
pub ok: bool,
|
||||
pub error: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub email: Option<String>,
|
||||
/// The user closed the consent browser rather than anything going wrong. Still not `ok`,
|
||||
/// because no account arrived, but changing your mind is not a failure.
|
||||
pub cancelled: bool,
|
||||
/// What was granted, which may be less than what was asked for.
|
||||
pub granted_scopes: Vec<String>,
|
||||
/// Set when the account was refused because a scope the app cannot work without was withheld.
|
||||
pub missing_required: Vec<String>,
|
||||
}
|
||||
|
||||
/// The flags a triage action sets, and the only thing in this file the provider ever hears about.
|
||||
/// An absent field means unchanged.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FlagPatch {
|
||||
#[serde(default)]
|
||||
pub seen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub starred: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub spam: Option<bool>,
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Taking your mail and your decisions out, and putting the decisions back.
|
||||
//
|
||||
// This is the module that makes "ownership is a feature, not a slogan" true. Mail leaves as mbox,
|
||||
// which every mail client on earth can read, and the decisions leave as the journal itself, which
|
||||
// is the same thing the backup store carries and the same thing another device absorbs. There is no
|
||||
// export format invented here: the point of exporting is that somebody else can read it, and a
|
||||
// shape nobody else knows is not an export.
|
||||
//
|
||||
// Nothing here asks the provider for anything. An export is of what is on the device, which is what
|
||||
// the storage window decided, and saying so is more honest than quietly downloading a mailbox.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::state;
|
||||
|
||||
/// Where an export lands. The downloads directory, because that is where a person looks for a file
|
||||
/// they asked an app to make, and because writing into the app's own data directory would put the
|
||||
/// export somewhere uninstalling deletes.
|
||||
fn downloads(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.download_dir()
|
||||
.or_else(|_| app.path().home_dir())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn stamp() -> String {
|
||||
chrono::Local::now().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Mail, as mbox
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The mboxo escape, which is the one every reader agrees on: a body line that begins with `From `
|
||||
/// gains a `>`, because that sequence at the start of a line is what separates one message from the
|
||||
/// next and a message quoting an email header would otherwise split in two.
|
||||
fn escape_from_lines(raw: &[u8], out: &mut Vec<u8>) {
|
||||
let mut at_line_start = true;
|
||||
let mut i = 0;
|
||||
while i < raw.len() {
|
||||
if at_line_start && raw[i..].starts_with(b"From ") {
|
||||
out.push(b'>');
|
||||
}
|
||||
out.push(raw[i]);
|
||||
at_line_start = raw[i] == b'\n';
|
||||
i += 1;
|
||||
}
|
||||
if !out.ends_with(b"\n") {
|
||||
out.push(b'\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// One message as an mbox entry: the separator line, then the message, then a blank line.
|
||||
///
|
||||
/// The separator carries the envelope sender and a date in asctime form, which is what the format
|
||||
/// asks for and what readers parse. A message with no raw bytes is skipped rather than written as
|
||||
/// an empty entry, because the mirror keeps headers for messages whose bodies it never fetched and
|
||||
/// an entry with no message in it is worse than an absence.
|
||||
fn mbox_entry(from: &str, date_ms: i64, raw: &[u8], out: &mut Vec<u8>) {
|
||||
let when = chrono::DateTime::from_timestamp_millis(date_ms)
|
||||
.unwrap_or_default()
|
||||
.format("%a %b %e %H:%M:%S %Y");
|
||||
let sender = if from.is_empty() { "MAILER-DAEMON" } else { from };
|
||||
out.extend_from_slice(format!("From {sender} {when}\n").as_bytes());
|
||||
escape_from_lines(raw, out);
|
||||
out.push(b'\n');
|
||||
}
|
||||
|
||||
fn write_mbox(conn: &Connection, path: &PathBuf) -> Result<u32, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT m.from_address, m.date_ms, b.raw FROM messages m
|
||||
JOIN bodies b ON b.message_id = m.id
|
||||
WHERE b.raw IS NOT NULL
|
||||
ORDER BY m.date_ms ASC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, Vec<u8>>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut file = fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
let mut written = 0u32;
|
||||
for row in rows {
|
||||
let (from, date_ms, raw) = row.map_err(|e| e.to_string())?;
|
||||
let mut entry = Vec::with_capacity(raw.len() + 96);
|
||||
mbox_entry(&from, date_ms, &raw, &mut entry);
|
||||
file.write_all(&entry).map_err(|e| e.to_string())?;
|
||||
written += 1;
|
||||
}
|
||||
file.sync_all().map_err(|e| e.to_string())?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Every message on the device for one account, as mbox, in the downloads directory.
|
||||
///
|
||||
/// What leaves is what is here: a body the mirror never fetched is not in the file, because the
|
||||
/// export is of the device rather than of the mailbox. The Data section says so beside the button.
|
||||
#[tauri::command(async)]
|
||||
pub fn export_mbox(app: tauri::AppHandle, account_id: String) -> Result<String, String> {
|
||||
let db = app.try_state::<Db>().ok_or("the mirror is not open yet")?;
|
||||
let safe: String = account_id
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let path = downloads(&app)?.join(format!("margin-mail-{safe}-{}.mbox", stamp()));
|
||||
db.with(&account_id, |conn| write_mbox(conn, &path))?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The decisions, as the journal
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Every decision on this device, as the journal, one account per key.
|
||||
///
|
||||
/// The journal rather than the tables, because the tables are a view of it and the journal is what
|
||||
/// another device can absorb without losing the order things happened in. It is the same shape the
|
||||
/// backup store carries, which is deliberate: one export format, one import path, one thing to get
|
||||
/// right.
|
||||
#[tauri::command(async)]
|
||||
pub fn export_state(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let db = app.try_state::<Db>().ok_or("the mirror is not open yet")?;
|
||||
let mut accounts = serde_json::Map::new();
|
||||
|
||||
for account_id in db.on_disk() {
|
||||
let records = db.with(&account_id, |conn| {
|
||||
let mut all = Vec::new();
|
||||
for device in state::merge::devices(conn)? {
|
||||
all.extend(state::merge::export(conn, &device, 0)?);
|
||||
}
|
||||
Ok(all)
|
||||
})?;
|
||||
accounts.insert(
|
||||
account_id,
|
||||
serde_json::to_value(&records).map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
|
||||
let document = serde_json::json!({
|
||||
"kind": "margin-mail-state",
|
||||
"version": 1,
|
||||
"exportedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"accounts": accounts,
|
||||
});
|
||||
let path = downloads(&app)?.join(format!("margin-mail-decisions-{}.json", stamp()));
|
||||
crate::library::atomic_write(
|
||||
&path,
|
||||
serde_json::to_string_pretty(&document)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Reads an export back in.
|
||||
///
|
||||
/// Absorbing rather than replacing, so importing into an account that has been used since is a
|
||||
/// merge and not a loss: the journal's own last-writer-wins settles every key, exactly as it does
|
||||
/// when two devices meet. Importing the same file twice changes nothing, which is the same property
|
||||
/// that lets a device catch up after a month offline.
|
||||
#[tauri::command(async)]
|
||||
pub fn import_state(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
let db = app.try_state::<Db>().ok_or("the mirror is not open yet")?;
|
||||
let raw = fs::read_to_string(&path).map_err(|e| format!("could not read {path}: {e}"))?;
|
||||
let document: serde_json::Value = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
|
||||
|
||||
if document.get("kind").and_then(|k| k.as_str()) != Some("margin-mail-state") {
|
||||
return Err("that file is not a Margin Mail export".to_string());
|
||||
}
|
||||
let accounts = document
|
||||
.get("accounts")
|
||||
.and_then(|a| a.as_object())
|
||||
.ok_or("that export has no accounts in it")?;
|
||||
|
||||
let here = db.on_disk();
|
||||
for (account_id, records) in accounts {
|
||||
// An account that is not connected here has nowhere for its decisions to go, and creating a
|
||||
// database for it would be creating an account nobody added.
|
||||
if !here.contains(account_id) {
|
||||
continue;
|
||||
}
|
||||
let records: Vec<state::journal::Record> =
|
||||
serde_json::from_value(records.clone()).map_err(|e| e.to_string())?;
|
||||
db.with(account_id, |conn| {
|
||||
state::merge::absorb(conn, &records).map(|_| ())
|
||||
})?;
|
||||
}
|
||||
crate::emit_store_changed(&app, "threads state screener");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The keymap
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// The keymap is a file rather than a table of pickers, which is a decision `docs/keyboard.md`
|
||||
/// makes: a person who wants to remap a key wants to see the whole map at once.
|
||||
pub fn keymap_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(crate::library::app_data_dir(app)?.join("keymap.json"))
|
||||
}
|
||||
|
||||
const KEYMAP_TEMPLATE: &str = r#"{
|
||||
"$comment": [
|
||||
"Margin Mail's keymap. Every command and its default key is in docs/keyboard.md, and the",
|
||||
"shortcuts sheet behind ? is generated from whatever is in force, so a remapped key is what",
|
||||
"the buttons print.",
|
||||
"",
|
||||
"One entry per command you want to change: the command's id, and the keys it should answer to.",
|
||||
"A combo is modifiers in cmd+ctrl+alt+shift order and then the key, so cmd+shift+a. Delete this",
|
||||
"file, or press Reset in Settings, to go back to the defaults."
|
||||
],
|
||||
"bindings": {}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The path, creating the file with its explanation the first time somebody asks for it. A settings
|
||||
/// screen that offers to open a file has to be sure there is one to open.
|
||||
#[tauri::command(async)]
|
||||
pub fn keymap_path(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let path = keymap_file(&app)?;
|
||||
if !path.exists() {
|
||||
crate::library::atomic_write(&path, KEYMAP_TEMPLATE.as_bytes())?;
|
||||
}
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Back to the defaults, which is the template rather than an absence: a file that is there and
|
||||
/// empty is easier to understand than one that has gone.
|
||||
#[tauri::command(async)]
|
||||
pub fn keymap_reset(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let path = keymap_file(&app)?;
|
||||
crate::library::atomic_write(&path, KEYMAP_TEMPLATE.as_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,152 @@
|
||||
// The export format, which is the part somebody else's software has to be able to read.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db;
|
||||
use crate::dto::Destination;
|
||||
use crate::state;
|
||||
|
||||
use super::{escape_from_lines, mbox_entry, write_mbox};
|
||||
|
||||
fn open() -> Connection {
|
||||
db::memory().expect("a pair of in-memory databases")
|
||||
}
|
||||
|
||||
fn message(conn: &Connection, id: &str, from: &str, date_ms: i64, raw: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
||||
from_address, subject, hydrated)
|
||||
VALUES (?1, ?1, ?1, ?1, ?2, ?3, 'Subject', 1)",
|
||||
rusqlite::params![id, date_ms, from],
|
||||
)
|
||||
.expect("a message");
|
||||
conn.execute(
|
||||
"INSERT INTO bodies (message_id, raw, fetched_at) VALUES (?1, ?2, 1)",
|
||||
rusqlite::params![id, raw.as_bytes()],
|
||||
)
|
||||
.expect("a body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_line_that_looks_like_a_separator_is_escaped() {
|
||||
// The one thing mbox can get wrong. A message quoting an email header would otherwise split
|
||||
// into two messages in whatever reads the file.
|
||||
let mut out = Vec::new();
|
||||
escape_from_lines(b"Hello\nFrom Ana, quoted\nBye\n", &mut out);
|
||||
assert_eq!(
|
||||
String::from_utf8(out).unwrap(),
|
||||
"Hello\n>From Ana, quoted\nBye\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_from_in_the_middle_of_a_line_is_left_alone() {
|
||||
let mut out = Vec::new();
|
||||
escape_from_lines(b"a note From Ana\n", &mut out);
|
||||
assert_eq!(String::from_utf8(out).unwrap(), "a note From Ana\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_carries_the_separator_the_format_asks_for() {
|
||||
let mut out = Vec::new();
|
||||
mbox_entry("[email protected]", 1_760_000_000_000, b"Subject: Hi\n\nHello\n", &mut out);
|
||||
let text = String::from_utf8(out).unwrap();
|
||||
assert!(text.starts_with("From [email protected] "), "got {text}");
|
||||
assert!(text.contains("\nSubject: Hi\n"));
|
||||
assert!(text.ends_with("\n\n"), "an entry ends with a blank line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_with_no_body_on_the_device_is_not_an_empty_entry() {
|
||||
let conn = open();
|
||||
message(&conn, "m1", "[email protected]", 1_760_000_000_000, "Subject: One\n\nOne\n");
|
||||
// Headers with no body, which is what the mirror holds until a thread is opened.
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
||||
from_address, subject, hydrated)
|
||||
VALUES ('m2', 'm2', 'm2', 'm2', 1760000001000, '[email protected]', 'Two', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("a message with no body");
|
||||
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("out.mbox");
|
||||
let written = write_mbox(&conn, &path).expect("an mbox");
|
||||
|
||||
assert_eq!(written, 1, "the export is of what is on the device");
|
||||
let text = std::fs::read_to_string(&path).expect("the file");
|
||||
assert!(text.contains("From [email protected] "));
|
||||
assert!(!text.contains("[email protected]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decisions_round_trip_through_the_journal() {
|
||||
let from = open();
|
||||
state::write::set_rule(&from, "[email protected]", false, Destination::Inbox, None)
|
||||
.expect("a rule");
|
||||
state::write::add_note(&from, "<t1@example>", "Ask about the oak finish", None)
|
||||
.expect("a note");
|
||||
|
||||
let records: Vec<_> = state::merge::devices(&from)
|
||||
.expect("devices")
|
||||
.into_iter()
|
||||
.flat_map(|device| state::merge::export(&from, &device, 0).expect("export"))
|
||||
.collect();
|
||||
let encoded = state::merge::encode(&records).expect("encode");
|
||||
|
||||
// A second device with nothing on it, which is what an import into a fresh install is.
|
||||
let to = open();
|
||||
state::merge::absorb(&to, &state::merge::decode(&encoded).expect("decode")).expect("absorb");
|
||||
|
||||
assert_eq!(
|
||||
state::read::destination_for(&to, "[email protected]").expect("rule"),
|
||||
Some(Destination::Inbox)
|
||||
);
|
||||
assert_eq!(state::read::notes_on(&to, "<t1@example>").expect("notes").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_the_same_export_twice_changes_nothing() {
|
||||
let from = open();
|
||||
state::write::set_rule(&from, "[email protected]", false, Destination::Feed, None)
|
||||
.expect("a rule");
|
||||
let records: Vec<_> = state::merge::devices(&from)
|
||||
.expect("devices")
|
||||
.into_iter()
|
||||
.flat_map(|device| state::merge::export(&from, &device, 0).expect("export"))
|
||||
.collect();
|
||||
|
||||
let to = open();
|
||||
state::merge::absorb(&to, &records).expect("first");
|
||||
state::merge::absorb(&to, &records).expect("second");
|
||||
|
||||
let rules = state::read::rules(&to, "acct").expect("rules");
|
||||
assert_eq!(rules.len(), 1, "an import is a merge, not an append");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_into_an_account_that_has_moved_on_is_a_merge_rather_than_a_loss() {
|
||||
let from = open();
|
||||
state::write::set_rule(&from, "[email protected]", false, Destination::Feed, None)
|
||||
.expect("the exported rule");
|
||||
let records: Vec<_> = state::merge::devices(&from)
|
||||
.expect("devices")
|
||||
.into_iter()
|
||||
.flat_map(|device| state::merge::export(&from, &device, 0).expect("export"))
|
||||
.collect();
|
||||
|
||||
let to = open();
|
||||
state::write::set_rule(&to, "[email protected]", false, Destination::Inbox, None)
|
||||
.expect("a decision made since");
|
||||
state::merge::absorb(&to, &records).expect("absorb");
|
||||
|
||||
assert_eq!(
|
||||
state::read::destination_for(&to, "[email protected]").expect("kept"),
|
||||
Some(Destination::Inbox),
|
||||
"a decision made here is not lost by importing one made there"
|
||||
);
|
||||
assert_eq!(
|
||||
state::read::destination_for(&to, "[email protected]").expect("arrived"),
|
||||
Some(Destination::Feed)
|
||||
);
|
||||
}
|
||||
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user