Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5843fb4b1f | ||
|
|
ff317dd863 | ||
|
|
72bec20a60 | ||
|
|
ba358c7de6 | ||
|
|
c5cb29fa34 | ||
|
|
cc0f5a9caf | ||
|
|
7f70ab6df7 | ||
|
|
6c972163b1 | ||
|
|
bddf765f77 | ||
|
|
22d913d45e | ||
|
|
4d0d64ff54 | ||
|
|
e42f580510 | ||
|
|
d4c59706d8 | ||
|
|
890bd80d4d | ||
|
|
5c5ebf5e93 | ||
|
|
1c225f6812 | ||
|
|
5068bc07ca
|
||
|
|
0e0415fdbe
|
||
|
|
271468d0e5
|
||
|
|
1d74bbc417
|
||
|
|
a06c9b299d
|
||
|
|
6b28c54e33
|
||
|
|
41321fd05f
|
||
|
|
bd68382921
|
||
|
|
7c5bf48589
|
||
|
|
fe90221c11
|
||
|
|
9f47b94714
|
||
|
|
d7de5619a7
|
||
|
|
0d991e1c71
|
||
|
|
0dfb4bb720
|
||
|
|
c8d7e6f3cc
|
||
|
|
05b205f629 |
No files matched your search
@@ -0,0 +1,63 @@
|
||||
name: Bug Report
|
||||
description: Something behaves, draws or builds wrong.
|
||||
title: "[BUG REPORT] "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: subject
|
||||
attributes:
|
||||
label: App
|
||||
options:
|
||||
- ElyOffice
|
||||
- ElyDoc
|
||||
- ElyTable
|
||||
- ElyShow
|
||||
- ElyPDF
|
||||
- ElyMark
|
||||
- ElyTree
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: where
|
||||
attributes:
|
||||
label: Where
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: A screenshot or a short recording helps most.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What you expected
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: "Version"
|
||||
placeholder: "0.1.8"
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Terminal output or error messages.
|
||||
render: shell
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ely Office
|
||||
url: https://office.elygpui.com
|
||||
about: "Downloads and what each app does."
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Feature Request
|
||||
description: Something new, or something that should do more.
|
||||
title: "[FEATURE REQUEST] "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: subject
|
||||
attributes:
|
||||
label: App
|
||||
options:
|
||||
- ElyOffice
|
||||
- ElyDoc
|
||||
- ElyTable
|
||||
- ElyShow
|
||||
- ElyPDF
|
||||
- ElyMark
|
||||
- ElyTree
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: use
|
||||
attributes:
|
||||
label: What it is for
|
||||
description: The problem, and the moment you need it in.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: behavior
|
||||
attributes:
|
||||
label: How it should work
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Links or screenshots of it elsewhere.
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: Checked
|
||||
options:
|
||||
- label: I searched the open issues for the same request.
|
||||
required: true
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Other
|
||||
description: Questions, docs, ideas, anything else.
|
||||
title: "[Other] "
|
||||
labels: [question]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What's on your mind
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,35 @@
|
||||
<!--
|
||||
Title: start with the type, for example
|
||||
[FEATURE REQUEST] Add dark mode to the settings page
|
||||
[BUG REPORT] Fix a crash when opening an empty file
|
||||
[Other] Update the README install steps
|
||||
-->
|
||||
|
||||
## Type
|
||||
|
||||
- [ ] **[FEATURE REQUEST]**: a new feature or an improvement
|
||||
- [ ] **[BUG REPORT]**: a bug fix
|
||||
- [ ] **[Other]**: docs, refactoring, CI, dependencies or chores
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- One or two sentences. Link the issue: Closes #123 -->
|
||||
|
||||
## Bug details
|
||||
|
||||
<!-- [BUG REPORT] only; delete this section otherwise. -->
|
||||
|
||||
- Steps to reproduce:
|
||||
- Expected:
|
||||
- Actual:
|
||||
- Cause and fix:
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Commands you ran, and what you checked by hand. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The title starts with `[FEATURE REQUEST]`, `[BUG REPORT]` or `[Other]`
|
||||
- [ ] Tests and the build pass locally
|
||||
- [ ] Docs are updated if behaviour changed
|
||||
@@ -1,17 +1,21 @@
|
||||
# A tag builds, packages and uploads all three platforms.
|
||||
# Tags only: no pull request ever reaches the self-hosted runners.
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
macos:
|
||||
runs-on: macos-arm64
|
||||
# A GUI-session agent: daemons see no login keychain.
|
||||
runs-on: [self-hosted, macos-signing]
|
||||
timeout-minutes: 720
|
||||
steps:
|
||||
- name: Build, package and upload for macOS
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
|
||||
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
|
||||
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
|
||||
@@ -20,42 +24,45 @@ jobs:
|
||||
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
|
||||
run: |
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
git -C "$dir" fetch --force --tags origin
|
||||
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||
"$dir/scripts/ci.sh" macos "${{ github.ref_name }}"
|
||||
windows:
|
||||
runs-on: windows
|
||||
runs-on: [self-hosted, ely-windows]
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Build, package and upload for Windows
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ELY_SHARE: /c/Users/zhang/Documents/Github/ElyOffice
|
||||
ELY_PATH: /c/Program Files/CMake/bin:/c/Users/zhang/AppData/Local/Microsoft/WinGet/Packages/Ninja-build.Ninja_Microsoft.Winget.Source_8wekyb3d8bbwe
|
||||
run: |
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
git -C "$dir" fetch --force --tags origin
|
||||
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||
"$dir/scripts/ci.sh" windows "${{ github.ref_name }}"
|
||||
linux:
|
||||
# WSL on the Windows runner keeps a built kernel.
|
||||
runs-on: windows
|
||||
runs-on: [self-hosted, ely-windows]
|
||||
needs: windows
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Build, package and upload for Linux
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WSLENV: ELY_RELEASE_TOKEN
|
||||
run: |
|
||||
wsl -d Ubuntu --exec bash -lc '
|
||||
set -euo pipefail
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
git -C "$dir" fetch --force --tags origin
|
||||
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||
ELY_SHARE=$HOME/Documents/Github/ElyOffice "$dir/scripts/ci.sh" linux "${{ github.ref_name }}"
|
||||
+2
-1
@@ -18,4 +18,5 @@ node_modules/
|
||||
/assets/papirus_icons-master.zip
|
||||
/Gitea.md
|
||||
/.work/
|
||||
/TyporaRef
|
||||
/TyporaRef
|
||||
/oauth.env
|
||||
+5
-8
@@ -1,15 +1,12 @@
|
||||
# Contributing
|
||||
|
||||
## Opening a pull request on git.zacharyzhang.com
|
||||
## Opening a pull request on GitHub
|
||||
|
||||
Forking is disabled on this server. Open pull requests with AGit instead:
|
||||
|
||||
1. Register an account at https://git.zacharyzhang.com
|
||||
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
|
||||
1. Fork https://github.com/ELY-Open-Source/ElyOffice and clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
|
||||
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
|
||||
git clone https://github.com/<your-account>/ElyOffice.git
|
||||
```
|
||||
|
||||
3. To update the pull request, push again with the same `topic`.
|
||||
2. Commit your changes on a branch and push it to your fork.
|
||||
3. Open a pull request against `main`. To update it, push to the same branch.
|
||||
Generated
+498
-30
@@ -290,7 +290,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -301,7 +301,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -410,6 +410,45 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[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 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.21",
|
||||
"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 0.13.2",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -755,6 +794,9 @@ name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
@@ -1648,6 +1690,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
@@ -1706,6 +1754,20 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[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 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1805,7 +1867,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.3",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1938,7 +2000,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "ely-agent"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -1947,7 +2009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-doc"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"csv",
|
||||
@@ -1969,7 +2031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-files"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"dunce",
|
||||
@@ -1986,7 +2048,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=de961eb164c1c88797ea35f0860aba9d18c95e4f#de961eb164c1c88797ea35f0860aba9d18c95e4f"
|
||||
source = "git+https://github.com/ELY-Open-Source/Ely-GPUI-Components?rev=cd365e92ea0c4467c439b8253963461ba0f6d155#cd365e92ea0c4467c439b8253963461ba0f6d155"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -2032,14 +2094,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ipc"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-lok"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"dunce",
|
||||
@@ -2060,16 +2122,54 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"ely-gpui-component",
|
||||
"ely-mail-engine",
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
"embed-resource",
|
||||
"gpui-pre",
|
||||
"jiff",
|
||||
"log",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail-engine"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ely-platform",
|
||||
"form_urlencoded",
|
||||
"getrandom 0.4.3",
|
||||
"html5ever",
|
||||
"imap-proto",
|
||||
"jiff",
|
||||
"keyring",
|
||||
"log",
|
||||
"mail-builder",
|
||||
"mail-parser",
|
||||
"mail-send",
|
||||
"nom 8.0.0",
|
||||
"rcgen",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smtp-proto",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-mark"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
@@ -2094,7 +2194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-office"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2115,7 +2215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ely-files",
|
||||
@@ -2133,7 +2233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf-engine"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"ely-ipc",
|
||||
@@ -2150,10 +2250,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-platform"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"core-foundation 0.10.0",
|
||||
"core-video",
|
||||
"directories",
|
||||
@@ -2189,7 +2290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-show"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2209,7 +2310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-table"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"ely-gpui-component",
|
||||
"ely-lok",
|
||||
@@ -2226,7 +2327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-tree"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ely-files",
|
||||
@@ -2246,7 +2347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ui"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2405,7 +2506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2470,6 +2571,18 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.14.0"
|
||||
@@ -3514,6 +3627,27 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashify"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.3"
|
||||
@@ -3793,6 +3927,15 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "imap-proto"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ccf963d57074747b455398a1763d174da80bcaba6f51e3671a82252b531a68b"
|
||||
dependencies = [
|
||||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.3"
|
||||
@@ -4044,6 +4187,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.21",
|
||||
"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"
|
||||
@@ -4251,6 +4424,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
@@ -4436,6 +4620,38 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-builder"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccbc27cb02a58a311a404769a22f5abf63fe3173b290d80be7c534f9c7023151"
|
||||
|
||||
[[package]]
|
||||
name = "mail-parser"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"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",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"smtp-proto",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -4611,7 +4827,7 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4776,7 +4992,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5320,6 +5536,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -5377,6 +5602,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -5481,6 +5712,16 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -6259,6 +6500,20 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.37.0"
|
||||
@@ -6467,6 +6722,31 @@ dependencies = [
|
||||
"lru",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.12.0"
|
||||
@@ -6529,6 +6809,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 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
@@ -6539,7 +6828,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6568,6 +6857,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -6577,6 +6878,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.0",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -6639,6 +6967,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -6976,6 +7313,16 @@ 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 = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
@@ -7084,6 +7431,25 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smtp-proto"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142a5a642c6bd7ffd7e1b525ad6a6e9921ccef992dba4663974f8519209a00c1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
@@ -7111,6 +7477,18 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -7127,7 +7505,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7377,6 +7755,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -7478,7 +7867,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7643,6 +8032,42 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"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.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -7828,7 +8253,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8092,6 +8517,12 @@ dependencies = [
|
||||
"sval_serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -8369,6 +8800,15 @@ dependencies = [
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -8633,7 +9073,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9288,7 +9728,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"dunce",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2 0.6.4",
|
||||
@@ -9340,6 +9780,24 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[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 7.1.3",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcb"
|
||||
version = "1.7.1"
|
||||
@@ -9412,6 +9870,16 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yazi"
|
||||
version = "0.2.1"
|
||||
@@ -9449,7 +9917,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9663,7 +10131,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+15
-2
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
@@ -19,7 +19,7 @@ jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
dunce = "1"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "de961eb164c1c88797ea35f0860aba9d18c95e4f" }
|
||||
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "cd365e92ea0c4467c439b8253963461ba0f6d155" }
|
||||
env_logger = "0.11"
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||
@@ -39,6 +39,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
html5ever = "0.39"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
ureq = "3"
|
||||
@@ -56,10 +58,21 @@ core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
security-framework = "3.7"
|
||||
objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] }
|
||||
imap-proto = "0.17"
|
||||
nom = "8"
|
||||
mail-builder = { version = "1.0", default-features = false }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12"] }
|
||||
rcgen = "0.14"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
tokio = { version = "1", features = ["net", "time", "io-util", "rt", "macros"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
|
||||
ely-files = { path = "crates/ely-files" }
|
||||
ely-ipc = { path = "crates/ely-ipc" }
|
||||
ely-lok = { path = "crates/ely-lok" }
|
||||
ely-mail-engine = { path = "crates/ely-mail-engine" }
|
||||
ely-office = { path = "crates/ely-office" }
|
||||
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
|
||||
ely-platform = { path = "crates/ely-platform" }
|
||||
|
||||
@@ -22,12 +22,12 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
|
||||
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
|
||||
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
|
||||
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 mail-send、mail-parser、mail-builder(IMAP 与 POP3 由引擎自写),加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
|
||||
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
|
||||
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
|
||||
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。邮件协议 crate、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
|
||||
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
|
||||
|
||||
@@ -375,16 +375,18 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
|
||||
|
||||
### 12.1 定位与后端
|
||||
|
||||
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
|
||||
提供多账号邮件客户端,不建设邮件托管服务。协议与 MIME 用 Rust crate(用户 2026-10-19 定,取代 MailCore 2):IMAP(imap-proto 解析)与 POP3 由 Ely 自写(async-imap 与 async-pop 有数处缺陷,C12.1 审查查出),mail-send 发 SMTP,mail-parser 读 MIME,mail-builder 写 MIME。它们在应用进程的后台任务里运行,不另起工作进程。Ely 负责账号流程、本地存储、搜索、同步队列和界面。
|
||||
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文与写信不用 CEF 与 Electron(用户 2026-10-19 定):正文以 html5ever 解析,净化后按块的子集由 GPUI 绘制(段落、标题、列表、引用、表格、图片、链接、粗斜体与颜色),脚本、定位、浮动与样式表之外的版式不画,不当作完整页面渲染;写信用组件库的 Markdown 文档组件,发出纯文本与由 markdown-rs 生成的 HTML 两部分。
|
||||
|
||||
内置登录:Gmail 与 Microsoft 走 OAuth;iCloud 不给第三方 OAuth,走应用专用密码向导(服务器 `imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)[S10]。OAuth 的客户端 ID 在构建时注入,缺 ID 的构建里该登录写明不可用。
|
||||
|
||||
### 12.2 功能要求
|
||||
|
||||
| 编号 | 功能 | 完成要求 |
|
||||
| --- | --- | --- |
|
||||
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
|
||||
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
|
||||
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程,iCloud 的应用专用密码向导;支持服务商允许的应用密码或自定义认证配置 |
|
||||
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
|
||||
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
|
||||
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
|
||||
@@ -630,7 +632,7 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
|
||||
|
||||
### 17.3 内容渲染
|
||||
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件(§12.1)。
|
||||
|
||||
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
|
||||
|
||||
@@ -766,7 +768,6 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
|
||||
| --- | --- |
|
||||
| MuPDF | 按锁定版本的 AGPL 条款提供对应源码、修改、集成和构建资料;不把公开 UI 仓库当成已完成合规 [S8][S9] |
|
||||
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
|
||||
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
|
||||
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
|
||||
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
|
||||
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
|
||||
@@ -841,7 +842,7 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
|
||||
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
|
||||
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
|
||||
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
|
||||
| 邮件协议 crate | 三端 TLS、OAuth、IMAP 与 POP3 同步、SMTP 发送和 MIME 测试 | 修复或换 crate;换后端须显式修订选型 |
|
||||
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
|
||||
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
|
||||
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
|
||||
@@ -895,8 +896,8 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S9] GNU Affero General Public License 第 3 版原文。
|
||||
`https://www.gnu.org/licenses/agpl-3.0.en.html`
|
||||
|
||||
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
|
||||
`https://etpan.org/mailcore2.html`
|
||||
[S10] Apple,iCloud 邮件用于其他邮件应用的服务器设置。
|
||||
`https://support.apple.com/en-us/102525`
|
||||
|
||||
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
|
||||
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
|
||||
@@ -935,8 +936,5 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S23] Git 官方 COPYING 文件。
|
||||
`https://raw.githubusercontent.com/git/git/master/COPYING`
|
||||
|
||||
[S24] MailCore 2 官方 LICENSE 文件。
|
||||
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
|
||||
|
||||
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
|
||||
`https://support.typora.io/Shortcut-Keys/`
|
||||
@@ -14,7 +14,7 @@
|
||||
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
|
||||
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
|
||||
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
|
||||
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
|
||||
| Mail | mail-send、mail-parser、mail-builder,IMAP(imap-proto 解析)与 POP3 由引擎自写(`crates/ely-mail-engine`,应用进程内的后台任务,无 worker)+ SQLite(FTS5);正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制;写信用 `documents::MarkdownDocument`,发出纯文本与 HTML;Gmail、Microsoft 走 OAuth,iCloud 走应用专用密码 |
|
||||
| Tree | Git CLI,参数数组启动 |
|
||||
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
|
||||
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
```text
|
||||
apps/ 八个应用,各自一个二进制
|
||||
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine 随其任务建
|
||||
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine(邮件协议、同步与本地存储)
|
||||
reference/ 上游源码,只读,不属于本项目,不审查
|
||||
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
|
||||
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
|
||||
@@ -33,14 +33,14 @@ assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns
|
||||
dist/ 打包产物,不入库
|
||||
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
|
||||
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely;mail/sample.eml:带编码主题、分组抄送、纯文本加 HTML、cid 图片与附件的邮件,LF 行尾,测试换成 CRLF)
|
||||
tasks/ TASKS.md 进度真相
|
||||
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
|
||||
```
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `ely-gpui-component`(pin de961eb,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
依赖 `ely-gpui-component`(pin cd365e9,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -149,7 +149,7 @@ packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标
|
||||
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
|
||||
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
|
||||
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
|
||||
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
|
||||
tests/engines/*.sh 引擎探测(LOK、MuPDF);LOK 探测本构建的内核,不挂载官方镜像
|
||||
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
|
||||
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
|
||||
tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型
|
||||
@@ -180,7 +180,7 @@ tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原
|
||||
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
|
||||
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:GitHub API 的最新版本与全部发布的下载总数,加上 Gitea 上迁移前的下载(Gitea 不通时只计 GitHub;都不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
|
||||
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
|
||||
cargo check --workspace
|
||||
@@ -206,7 +206,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
|
||||
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 i18n(原为英文)。
|
||||
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
|
||||
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
|
||||
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
|
||||
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
|
||||
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
|
||||
@@ -302,7 +301,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
|
||||
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
|
||||
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
|
||||
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
|
||||
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。2026-10-19 起:macOS 任务在 `macos-signing` 运行器上跑(Mac mini 上以 LaunchAgent 在登录会话里运行);`macos-arm64` 运行器是以 `UserName` 运行的 LaunchDaemon,其会话的钥匙串搜索列表里没有登录钥匙串,codesign 报“no identity found”(v0.1.8 实测)。两个变量此前未设,0.1.6 至 0.1.8 的 macOS 任务因此在 `ci.sh` 的检查处即失败,已在仓库变量里设好。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
|
||||
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。第 3 轮后(组件库 f1771b3,最后一轮,修完不再审查):无外侧竖线的行里清空首格或末格时写回一个竖线(`| | beta`、`alpha | |`),列数不变;插入点照写回的转义文字报出(键入竖线后不再落在多字节字中间);改过一格后右键邻格照常给出目标;长文(后台解析)里改格后 Tab 等到解析完成再打开下一格。
|
||||
- 2026-10-15(用户报:二级菜单选不到,往子菜单移动时经过下面的项会弹出它们的子菜单,七个应用都如此;用户定照桌面系统的悬停意图做:安全三角加打开与关闭的延时):级联菜单(R6.2,组件库 d4fdb69,`ContextMenu`、`DropdownMenu`、`MenuBar` 共用的面板)。菜单面板挡住其下的元素(原先子菜单被挤回、盖在父菜单上时,被盖住的父菜单行照收悬停);子菜单右侧放不下且左侧放得下即开在所在行的左侧,从右滑入,不盖父菜单的行(两侧都放不下时贴窗口边,仍被面板挡住);指针停在有子菜单的行上 150 毫秒才打开;子菜单开着时,指针落在“上一位置与子菜单近侧上下两角”构成的三角形里的另一行,等 300 毫秒,指针仍在该行才切换,其间到达子菜单即作废;三角形外的行即刻标记。键盘与按下照旧即刻打开。实现在组件 `menus::aim`。第 1 轮后(组件库 5a53c08):指针离开一行(到边距、分隔线、禁用的行或菜单外)即作废其延时,回到该行重新计时;菜单在别处重开即忘掉旧的悬停;图标按钮行同样受三角形保护。
|
||||
- 2026-10-15(用户报:ElyMark 的最近列表清不掉):侧栏“清除最近文件”(R6.3,98692cb)写空 `recent.json`(`ely_files::recent::clear`),失败即提示。会话恢复的标签不把文件加回列表(`Desk::remembered` 预先记下),清空在重启后照旧;显式打开照样加入,已开着的文件(按文件身份,含硬链接,`Sessions::is_open`)只切到该标签,也加入列表。
|
||||
@@ -321,3 +320,9 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 `<?>`);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。
|
||||
- 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。
|
||||
- 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
|
||||
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。账号(C12.5):`ely_mail_engine::Account` 存在 accounts 表的 settings 列(JSON),凭据库名 `account:地址`;删账号先删凭据再删本地数据,本地删除失败即放回凭据,凭据库删不掉时内存里的凭据照留,不动服务端;地址在库里唯一,不分 ASCII 大小写(`Error::Taken`)。应用的库读写在后台、锁后进行,网络任务在应用自带的两线程 tokio 运行时上,结果交回 gpui。iCloud 收发都以完整地址为用户名。删去原文先改名为 `.散列.gone` 留在原处,事务提交后才删,失败即改回;打开库时按库里是否仍有邮件指向它放回或删去(C12.5 第 1 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。IMAP 同步(C12.6):文件夹按 LIST 存为邮箱(角色按 RFC 6154,INBOX 按名、库里不分大小写;有 LIST-EXTENDED 与 SPECIAL-USE 即以 `RETURN (SPECIAL-USE)` 取角色),不再列出的连同邮件删去;EXAMINE 缺 UIDVALIDITY 或 EXISTS 即报错;UIDVALIDITY 变了即清空重建;同一 UID 的 FETCH 回复合并;邮件头全取,正文只取 90 天内(`sync::BODY_DAYS`):先读 BODYSTRUCTURE,无附件即取整封,有附件只取头与正文部分、按原结构重组,附件内容为空、部分号记在 `missing`,附件按需(C12.17),处置为 attachment 的多部分整个按需,related 里不看处置(RFC 2387);新列由结构 v2 升级加上,UIDVALIDITY 不同于所记时只删别的 ULine truncated
|
||||
- 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `<slug>.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。
|
||||
- 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。
|
||||
- 2026-10-19(GitHub #2,R9.1):Impress 在母版视图里 `getParts` 数母版、`getPartName` 给母版名,`getPartInfo` 却读同一序号的幻灯片,母版多于幻灯片时信息里没有 `visible`,ElyShow 每次重读部件都报错;补丁 `0052-lok-impress-master-view-part-info.patch` 让母版视图里取母版页(`client/tests/show.rs` 核对)。`ELY_SHARE` 下发布流程不重建内核:新补丁须先在三台机器的共享引擎上各自重建。GitHub PR #1(R9.2,用户定合并):选区画在图块之上,不透明的选区色盖住文字,`LokView` 画选区时不透明度至多 0.3。
|
||||
- 2026-10-20(GitHub #4,R9.3):整列看不出标题时 Calc 开自动筛选会弹异步询问,worker 当场关掉即在其回调里崩溃;补丁 `0053-lok-calc-autofilter-header-without-asking.patch`:LOK 下不问,首行作标题(同 Excel)。R9.4:arboard 在 Windows 把剪贴板上没有 HTML 读成错误,`ely_platform::clipboard` 先查格式;剪贴板错误的提示写出整条原因。补丁 0049 补上 `doc.hxx`(Linux 的 gcc 编不过;MSVC 与 clang 经别的头文件带入)。Gitea 上另发同名的过渡发布(用户定):0.1.8 及更早的应用在 Gitea 查更新,过渡发布的清单指向 GitHub 的下载,装上的 0.1.9 起从 GitHub 查;Gitea 只为此推上 main 与标签,不再是发布之处。
|
||||
- 2026-10-20(GitHub PR #6,R9.5,用户定合并):ElyTable 的函数候选由 Tab、不带修饰的 Enter 或单击接受(原 Enter 提交半个公式并下移);接受后到对应输入与其修订的事件批次到达前不再提供候选,不能重复接受(`view/formula/completion.rs`);单击按所点的函数名接受。接受与否由 worker 在执行时按内核此刻的候选判定(`worker/complete.rs`,同换页的 `TURN`):界面上回报的候选可能已被其后排队的键入作废;所选的仍在即取它,按键时不在即取菜单首项(名最短者,同菜单的次序),没有候选即照常交按键,单击的已不在即什么也不做。有效性下拉按钮挡鼠标的框至少为按钮的主题高度(默认行比按钮矮)。候选、参数提示、有效性下拉按钮与输入说明挡住滚轮以外的鼠标,按下不落到其下的格;填充与行列拖动在其上松开即取消。PR 的 ElyTable 测试原设空的错误样式,补丁 0017 拒绝该规则,改为 `stop`;空的单格读不出选区文字,改读 E2:F2。
|
||||
@@ -11,8 +11,20 @@ build = "../../packaging/windows/icon.rs"
|
||||
embed-resource.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely-gpui-component.workspace = true
|
||||
ely-mail-engine.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
gpui.workspace = true
|
||||
jiff.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
ely-ui = { workspace = true, features = ["test-support"] }
|
||||
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The add-account dialog (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, InlineMessage},
|
||||
forms::{Input, PasswordInput, Switch},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_mail_engine::{Auth, Error, Security};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _,
|
||||
WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
adding::{Adding, Step},
|
||||
kinds::{APPLE_ACCOUNT, KINDS, Kind},
|
||||
mail::{Mail, text},
|
||||
view::auth,
|
||||
};
|
||||
|
||||
type Edit = fn(&mut Adding, &str, &mut App);
|
||||
|
||||
/// A segmented choice whose change edits the dialog.
|
||||
fn choice(
|
||||
this: &WeakEntity<Mail>,
|
||||
id: &'static str,
|
||||
picked: &str,
|
||||
options: impl IntoIterator<Item = (&'static str, SharedString)>,
|
||||
edit: Edit,
|
||||
) -> SegmentedControl {
|
||||
let this = this.clone();
|
||||
options
|
||||
.into_iter()
|
||||
.fold(
|
||||
SegmentedControl::new(id, picked.to_owned()),
|
||||
|control, (key, label)| control.segment(key, label, None),
|
||||
)
|
||||
.on_change(move |key, _, cx| {
|
||||
this.update(cx, |mail, cx| {
|
||||
if let Some(adding) = &mut mail.adding {
|
||||
edit(adding, key, cx);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn security_key(security: Security) -> &'static str {
|
||||
match security {
|
||||
Security::Tls => "tls",
|
||||
Security::StartTls => "starttls",
|
||||
}
|
||||
}
|
||||
|
||||
fn security_of(key: &str) -> Security {
|
||||
match key {
|
||||
"tls" => Security::Tls,
|
||||
_ => Security::StartTls,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_key(auth: Auth) -> &'static str {
|
||||
match auth {
|
||||
Auth::Password => "password",
|
||||
Auth::Google => "google",
|
||||
Auth::Microsoft => "microsoft",
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn add_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let adding = self.adding.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |mail, cx| mail.close_adding(cx)).ok();
|
||||
}
|
||||
};
|
||||
let kinds = KINDS.map(|kind| {
|
||||
(
|
||||
kind.key(),
|
||||
text(cx, &format!("mail.kind.{}", kind.key()), &[]),
|
||||
)
|
||||
});
|
||||
let kinds = choice(
|
||||
&this,
|
||||
"mail-add-kind",
|
||||
adding.kind.key(),
|
||||
kinds,
|
||||
|adding, key, cx| {
|
||||
if let Some(kind) = Kind::from_key(key) {
|
||||
adding.set_kind(kind, cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
let oauth = adding.auth() != Auth::Password;
|
||||
let unavailable = match adding.auth().provider() {
|
||||
Some(Err(Error::Unavailable(name))) => {
|
||||
Some(text(cx, &format!("mail.add.unavailable.{name}"), &[]))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let cancel = text(cx, "mail.cancel", &[]);
|
||||
let (go_key, go): (&str, fn(&mut Mail, &mut Context<Mail>)) = match &adding.step {
|
||||
Step::Checked { checked, .. } if checked.passed() => ("mail.add.save", Mail::save),
|
||||
_ if oauth => ("mail.add.sign_in", Mail::sign_in),
|
||||
_ => ("mail.add.check", Mail::check),
|
||||
};
|
||||
let go_label = text(cx, go_key, &[("method", &auth(adding.auth(), cx))]);
|
||||
let busy = adding.busy();
|
||||
let blocked = busy || (unavailable.is_some() && oauth);
|
||||
let dialog = Dialog::new("mail-add", text(cx, "mail.add.title", &[]), dismiss)
|
||||
.child(kinds)
|
||||
.children(
|
||||
unavailable
|
||||
.map(|words| Banner::new("mail-add-unavailable", Severity::Danger, words)),
|
||||
)
|
||||
.child(self.add_fields(adding, &this, cx))
|
||||
.children(status(adding, cx))
|
||||
.action(move |close| {
|
||||
Button::new("mail-add-cancel", cancel)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action(move |_| {
|
||||
Button::new("mail-add-go", go_label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.loading(busy)
|
||||
.disabled(blocked)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, go).ok();
|
||||
})
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
|
||||
fn add_fields(&self, adding: &Adding, this: &WeakEntity<Mail>, cx: &App) -> AnyElement {
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let field = |input| div().w_full().child(Input::new(input));
|
||||
let fields = &adding.fields;
|
||||
let password = div().w_full().child(PasswordInput::new(&fields.password));
|
||||
match adding.kind {
|
||||
Kind::Gmail | Kind::Microsoft => DescriptionList::new()
|
||||
.item(label("mail.add.address_hint"), field(&fields.address))
|
||||
.into_any_element(),
|
||||
Kind::ICloud => {
|
||||
let open = Button::new("mail-add-apple", label("mail.icloud.open"))
|
||||
.on_click(|_, _, cx| cx.open_url(APPLE_ACCOUNT));
|
||||
let rows = DescriptionList::new()
|
||||
.item(label("mail.add.address"), field(&fields.address))
|
||||
.item(label("mail.icloud.password"), password);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(label("mail.icloud.steps"))
|
||||
.child(div().child(open))
|
||||
.child(rows)
|
||||
.into_any_element()
|
||||
}
|
||||
Kind::Imap | Kind::Pop => {
|
||||
let auths = [Auth::Password, Auth::Google, Auth::Microsoft]
|
||||
.map(|each| (auth_key(each), auth(each, cx)));
|
||||
let methods = choice(
|
||||
this,
|
||||
"mail-add-auth",
|
||||
auth_key(adding.auth),
|
||||
auths,
|
||||
|adding, key, _| {
|
||||
adding.stop();
|
||||
adding.step = Step::Editing;
|
||||
adding.auth = match key {
|
||||
"google" => Auth::Google,
|
||||
"microsoft" => Auth::Microsoft,
|
||||
_ => Auth::Password,
|
||||
};
|
||||
},
|
||||
);
|
||||
let securities = |outgoing: bool, picked: Security| {
|
||||
let options = [Security::Tls, Security::StartTls].map(|each| {
|
||||
(
|
||||
security_key(each),
|
||||
label(&format!("mail.security.{}", security_key(each))),
|
||||
)
|
||||
});
|
||||
let id = if outgoing {
|
||||
"mail-add-out-security"
|
||||
} else {
|
||||
"mail-add-in-security"
|
||||
};
|
||||
let edit: Edit = if outgoing {
|
||||
|adding, key, cx| adding.set_security(true, security_of(key), cx)
|
||||
} else {
|
||||
|adding, key, cx| adding.set_security(false, security_of(key), cx)
|
||||
};
|
||||
choice(this, id, security_key(picked), options, edit)
|
||||
};
|
||||
let mut rows = DescriptionList::new()
|
||||
.item(label("mail.add.address"), field(&fields.address))
|
||||
.item(label("mail.auth.title"), methods);
|
||||
if adding.auth == Auth::Password {
|
||||
rows = rows
|
||||
.item(label("mail.add.user"), field(&fields.user))
|
||||
.item(label("mail.add.password"), password);
|
||||
}
|
||||
if adding.kind == Kind::Pop {
|
||||
let this = this.clone();
|
||||
let keep = Switch::new("mail-add-keep", adding.keep)
|
||||
.name(label("mail.add.keep"))
|
||||
.on_change(move |on, _, cx| {
|
||||
this.update(cx, |mail, cx| {
|
||||
if let Some(adding) = &mut mail.adding {
|
||||
adding.set_keep(on);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
rows = rows.item(label("mail.add.keep"), keep);
|
||||
}
|
||||
rows.item(label("mail.add.incoming"), field(&fields.incoming))
|
||||
.item(label("mail.add.port"), field(&fields.incoming_port))
|
||||
.item(
|
||||
label("mail.add.security"),
|
||||
securities(false, adding.incoming_security),
|
||||
)
|
||||
.item(label("mail.add.outgoing"), field(&fields.outgoing))
|
||||
.item(label("mail.add.port"), field(&fields.outgoing_port))
|
||||
.item(
|
||||
label("mail.add.security"),
|
||||
securities(true, adding.outgoing_security),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each server's answer, or what is under way.
|
||||
fn status(adding: &Adding, cx: &App) -> Option<AnyElement> {
|
||||
let line =
|
||||
|severity, words: SharedString| InlineMessage::new(severity, words).into_any_element();
|
||||
let said = |key: &str, result: &ely_mail_engine::Result<()>| match result {
|
||||
Ok(()) => line(
|
||||
Severity::Success,
|
||||
text(cx, key, &[("result", &text(cx, "mail.check.passed", &[]))]),
|
||||
),
|
||||
Err(error) => line(
|
||||
Severity::Danger,
|
||||
text(cx, key, &[("result", &failure(error, cx))]),
|
||||
),
|
||||
};
|
||||
Some(match &adding.step {
|
||||
Step::Editing => return None,
|
||||
Step::Signing => line(Severity::Info, text(cx, "mail.add.signing", &[])),
|
||||
Step::Checking => line(Severity::Info, text(cx, "mail.add.checking", &[])),
|
||||
Step::Saving => line(Severity::Info, text(cx, "mail.add.saving", &[])),
|
||||
Step::Failed(words) => line(Severity::Danger, words.clone()),
|
||||
Step::Checked { checked, .. } => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(said("mail.check.incoming", &checked.incoming))
|
||||
.child(said("mail.check.outgoing", &checked.outgoing))
|
||||
.into_any_element(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A server's refusal in the reader's language, its words kept.
|
||||
pub fn failure(error: &Error, cx: &App) -> SharedString {
|
||||
let (key, detail) = match error {
|
||||
Error::Connect(err) => ("mail.error.connect", err.to_string()),
|
||||
Error::Tls(detail) => ("mail.error.tls_failed", detail.clone()),
|
||||
Error::Rejected(detail) => ("mail.error.rejected", detail.clone()),
|
||||
Error::Server(detail) => ("mail.error.server", detail.clone()),
|
||||
Error::Protocol(detail) => ("mail.error.protocol", detail.clone()),
|
||||
Error::Timeout(step) => ("mail.error.timeout", (*step).to_owned()),
|
||||
other => ("mail.error.other", other.to_string()),
|
||||
};
|
||||
text(cx, key, &[("detail", &detail)])
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The add-account dialog's fields and steps (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::forms::{InputEvent, TextInput};
|
||||
use ely_mail_engine::{Account, Auth, Checked, Security, Server};
|
||||
use gpui::{App, AppContext as _, Context, Entity, SharedString, Subscription, Window};
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::{
|
||||
kinds::{self, Kind},
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
pub struct Fields {
|
||||
pub address: Entity<TextInput>,
|
||||
pub user: Entity<TextInput>,
|
||||
pub password: Entity<TextInput>,
|
||||
pub incoming: Entity<TextInput>,
|
||||
pub incoming_port: Entity<TextInput>,
|
||||
pub outgoing: Entity<TextInput>,
|
||||
pub outgoing_port: Entity<TextInput>,
|
||||
}
|
||||
|
||||
pub enum Step {
|
||||
Editing,
|
||||
/// The browser holds the provider's page.
|
||||
Signing,
|
||||
Checking,
|
||||
/// `kept` goes to the vault: a password or refresh token.
|
||||
Checked {
|
||||
account: Account,
|
||||
kept: String,
|
||||
checked: Checked,
|
||||
},
|
||||
Failed(SharedString),
|
||||
Saving,
|
||||
}
|
||||
|
||||
pub struct Adding {
|
||||
pub kind: Kind,
|
||||
pub fields: Fields,
|
||||
pub incoming_security: Security,
|
||||
pub outgoing_security: Security,
|
||||
/// Typed-in accounts choose; providers fix it.
|
||||
pub auth: Auth,
|
||||
/// POP3 leaves a copy on the server (§12.3).
|
||||
pub keep: bool,
|
||||
pub step: Step,
|
||||
/// The ports last filled in; any other was typed.
|
||||
filled: [String; 2],
|
||||
/// The request awaited; any other answer is dropped.
|
||||
pub asked: Option<u64>,
|
||||
pub task: Option<AbortHandle>,
|
||||
_edits: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Drop for Adding {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Adding {
|
||||
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Mail>) -> Self {
|
||||
let mut field = |key: &str, masked: bool, cx: &mut Context<Mail>| {
|
||||
let words = text(cx, key, &[]);
|
||||
cx.new(|cx| {
|
||||
let input = TextInput::new(window, cx)
|
||||
.placeholder(words.clone())
|
||||
.label(words);
|
||||
if masked { input.masked() } else { input }
|
||||
})
|
||||
};
|
||||
let address = field("mail.add.address", false, cx);
|
||||
let user = field("mail.add.user", false, cx);
|
||||
let password = field("mail.add.password", true, cx);
|
||||
let incoming = field("mail.add.incoming", false, cx);
|
||||
let incoming_port = field("mail.add.port", false, cx);
|
||||
let outgoing = field("mail.add.outgoing", false, cx);
|
||||
let outgoing_port = field("mail.add.port", false, cx);
|
||||
let fields = Fields {
|
||||
address,
|
||||
user,
|
||||
password,
|
||||
incoming,
|
||||
incoming_port,
|
||||
outgoing,
|
||||
outgoing_port,
|
||||
};
|
||||
let edits = [
|
||||
&fields.address,
|
||||
&fields.user,
|
||||
&fields.password,
|
||||
&fields.incoming,
|
||||
&fields.incoming_port,
|
||||
&fields.outgoing,
|
||||
&fields.outgoing_port,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
cx.subscribe(field, |mail: &mut Mail, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Changed {
|
||||
mail.edited(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut adding = Adding {
|
||||
kind,
|
||||
fields,
|
||||
incoming_security: Security::Tls,
|
||||
outgoing_security: Security::StartTls,
|
||||
auth: Auth::Password,
|
||||
keep: true,
|
||||
step: Step::Editing,
|
||||
filled: Default::default(),
|
||||
asked: None,
|
||||
task: None,
|
||||
_edits: edits,
|
||||
};
|
||||
adding.set_ports(cx);
|
||||
adding
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
self.asked = None;
|
||||
}
|
||||
|
||||
/// A new kind keeps what was typed.
|
||||
pub fn set_kind(&mut self, kind: Kind, cx: &mut App) {
|
||||
self.stop();
|
||||
self.kind = kind;
|
||||
self.step = Step::Editing;
|
||||
self.set_ports(cx);
|
||||
}
|
||||
|
||||
pub fn set_security(&mut self, outgoing: bool, security: Security, cx: &mut App) {
|
||||
self.stop();
|
||||
self.step = Step::Editing;
|
||||
if outgoing {
|
||||
self.outgoing_security = security;
|
||||
} else {
|
||||
self.incoming_security = security;
|
||||
}
|
||||
self.set_ports(cx);
|
||||
}
|
||||
|
||||
pub fn set_keep(&mut self, keep: bool) {
|
||||
self.stop();
|
||||
self.step = Step::Editing;
|
||||
self.keep = keep;
|
||||
}
|
||||
|
||||
/// The usual ports, unless one was typed.
|
||||
fn set_ports(&mut self, cx: &mut App) {
|
||||
let ports = [
|
||||
kinds::port(Some(self.kind.protocol(self.keep)), self.incoming_security),
|
||||
kinds::port(None, self.outgoing_security),
|
||||
];
|
||||
let fields = [&self.fields.incoming_port, &self.fields.outgoing_port];
|
||||
for ((field, port), filled) in fields.into_iter().zip(ports).zip(&mut self.filled) {
|
||||
let typed = field.read(cx).text().trim().to_owned();
|
||||
if typed.is_empty() || typed == *filled {
|
||||
*filled = port.to_string();
|
||||
field.update(cx, |field, cx| field.set_text(filled.clone(), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-in this kind uses.
|
||||
pub fn auth(&self) -> Auth {
|
||||
match self.kind.known() {
|
||||
Some((auth, ..)) => auth,
|
||||
None => self.auth,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self, field: &Entity<TextInput>, cx: &App) -> String {
|
||||
field.read(cx).text().trim().to_owned()
|
||||
}
|
||||
|
||||
/// The servers as known or typed.
|
||||
pub fn servers(&self, cx: &App) -> Result<(Server, Server), SharedString> {
|
||||
if let Some((_, incoming, outgoing)) = self.kind.known() {
|
||||
return Ok((incoming, outgoing));
|
||||
}
|
||||
let server = |host: &Entity<TextInput>, port: &Entity<TextInput>, security| {
|
||||
let host = self.read(host, cx);
|
||||
if host.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_host", &[]));
|
||||
}
|
||||
let port = self.read(port, cx);
|
||||
let port = port
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|port| *port > 0)
|
||||
.ok_or_else(|| text(cx, "mail.add.bad_port", &[("port", &port)]))?;
|
||||
Ok(Server {
|
||||
host,
|
||||
port,
|
||||
security,
|
||||
})
|
||||
};
|
||||
Ok((
|
||||
server(
|
||||
&self.fields.incoming,
|
||||
&self.fields.incoming_port,
|
||||
self.incoming_security,
|
||||
)?,
|
||||
server(
|
||||
&self.fields.outgoing,
|
||||
&self.fields.outgoing_port,
|
||||
self.outgoing_security,
|
||||
)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The account the fields name; `signed` is an OAuth address.
|
||||
pub fn account(&self, signed: Option<&str>, cx: &App) -> Result<Account, SharedString> {
|
||||
let address = match signed {
|
||||
Some(address) => address.to_owned(),
|
||||
None => self.read(&self.fields.address, cx),
|
||||
};
|
||||
if address.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_address", &[]));
|
||||
}
|
||||
let user = match (signed, self.kind) {
|
||||
(None, Kind::Imap | Kind::Pop) => self.read(&self.fields.user, cx),
|
||||
_ => address.clone(),
|
||||
};
|
||||
if user.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_user", &[]));
|
||||
}
|
||||
let (incoming, outgoing) = self.servers(cx)?;
|
||||
let account = Account {
|
||||
address,
|
||||
user,
|
||||
auth: self.auth(),
|
||||
protocol: self.kind.protocol(self.keep),
|
||||
incoming,
|
||||
outgoing,
|
||||
};
|
||||
account
|
||||
.valid()
|
||||
.map_err(|err| text(cx, "mail.add.invalid", &[("error", &err.to_string())]))?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub fn password(&self, cx: &App) -> Result<String, SharedString> {
|
||||
let password = self.fields.password.read(cx).text().to_owned();
|
||||
if password.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_password", &[]));
|
||||
}
|
||||
Ok(password)
|
||||
}
|
||||
|
||||
pub fn busy(&self) -> bool {
|
||||
matches!(self.step, Step::Signing | Step::Checking | Step::Saving)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! What the window has open, and its list.
|
||||
|
||||
use std::{collections::HashMap, rc::Rc};
|
||||
|
||||
use ely_gpui_component::mail::Mail as Row;
|
||||
use ely_mail_engine::{AccountId, Filter, MailboxId, Role, Scope, Summary};
|
||||
use gpui::{Context, SharedString};
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
pub type Unread = HashMap<MailboxId, u32>;
|
||||
|
||||
/// What the middle of the window shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Open {
|
||||
Inboxes,
|
||||
Mailbox(AccountId, MailboxId),
|
||||
/// The account's settings and sync.
|
||||
Account(AccountId),
|
||||
}
|
||||
|
||||
impl Open {
|
||||
pub fn key(self) -> SharedString {
|
||||
match self {
|
||||
Open::Inboxes => "inboxes".into(),
|
||||
Open::Mailbox(_, mailbox) => format!("box-{}", mailbox.0).into(),
|
||||
Open::Account(id) => format!("account-{}", id.0).into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account(self) -> Option<AccountId> {
|
||||
match self {
|
||||
Open::Inboxes => None,
|
||||
Open::Mailbox(id, _) | Open::Account(id) => Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(self) -> Option<Scope> {
|
||||
match self {
|
||||
Open::Inboxes => Some(Scope::Inboxes),
|
||||
Open::Mailbox(_, mailbox) => Some(Scope::Mailbox(mailbox)),
|
||||
Open::Account(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows read, and where each key sits.
|
||||
pub struct Indexed {
|
||||
rows: Vec<Row>,
|
||||
at: HashMap<SharedString, usize>,
|
||||
}
|
||||
|
||||
/// Off the UI thread: a long list takes a while.
|
||||
pub fn index(rows: Vec<Row>) -> Indexed {
|
||||
let at = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, row)| (row.key.clone(), ix))
|
||||
.collect();
|
||||
Indexed { rows, at }
|
||||
}
|
||||
|
||||
/// The open list and the messages picked in it.
|
||||
#[derive(Default)]
|
||||
pub struct Listing {
|
||||
/// Numbers reads; an older one is dropped.
|
||||
pub read: u64,
|
||||
/// None until read, or when nothing lists mail.
|
||||
pub mails: Option<Result<Rc<Vec<Row>>, String>>,
|
||||
at: HashMap<SharedString, usize>,
|
||||
pub selected: Vec<SharedString>,
|
||||
/// The one message picked, for the reading pane.
|
||||
pub chosen: Option<Row>,
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
pub fn show(&mut self, mails: Option<Result<Indexed, String>>) {
|
||||
self.at = HashMap::new();
|
||||
self.mails = mails.map(|read| {
|
||||
read.map(|indexed| {
|
||||
self.at = indexed.at;
|
||||
Rc::new(indexed.rows)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps the picks still listed.
|
||||
fn pick(&mut self, keys: Vec<SharedString>) {
|
||||
let Some(Ok(mails)) = &self.mails else {
|
||||
self.selected.clear();
|
||||
self.chosen = None;
|
||||
return;
|
||||
};
|
||||
self.selected = keys
|
||||
.into_iter()
|
||||
.filter(|key| self.at.contains_key(key))
|
||||
.collect();
|
||||
self.chosen = match &self.selected[..] {
|
||||
[key] => Some(mails[self.at[key]].clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn open_box(&mut self, open: Open, cx: &mut Context<Self>) {
|
||||
log::info!("mail: open {}", open.key());
|
||||
if open != self.open {
|
||||
self.open = open;
|
||||
self.listing.show(None);
|
||||
self.listing.pick(Vec::new());
|
||||
}
|
||||
self.read_mail(cx);
|
||||
self.notify(cx);
|
||||
}
|
||||
|
||||
pub fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
log::info!("mail: filter {filter:?}");
|
||||
self.filter = filter;
|
||||
self.read_mail(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn select(&mut self, keys: &[SharedString], cx: &mut Context<Self>) {
|
||||
self.listing.pick(keys.to_vec());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Unread counts, and the open list when it lists mail.
|
||||
pub fn read_mail(&mut self, cx: &mut Context<Self>) {
|
||||
self.listing.read += 1;
|
||||
let (read, scope, filter) = (self.listing.read, self.open.scope(), self.filter);
|
||||
let none = (
|
||||
text(cx, "mail.no_sender", &[]),
|
||||
text(cx, "mail.no_subject", &[]),
|
||||
);
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| {
|
||||
let rows = match scope {
|
||||
Some(scope) => {
|
||||
let list = store.list(scope, filter)?;
|
||||
Some(index(
|
||||
list.into_iter().map(|each| row(each, &none)).collect(),
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok((rows, store.unread()?))
|
||||
},
|
||||
move |mail, read_now, _| {
|
||||
if mail.listing.read != read {
|
||||
return log::info!("mail: list read {read} ended after a newer one");
|
||||
}
|
||||
match read_now {
|
||||
Ok((rows, unread)) => {
|
||||
mail.unread = unread;
|
||||
mail.listing.show(rows.map(Ok));
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("mail: list read {read} failed: {error}");
|
||||
mail.listing.show(scope.map(|_| Err(error)));
|
||||
}
|
||||
}
|
||||
let keys = std::mem::take(&mut mail.listing.selected);
|
||||
mail.listing.pick(keys);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Unread mail in every account's inbox.
|
||||
pub fn inboxes_unread(&self) -> u32 {
|
||||
self.syncs
|
||||
.values()
|
||||
.flat_map(|syncing| &syncing.folders)
|
||||
.filter(|folder| folder.role == Some(Role::Inbox))
|
||||
.filter_map(|folder| self.unread.get(&folder.id))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// `none` names a missing sender, then subject.
|
||||
fn row(summary: Summary, none: &(SharedString, SharedString)) -> Row {
|
||||
let key = summary.id.0.to_string();
|
||||
let from = summary.from.first().map(|from| match &from.name {
|
||||
Some(name) if !name.trim().is_empty() => name.clone(),
|
||||
_ => from.email.clone(),
|
||||
});
|
||||
let from: SharedString = from.map_or_else(|| none.0.clone(), Into::into);
|
||||
let subject: SharedString = summary.subject.map_or_else(|| none.1.clone(), Into::into);
|
||||
let row = match summary.date.map(jiff::Timestamp::from_second) {
|
||||
Some(Ok(at)) => Row::new(key, from, subject, at),
|
||||
Some(Err(error)) => {
|
||||
log::warn!("mail: message {} date unshown: {error}", summary.id.0);
|
||||
Row::undated(key, from, subject)
|
||||
}
|
||||
None => Row::undated(key, from, subject),
|
||||
};
|
||||
let row = row.unread(!summary.seen).starred(summary.flagged);
|
||||
match summary.attachments {
|
||||
Some(count) => row.attachments(count as usize),
|
||||
None => row,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Sync now, pause, and folders kept whole.
|
||||
|
||||
use ely_gpui_component::{buttons::Button, feedback::Banner, forms::Switch, primitives::Severity};
|
||||
use ely_mail_engine::{AccountId, Protocol};
|
||||
use gpui::{
|
||||
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div,
|
||||
};
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
impl Mail {
|
||||
pub fn sync_buttons(&self, id: AccountId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let running = self.syncs.get(&id).is_some_and(|each| each.task.is_some());
|
||||
let this = cx.weak_entity();
|
||||
let now = Button::new("mail-sync-now", text(cx, "mail.sync.now", &[]))
|
||||
.disabled(running || self.paused)
|
||||
.on_click({
|
||||
let this = this.clone();
|
||||
move |_, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.sync(id, cx)).ok();
|
||||
}
|
||||
});
|
||||
let key = if self.paused {
|
||||
"mail.sync.resume"
|
||||
} else {
|
||||
"mail.sync.pause"
|
||||
};
|
||||
let pause = Button::new("mail-sync-pause", text(cx, key, &[])).on_click(move |_, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.set_paused(!mail.paused, cx))
|
||||
.ok();
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(now)
|
||||
.child(pause)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub fn paused_banner(&self, cx: &Context<Self>) -> Option<Banner> {
|
||||
self.paused.then(|| {
|
||||
Banner::new(
|
||||
"mail-sync-paused",
|
||||
Severity::Info,
|
||||
text(cx, "mail.sync.paused", &[]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// One switch per IMAP folder.
|
||||
pub fn offline_switches(
|
||||
&self,
|
||||
id: AccountId,
|
||||
protocol: Protocol,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
if protocol != Protocol::Imap {
|
||||
return None;
|
||||
}
|
||||
let folders = &self.syncs.get(&id)?.folders;
|
||||
if folders.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
let switches = folders.iter().map(|folder| {
|
||||
let mailbox = folder.id;
|
||||
let this = this.clone();
|
||||
let label = text(cx, "mail.sync.offline", &[("name", &folder.name)]);
|
||||
let switch = Switch::new(("mail-offline", mailbox.0 as u64), folder.offline)
|
||||
.label(label)
|
||||
.on_change(move |on, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.set_offline(id, mailbox, on, cx))
|
||||
.ok();
|
||||
});
|
||||
div()
|
||||
.debug_selector(move || format!("mail-offline-{}", mailbox.0))
|
||||
.child(switch)
|
||||
});
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(switches)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Checking, signing in and adding an account (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{Account, Checked, Error, Kept, Secret, oauth};
|
||||
use gpui::{Context, SharedString, Window};
|
||||
|
||||
use crate::{
|
||||
adding::{Adding, Step},
|
||||
kinds::Kind,
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
impl Mail {
|
||||
pub fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.adding.is_none() && self.removing.is_none() {
|
||||
self.adding = Some(Adding::new(Kind::Gmail, window, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close_adding(&mut self, cx: &mut Context<Self>) {
|
||||
self.adding = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A field changed: an earlier check no longer holds.
|
||||
pub fn edited(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &mut self.adding else {
|
||||
return;
|
||||
};
|
||||
if !matches!(adding.step, Step::Editing | Step::Saving) {
|
||||
adding.stop();
|
||||
adding.step = Step::Editing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a request for the open dialog.
|
||||
fn ask(&mut self) -> Option<u64> {
|
||||
let adding = self.adding.as_mut()?;
|
||||
adding.stop();
|
||||
self.asked += 1;
|
||||
adding.asked = Some(self.asked);
|
||||
Some(self.asked)
|
||||
}
|
||||
|
||||
/// The open dialog, if it awaits `asked`.
|
||||
fn awaiting(&mut self, asked: u64) -> Option<&mut Adding> {
|
||||
self.adding
|
||||
.as_mut()
|
||||
.filter(|adding| adding.asked == Some(asked))
|
||||
}
|
||||
|
||||
fn halt(&mut self, words: SharedString, cx: &mut Context<Self>) {
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.stop();
|
||||
adding.step = Step::Failed(words);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The words when the address is already here.
|
||||
fn taken(&self, address: &str, cx: &Context<Self>) -> Option<SharedString> {
|
||||
self.accounts()
|
||||
.iter()
|
||||
.any(|(_, account)| account.address.eq_ignore_ascii_case(address))
|
||||
.then(|| text(cx, "mail.add.taken", &[("address", address)]))
|
||||
}
|
||||
|
||||
/// Checks a typed-in password with both servers.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &self.adding else { return };
|
||||
let ready = adding
|
||||
.account(None, cx)
|
||||
.and_then(|account| Ok((account, adding.password(cx)?)));
|
||||
match ready {
|
||||
Ok((account, password)) => {
|
||||
self.probe(account, password.clone(), Secret::Password(password), cx)
|
||||
}
|
||||
Err(words) => self.halt(words, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs in with `secret`; `kept` goes to the vault after.
|
||||
fn probe(&mut self, account: Account, kept: String, secret: Secret, cx: &mut Context<Self>) {
|
||||
if let Some(words) = self.taken(&account.address, cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let tls = match &self.tls {
|
||||
Ok(tls) => tls.clone(),
|
||||
Err(error) => return self.halt(text(cx, "mail.error.tls", &[("error", error)]), cx),
|
||||
};
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Checking;
|
||||
}
|
||||
log::info!("mail: checking a {:?} account", account.protocol);
|
||||
let probe = account.clone();
|
||||
let work = async move { probe.check(secret, &tls).await };
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
mail.checked(asked, account, kept, done, cx)
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn checked(
|
||||
&mut self,
|
||||
asked: u64,
|
||||
account: Account,
|
||||
kept: String,
|
||||
done: Result<Checked, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(adding) = self.awaiting(asked) else {
|
||||
return log::info!("mail: a stale check dropped");
|
||||
};
|
||||
adding.task = None;
|
||||
adding.step = match done {
|
||||
Ok(checked) => Step::Checked {
|
||||
account,
|
||||
kept,
|
||||
checked,
|
||||
},
|
||||
Err(error) => Step::Failed(text(cx, "mail.add.check_failed", &[("error", &error)])),
|
||||
};
|
||||
}
|
||||
|
||||
/// Opens the provider's page, then checks what it grants.
|
||||
pub fn sign_in(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &self.adding else { return };
|
||||
let provider = match adding.auth().provider() {
|
||||
Some(Ok(provider)) => provider,
|
||||
Some(Err(error)) => return self.halt(unsigned(&error, cx), cx),
|
||||
None => return log::error!("mail: sign-in asked of a password account"),
|
||||
};
|
||||
if let Err(words) = adding.servers(cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let hint = adding.fields.address.read(cx).text().trim().to_owned();
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Signing;
|
||||
}
|
||||
let work = async move { oauth::SignIn::start(provider, &hint).await };
|
||||
let task = self.on_runtime(cx, work, move |mail, started, cx| {
|
||||
if mail.awaiting(asked).is_none() {
|
||||
return log::info!("mail: a stale sign-in dropped");
|
||||
}
|
||||
match started.map_err(|error| text(cx, "mail.add.check_failed", &[("error", &error)])) {
|
||||
Ok(Ok(sign_in)) => mail.wait_for_browser(asked, sign_in, cx),
|
||||
Ok(Err(error)) => mail.halt(unsigned(&error, cx), cx),
|
||||
Err(words) => mail.halt(words, cx),
|
||||
}
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn wait_for_browser(&mut self, asked: u64, sign_in: oauth::SignIn, cx: &mut Context<Self>) {
|
||||
cx.open_url(sign_in.url());
|
||||
log::info!("mail: the browser has the sign-in page");
|
||||
let work = async move {
|
||||
let tokens = sign_in.finish().await?;
|
||||
let email = tokens
|
||||
.email
|
||||
.clone()
|
||||
.ok_or_else(|| Error::SignIn("the provider named no address".into()))?;
|
||||
let refresh = tokens
|
||||
.refresh
|
||||
.clone()
|
||||
.ok_or_else(|| Error::SignIn("no refresh token granted".into()))?;
|
||||
Ok::<_, Error>((tokens.access, refresh, email))
|
||||
};
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
if mail.awaiting(asked).is_none() {
|
||||
return log::info!("mail: a stale sign-in dropped");
|
||||
}
|
||||
let account = match done {
|
||||
Ok(Ok((access, refresh, email))) => mail.adding.as_ref().map(|adding| {
|
||||
adding
|
||||
.account(Some(&email), cx)
|
||||
.map(|account| (account, access, refresh))
|
||||
}),
|
||||
Ok(Err(error)) => Some(Err(unsigned(&error, cx))),
|
||||
Err(error) => Some(Err(text(cx, "mail.add.check_failed", &[("error", &error)]))),
|
||||
};
|
||||
match account {
|
||||
Some(Ok((account, access, refresh))) => {
|
||||
mail.probe(account, refresh, Secret::Token(access), cx)
|
||||
}
|
||||
Some(Err(words)) => mail.halt(words, cx),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the checked account, then its secret.
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Adding {
|
||||
step:
|
||||
Step::Checked {
|
||||
account,
|
||||
kept,
|
||||
checked,
|
||||
},
|
||||
..
|
||||
}) = &self.adding
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !checked.passed() {
|
||||
return;
|
||||
}
|
||||
if let Some(words) = self.taken(&account.address, cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let (account, kept) = (account.clone(), kept.clone());
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Saving;
|
||||
}
|
||||
let vault = self.vault.clone();
|
||||
let work = move |store: &mut ely_mail_engine::Store| {
|
||||
let id = match store.add_account(&account) {
|
||||
Ok(id) => id,
|
||||
Err(Error::Taken(address)) => return Ok(Err(address)),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let held = vault.put(&account.secret_name(), &kept);
|
||||
Ok(Ok((id, account, held)))
|
||||
};
|
||||
self.with_store(cx, work, move |mail, saved, cx| match saved {
|
||||
Ok(Err(address)) => {
|
||||
let words = text(cx, "mail.add.taken", &[("address", &address)]);
|
||||
match mail.awaiting(asked) {
|
||||
Some(_) => mail.halt(words, cx),
|
||||
None => mail.toast(words, Severity::Danger, cx),
|
||||
}
|
||||
}
|
||||
Ok(Ok((id, account, held))) => {
|
||||
log::info!("mail: account {} added", id.0);
|
||||
let words = text(cx, "mail.add.added", &[("address", &account.address)]);
|
||||
if let Some(Ok(accounts)) = &mut mail.accounts {
|
||||
accounts.push((id, account));
|
||||
}
|
||||
mail.open_box(crate::boxes::Open::Account(id), cx);
|
||||
mail.sync(id, cx);
|
||||
if mail.awaiting(asked).is_some() {
|
||||
mail.adding = None;
|
||||
}
|
||||
mail.toast(words, Severity::Success, cx);
|
||||
if let Kept::Session(reason) = held {
|
||||
mail.toast(
|
||||
text(cx, "mail.add.session_only", &[("error", &reason)]),
|
||||
Severity::Warning,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let words = text(cx, "mail.add.save_failed", &[("error", &error)]);
|
||||
match mail.awaiting(asked) {
|
||||
Some(_) => mail.halt(words, cx),
|
||||
None => mail.toast(words, Severity::Danger, cx),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign-in failures, with this build's missing client named.
|
||||
fn unsigned(error: &Error, cx: &gpui::App) -> SharedString {
|
||||
match error {
|
||||
Error::Unavailable(name) => text(cx, &format!("mail.add.unavailable.{name}"), &[]),
|
||||
other => text(
|
||||
cx,
|
||||
"mail.add.sign_in_failed",
|
||||
&[("error", &other.to_string())],
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! The kinds of account and their known servers.
|
||||
|
||||
use ely_mail_engine::{Auth, Protocol, Security, Server};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Gmail,
|
||||
Microsoft,
|
||||
ICloud,
|
||||
Imap,
|
||||
Pop,
|
||||
}
|
||||
|
||||
pub const KINDS: [Kind; 5] = [
|
||||
Kind::Gmail,
|
||||
Kind::Microsoft,
|
||||
Kind::ICloud,
|
||||
Kind::Imap,
|
||||
Kind::Pop,
|
||||
];
|
||||
|
||||
/// Where iCloud makes app-specific passwords.
|
||||
pub const APPLE_ACCOUNT: &str = "https://account.apple.com";
|
||||
|
||||
impl Kind {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Gmail => "gmail",
|
||||
Kind::Microsoft => "microsoft",
|
||||
Kind::ICloud => "icloud",
|
||||
Kind::Imap => "imap",
|
||||
Kind::Pop => "pop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> Option<Kind> {
|
||||
KINDS.into_iter().find(|kind| kind.key() == key)
|
||||
}
|
||||
|
||||
/// `keep` is POP3's server copy.
|
||||
pub fn protocol(self, keep: bool) -> Protocol {
|
||||
match self {
|
||||
Kind::Pop => Protocol::Pop { keep },
|
||||
_ => Protocol::Imap,
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider's servers and sign-in; None when typed in.
|
||||
pub fn known(self) -> Option<(Auth, Server, Server)> {
|
||||
let server = |host: &str, port, security| Server {
|
||||
host: host.into(),
|
||||
port,
|
||||
security,
|
||||
};
|
||||
match self {
|
||||
Kind::Gmail => Some((
|
||||
Auth::Google,
|
||||
server("imap.gmail.com", 993, Security::Tls),
|
||||
server("smtp.gmail.com", 465, Security::Tls),
|
||||
)),
|
||||
Kind::Microsoft => Some((
|
||||
Auth::Microsoft,
|
||||
server("outlook.office365.com", 993, Security::Tls),
|
||||
server("smtp.office365.com", 587, Security::StartTls),
|
||||
)),
|
||||
// Apple: IMAP takes the full address too.
|
||||
Kind::ICloud => Some((
|
||||
Auth::Password,
|
||||
server("imap.mail.me.com", 993, Security::Tls),
|
||||
server("smtp.mail.me.com", 587, Security::StartTls),
|
||||
)),
|
||||
Kind::Imap | Kind::Pop => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The usual port for a protocol under a security.
|
||||
pub fn port(protocol: Option<Protocol>, security: Security) -> u16 {
|
||||
match (protocol, security) {
|
||||
(Some(Protocol::Imap), Security::Tls) => 993,
|
||||
(Some(Protocol::Imap), Security::StartTls) => 143,
|
||||
(Some(Protocol::Pop { .. }), Security::Tls) => 995,
|
||||
(Some(Protocol::Pop { .. }), Security::StartTls) => 110,
|
||||
(None, Security::Tls) => 465,
|
||||
(None, Security::StartTls) => 587,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! The window: accounts and the dialogs that change them.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{Account, AccountId, Filter, Store, TlsConnector, Vault};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Root;
|
||||
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, SharedString, WeakEntity, Window};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
adding::Adding,
|
||||
boxes::{Listing, Open, Unread},
|
||||
removing::Removing,
|
||||
side::Side,
|
||||
syncing::Syncs,
|
||||
};
|
||||
|
||||
/// What the engine runs on and keeps to.
|
||||
pub struct Parts {
|
||||
pub store: Result<Store, String>,
|
||||
pub vault: Vault,
|
||||
pub tls: Result<TlsConnector, String>,
|
||||
}
|
||||
|
||||
impl Parts {
|
||||
pub fn system(dir: &Path) -> Self {
|
||||
Self {
|
||||
store: Store::open(dir).map_err(|err| err.to_string()),
|
||||
vault: Vault::system(),
|
||||
tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Mail {
|
||||
pub store: Result<Arc<Mutex<Store>>, String>,
|
||||
pub read_only: Option<String>,
|
||||
pub vault: Arc<Vault>,
|
||||
pub tls: Result<TlsConnector, String>,
|
||||
runtime: Arc<Runtime>,
|
||||
/// None until read.
|
||||
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
|
||||
/// Remove acts on its account.
|
||||
pub open: Open,
|
||||
pub filter: Filter,
|
||||
pub listing: Listing,
|
||||
pub unread: Unread,
|
||||
pub adding: Option<Adding>,
|
||||
pub removing: Option<Removing>,
|
||||
pub syncs: Syncs,
|
||||
/// Numbers every sync, even across forgotten accounts.
|
||||
pub runs: u64,
|
||||
/// Until resumed, no account syncs.
|
||||
pub paused: bool,
|
||||
/// Numbers every request across dialogs.
|
||||
pub asked: u64,
|
||||
pub focus: FocusHandle,
|
||||
pub root: WeakEntity<Root>,
|
||||
pub side: Entity<Side>,
|
||||
}
|
||||
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
}
|
||||
|
||||
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let dir = match ely_platform::data_dir(app) {
|
||||
Ok(dir) => dir,
|
||||
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
|
||||
};
|
||||
let parts = Parts::system(&dir);
|
||||
let mail = cx.new(|cx| Mail::new(parts, cx));
|
||||
let root = ely_ui::tool_root(app, mail.clone().into(), crate::menu::menus(cx), window, cx);
|
||||
mail.update(cx, |mail, cx| {
|
||||
mail.root = root.downgrade();
|
||||
window.focus(&mail.focus, cx);
|
||||
});
|
||||
root
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn new(parts: Parts, cx: &mut Context<Self>) -> Self {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("mail")
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|error| {
|
||||
ely_platform::startup::fail(AppId::Mail, &format!("no mail runtime: {error}"))
|
||||
});
|
||||
let read_only = match &parts.store {
|
||||
Ok(store) => store.read_only().map(str::to_owned),
|
||||
Err(error) => {
|
||||
log::error!("mail: the store did not open: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Err(error) = &parts.tls {
|
||||
log::error!("mail: no system TLS: {error}");
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
let mut mail = Mail {
|
||||
store: parts.store.map(|store| Arc::new(Mutex::new(store))),
|
||||
read_only,
|
||||
vault: Arc::new(parts.vault),
|
||||
tls: parts.tls,
|
||||
runtime: Arc::new(runtime),
|
||||
accounts: None,
|
||||
open: Open::Inboxes,
|
||||
filter: Filter::default(),
|
||||
listing: Listing::default(),
|
||||
unread: Unread::new(),
|
||||
adding: None,
|
||||
removing: None,
|
||||
syncs: Syncs::new(),
|
||||
runs: 0,
|
||||
paused: false,
|
||||
asked: 0,
|
||||
focus: cx.focus_handle(),
|
||||
root: WeakEntity::new_invalid(),
|
||||
side: cx.new(|_| Side::new(this)),
|
||||
};
|
||||
mail.read_accounts(cx);
|
||||
Self::sync_on_a_timer(cx);
|
||||
mail
|
||||
}
|
||||
|
||||
pub fn read_accounts(&mut self, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
|store| store.accounts(),
|
||||
|mail, read, cx| {
|
||||
let first = mail.accounts.is_none();
|
||||
if let Ok(accounts) = &read {
|
||||
let still = |id| accounts.iter().any(|(each, _)| *each == id);
|
||||
if mail.open.account().is_some_and(|id| !still(id)) {
|
||||
mail.open = Open::Inboxes;
|
||||
}
|
||||
}
|
||||
mail.accounts = Some(read);
|
||||
if first {
|
||||
mail.sync_all(cx);
|
||||
}
|
||||
mail.read_mail(cx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn accounts(&self) -> &[(AccountId, Account)] {
|
||||
match &self.accounts {
|
||||
Some(Ok(accounts)) => accounts,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraws the window and its account list.
|
||||
pub fn notify(&self, cx: &mut Context<Self>) {
|
||||
self.side.update(cx, |_, cx| cx.notify());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Store work off the window's thread.
|
||||
pub fn with_store<R: Send + 'static>(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl FnOnce(&mut Store) -> ely_mail_engine::Result<R> + Send + 'static,
|
||||
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
|
||||
) {
|
||||
let store = match &self.store {
|
||||
Ok(store) => store.clone(),
|
||||
Err(error) => {
|
||||
self.failed("mail.error.store", error, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let read = cx.background_spawn(async move {
|
||||
let mut store = store
|
||||
.lock()
|
||||
.map_err(|_| "a store task panicked".to_owned())?;
|
||||
work(&mut store).map_err(|err| err.to_string())
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let read = read.await;
|
||||
this.update(cx, |mail, cx| {
|
||||
then(mail, read, cx);
|
||||
mail.notify(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Network work on the mail runtime; abort stops it.
|
||||
pub fn on_runtime<R: Send + 'static>(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl Future<Output = R> + Send + 'static,
|
||||
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
|
||||
) -> tokio::task::AbortHandle {
|
||||
let task = self.runtime.spawn(work);
|
||||
let abort = task.abort_handle();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let done = task.await;
|
||||
if done.as_ref().is_err_and(|err| err.is_cancelled()) {
|
||||
return;
|
||||
}
|
||||
let done = done.map_err(|err| format!("a mail task failed: {err}"));
|
||||
this.update(cx, |mail, cx| {
|
||||
then(mail, done, cx);
|
||||
mail.notify(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
abort
|
||||
}
|
||||
|
||||
/// Not logged: the words may name an address.
|
||||
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
|
||||
if let Some(root) = self.root.upgrade() {
|
||||
root.update(cx, |root, cx| root.toast(text, severity, cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
|
||||
log::warn!("mail: {key}");
|
||||
let words = text(cx, key, &[("error", error)]);
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,25 @@
|
||||
// Release builds open no console on Windows.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod add_view;
|
||||
mod adding;
|
||||
mod boxes;
|
||||
mod controls;
|
||||
mod flow;
|
||||
mod kinds;
|
||||
mod mail;
|
||||
mod menu;
|
||||
mod panes;
|
||||
mod removing;
|
||||
mod side;
|
||||
mod syncing;
|
||||
mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
ely_ui::run(AppId::Mail, ely_ui::blank);
|
||||
ely_ui::run(AppId::Mail, mail::open);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The Account and Sync menus.
|
||||
|
||||
use gpui::{App, Menu, MenuItem, actions};
|
||||
|
||||
use crate::mail::text;
|
||||
|
||||
actions!(mail, [AddAccount, RemoveAccount, SyncAll]);
|
||||
|
||||
pub const CONTEXT: &str = "mail";
|
||||
|
||||
pub fn menus(cx: &App) -> Vec<Menu> {
|
||||
vec![
|
||||
Menu::new(text(cx, "mail.menu.account", &[])).items([
|
||||
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
|
||||
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
|
||||
]),
|
||||
Menu::new(text(cx, "mail.menu.sync", &[]))
|
||||
.items([MenuItem::action(text(cx, "mail.sync.now", &[]), SyncAll)]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! The message list and the reading pane (C12.9).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ToggleGroup, ToggleItem},
|
||||
data_display::DescriptionList,
|
||||
feedback::{EmptyState, ErrorView},
|
||||
layout::SplitPane,
|
||||
mail::MailList,
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_mail_engine::Filter;
|
||||
use gpui::{
|
||||
AnyElement, Axis, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
mail::{Mail, text},
|
||||
view::local_time,
|
||||
};
|
||||
|
||||
type Filtered = fn(Filter) -> bool;
|
||||
|
||||
const FILTERS: [(&str, &str, IconName, Filtered); 3] = [
|
||||
("unread", "mail.filter.unread", IconName::Mail, |f| f.unread),
|
||||
("flagged", "mail.filter.flagged", IconName::Star, |f| {
|
||||
f.flagged
|
||||
}),
|
||||
(
|
||||
"attachments",
|
||||
"mail.filter.attachments",
|
||||
IconName::Paperclip,
|
||||
|f| f.attachments,
|
||||
),
|
||||
];
|
||||
|
||||
impl Mail {
|
||||
pub fn panes(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
SplitPane::new("mail-panes", Axis::Horizontal, px(240.))
|
||||
.pane(self.list_pane(cx))
|
||||
.pane(self.reader(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn list_pane(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let on: Vec<&str> = FILTERS
|
||||
.iter()
|
||||
.filter(|(_, _, _, on)| on(self.filter))
|
||||
.map(|(key, _, _, _)| *key)
|
||||
.collect();
|
||||
let toggles = FILTERS.iter().fold(
|
||||
ToggleGroup::new("mail-filters").multiple().selected(on),
|
||||
|group, (key, label, icon, _)| {
|
||||
let words = text(cx, label, &[]);
|
||||
group.item(ToggleItem::new(*key).icon(*icon).label(words))
|
||||
},
|
||||
);
|
||||
let this = cx.weak_entity();
|
||||
let toggles = toggles.on_change(move |keys, _, cx| {
|
||||
let has = |key: &str| keys.iter().any(|each| each == key);
|
||||
let filter = Filter {
|
||||
unread: has("unread"),
|
||||
flagged: has("flagged"),
|
||||
attachments: has("attachments"),
|
||||
};
|
||||
this.update(cx, |mail, cx| mail.set_filter(filter, cx)).ok();
|
||||
});
|
||||
let list = match &self.listing.mails {
|
||||
None => div().into_any_element(),
|
||||
Some(Err(detail)) => ErrorView::new(
|
||||
"mail-list-error",
|
||||
IconName::TriangleAlert,
|
||||
text(cx, "mail.list.unread", &[]),
|
||||
)
|
||||
.detail(detail.clone())
|
||||
.into_any_element(),
|
||||
Some(Ok(mails)) => {
|
||||
let this = cx.weak_entity();
|
||||
MailList::new("mail-list", mails.clone())
|
||||
.selected(self.listing.selected.clone())
|
||||
.on_select(move |keys, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.select(keys, cx)).ok();
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(div().p_2().child(toggles))
|
||||
.child(div().flex_1().min_h_0().child(list))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn reader(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(row) = &self.listing.chosen else {
|
||||
return EmptyState::new(
|
||||
"mail-reader-empty",
|
||||
IconName::Mail,
|
||||
text(cx, "mail.reader.none", &[]),
|
||||
)
|
||||
.into_any_element();
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let date: SharedString = match row.at {
|
||||
Some(at) => local_time(at.as_second()).into(),
|
||||
None => label("mail.reader.no_date"),
|
||||
};
|
||||
let details = DescriptionList::new()
|
||||
.text(label("mail.reader.from"), row.from.clone())
|
||||
.text(label("mail.reader.date"), date);
|
||||
div()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(div().text_xl().child(row.subject.clone()))
|
||||
.child(details)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Removing an account: its local data and secret only.
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{AccountId, Cached, Error, Kept};
|
||||
use gpui::Context;
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Removing {
|
||||
pub id: AccountId,
|
||||
pub address: String,
|
||||
pub cached: Cached,
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
/// Measures what goes, then asks.
|
||||
pub fn ask_remove(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.open.account() else {
|
||||
let words = text(cx, "mail.remove.none", &[]);
|
||||
return self.toast(words, Severity::Info, cx);
|
||||
};
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.cached(id),
|
||||
move |mail, cached, cx| {
|
||||
let address = mail
|
||||
.accounts()
|
||||
.iter()
|
||||
.find(|(each, _)| *each == id)
|
||||
.map(|(_, account)| account.address.clone());
|
||||
match (cached, address) {
|
||||
(Ok(cached), Some(address))
|
||||
if mail.adding.is_none() && mail.removing.is_none() =>
|
||||
{
|
||||
mail.removing = Some(Removing {
|
||||
id,
|
||||
address,
|
||||
cached,
|
||||
});
|
||||
}
|
||||
(Ok(_), _) => {
|
||||
log::info!("mail: removal asked of a gone account or over a dialog")
|
||||
}
|
||||
(Err(error), _) => mail.failed("mail.remove.failed", &error, cx),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The secret goes first, and back if the rest fails.
|
||||
pub fn remove(&mut self, removing: Removing, cx: &mut Context<Self>) {
|
||||
let Some((_, account)) = self
|
||||
.accounts()
|
||||
.iter()
|
||||
.find(|(each, _)| *each == removing.id)
|
||||
else {
|
||||
return log::info!("mail: the account to remove is already gone");
|
||||
};
|
||||
let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone());
|
||||
self.stop_sync(id);
|
||||
let address = removing.address;
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| {
|
||||
let secret = vault.get(&name)?;
|
||||
vault.remove(&name)?;
|
||||
store.remove_account(id).map_err(|error| {
|
||||
match secret.map(|secret| vault.put(&name, &secret)) {
|
||||
Some(Kept::Session(reason)) => Error::Vault(format!(
|
||||
"{error}; its secret is back for this session only: {reason}"
|
||||
)),
|
||||
_ => error,
|
||||
}
|
||||
})
|
||||
},
|
||||
move |mail, removed, cx| {
|
||||
match removed {
|
||||
Ok(()) => {
|
||||
log::info!("mail: account {} removed", id.0);
|
||||
mail.toast(
|
||||
text(cx, "mail.remove.done", &[("address", &address)]),
|
||||
Severity::Success,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
mail.failed("mail.remove.failed", &error, cx);
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
}
|
||||
mail.read_accounts(cx);
|
||||
},
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Every inbox, then each account's folders, down the left.
|
||||
|
||||
use ely_gpui_component::{
|
||||
layout::ScrollArea,
|
||||
mail::{Mailbox, MailboxList},
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_mail_engine::{LocalFolder, Role};
|
||||
use gpui::{
|
||||
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, WeakEntity,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
boxes::Open,
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
pub struct Side {
|
||||
mail: WeakEntity<Mail>,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn new(mail: WeakEntity<Mail>) -> Self {
|
||||
Self { mail }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Side {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(mail) = self.mail.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let mail = mail.read(cx);
|
||||
let inboxes = Mailbox::new(
|
||||
Open::Inboxes.key(),
|
||||
text(cx, "mail.boxes.all", &[]),
|
||||
IconName::Inbox,
|
||||
)
|
||||
.unread(mail.inboxes_unread());
|
||||
let mut list =
|
||||
MailboxList::new("mail-boxes").section(text(cx, "mail.boxes.unified", &[]), [inboxes]);
|
||||
for (id, account) in mail.accounts() {
|
||||
let folders = mail.syncs.get(id).map_or(&[][..], |each| &each.folders);
|
||||
let mut sorted: Vec<&LocalFolder> = folders.iter().collect();
|
||||
sorted.sort_by(|a, b| rank(a.role).cmp(&rank(b.role)).then(a.name.cmp(&b.name)));
|
||||
let boxes = sorted.into_iter().map(|folder| {
|
||||
let open = Open::Mailbox(*id, folder.id);
|
||||
// Boxes with nothing unread are absent.
|
||||
let unread = mail.unread.get(&folder.id).copied().unwrap_or(0);
|
||||
Mailbox::new(open.key(), name(folder, cx), icon(folder.role)).unread(unread)
|
||||
});
|
||||
let settings = Mailbox::new(
|
||||
Open::Account(*id).key(),
|
||||
text(cx, "mail.boxes.settings", &[]),
|
||||
IconName::Settings,
|
||||
);
|
||||
list = list.section(account.address.clone(), boxes.chain([settings]));
|
||||
}
|
||||
let this = self.mail.clone();
|
||||
let list = list.open(mail.open.key()).on_open(move |key, _, cx| {
|
||||
this.update(cx, |mail, cx| match mail.opened(key) {
|
||||
Some(open) => mail.open_box(open, cx),
|
||||
None => log::error!("mail: the mailbox list sent an unknown box"),
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
ScrollArea::new("mail-side-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(div().p_2().child(list))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
/// The box a side key names, if still here.
|
||||
fn opened(&self, key: &SharedString) -> Option<Open> {
|
||||
if *key == Open::Inboxes.key() {
|
||||
return Some(Open::Inboxes);
|
||||
}
|
||||
self.accounts().iter().find_map(|(id, _)| {
|
||||
let folders = self.syncs.get(id).map_or(&[][..], |each| &each.folders);
|
||||
let boxes = folders.iter().map(|folder| Open::Mailbox(*id, folder.id));
|
||||
boxes
|
||||
.chain([Open::Account(*id)])
|
||||
.find(|open| open.key() == *key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox in the reader's words; others as named.
|
||||
fn name(folder: &LocalFolder, cx: &App) -> SharedString {
|
||||
match folder.role {
|
||||
Some(Role::Inbox) => text(cx, "mail.boxes.inbox", &[]),
|
||||
_ => folder.name.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rank(role: Option<Role>) -> u8 {
|
||||
match role {
|
||||
Some(Role::Inbox) => 0,
|
||||
Some(Role::Flagged) => 1,
|
||||
Some(Role::Drafts) => 2,
|
||||
Some(Role::Sent) => 3,
|
||||
Some(Role::Archive) => 4,
|
||||
Some(Role::All) => 5,
|
||||
Some(Role::Junk) => 6,
|
||||
Some(Role::Trash) => 7,
|
||||
None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(role: Option<Role>) -> IconName {
|
||||
match role {
|
||||
Some(Role::Inbox) => IconName::Inbox,
|
||||
Some(Role::Flagged) => IconName::Star,
|
||||
Some(Role::Drafts) => IconName::File,
|
||||
Some(Role::Sent) => IconName::Send,
|
||||
Some(Role::Archive) => IconName::Archive,
|
||||
Some(Role::All) => IconName::Mail,
|
||||
Some(Role::Junk) => IconName::TriangleAlert,
|
||||
Some(Role::Trash) => IconName::Trash2,
|
||||
None => IconName::Folder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Accounts sync on open, on demand and on a timer.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Protocol, Synced, sync};
|
||||
use gpui::Context;
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::mail::Mail;
|
||||
|
||||
/// How often every account syncs while the window is open.
|
||||
pub const EVERY: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// One account's sync as the window shows it.
|
||||
#[derive(Default)]
|
||||
pub struct Syncing {
|
||||
pub task: Option<AbortHandle>,
|
||||
/// Which sync `task` is; older completions are stale.
|
||||
pub run: u64,
|
||||
/// A folder changed mid-sync: sync once more.
|
||||
pub again: bool,
|
||||
pub synced: Option<Synced>,
|
||||
pub failed: Option<String>,
|
||||
pub folders: Vec<LocalFolder>,
|
||||
}
|
||||
|
||||
pub type Syncs = HashMap<AccountId, Syncing>;
|
||||
|
||||
impl Mail {
|
||||
pub fn sync(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
if self.paused {
|
||||
return log::info!("mail: account {} not synced: paused", id.0);
|
||||
}
|
||||
let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else {
|
||||
return log::info!("mail: sync asked of a gone account");
|
||||
};
|
||||
if self.syncs.get(&id).is_some_and(|each| each.task.is_some()) {
|
||||
return log::info!("mail: account {} is already syncing", id.0);
|
||||
}
|
||||
let Ok(store) = self.store.clone() else {
|
||||
return log::error!("mail: no store to sync with");
|
||||
};
|
||||
let tls = match self.tls.clone() {
|
||||
Ok(tls) => tls,
|
||||
Err(error) => {
|
||||
log::warn!("mail: account {} cannot sync without TLS", id.0);
|
||||
self.syncs.entry(id).or_default().failed = Some(error);
|
||||
return self.read_synced(id, cx);
|
||||
}
|
||||
};
|
||||
let (account, vault) = (account.clone(), self.vault.clone());
|
||||
self.runs += 1;
|
||||
let run = self.runs;
|
||||
self.syncs.entry(id).or_default().run = run;
|
||||
let work = async move {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| ely_mail_engine::Error::Store(format!("the clock: {err}")))?
|
||||
.as_secs() as i64;
|
||||
let secret = sync::secret(&account, &vault).await?;
|
||||
match account.protocol {
|
||||
Protocol::Imap => sync::imap(&account, id, secret, &tls, &store, now).await,
|
||||
Protocol::Pop { .. } => sync::pop(&account, id, secret, &tls, &store, now).await,
|
||||
}
|
||||
};
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
let failed = match done {
|
||||
Ok(Ok(_)) => None,
|
||||
Ok(Err(error)) => Some(error.to_string()),
|
||||
Err(error) => Some(error),
|
||||
};
|
||||
if failed.is_some() {
|
||||
log::warn!("mail: account {} did not sync", id.0);
|
||||
}
|
||||
let Some(syncing) = mail.syncs.get_mut(&id) else {
|
||||
return log::info!("mail: a sync ended for a removed account");
|
||||
};
|
||||
if syncing.run != run {
|
||||
return log::info!("mail: account {} sync {run} ended after a newer one", id.0);
|
||||
}
|
||||
syncing.task = None;
|
||||
syncing.failed = failed;
|
||||
let again = std::mem::take(&mut syncing.again);
|
||||
mail.read_synced(id, cx);
|
||||
if again {
|
||||
log::info!("mail: account {} syncs again for a changed folder", id.0);
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
});
|
||||
self.syncs.entry(id).or_default().task = Some(task);
|
||||
self.read_synced(id, cx);
|
||||
}
|
||||
|
||||
pub fn sync_all(&mut self, cx: &mut Context<Self>) {
|
||||
if self.paused {
|
||||
return log::info!("mail: sync is paused");
|
||||
}
|
||||
let ids: Vec<_> = self.accounts().iter().map(|(id, _)| *id).collect();
|
||||
for id in ids {
|
||||
self.sync(id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pausing stops running syncs; resuming syncs every account.
|
||||
pub fn set_paused(&mut self, paused: bool, cx: &mut Context<Self>) {
|
||||
log::info!("mail: sync paused: {paused}");
|
||||
self.paused = paused;
|
||||
if paused {
|
||||
for syncing in self.syncs.values_mut() {
|
||||
syncing.again = false;
|
||||
if let Some(task) = syncing.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.sync_all(cx);
|
||||
}
|
||||
self.notify(cx);
|
||||
}
|
||||
|
||||
/// Syncs every account each `EVERY` until the window goes.
|
||||
pub fn sync_on_a_timer(cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(EVERY).await;
|
||||
if this.update(cx, |mail, cx| mail.sync_all(cx)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Takes the folder's mail whole, then syncs to fetch it.
|
||||
pub fn set_offline(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
mailbox: MailboxId,
|
||||
offline: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.set_offline(mailbox, offline),
|
||||
move |mail, done, cx| match done {
|
||||
Ok(()) => {
|
||||
mail.read_synced(id, cx);
|
||||
match mail.syncs.get_mut(&id) {
|
||||
Some(syncing) if offline && syncing.task.is_some() => {
|
||||
log::info!("mail: account {} syncs again after this one", id.0);
|
||||
syncing.again = true;
|
||||
}
|
||||
_ if offline => mail.sync(id, cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(error) => mail.failed("mail.sync.offline_failed", &error, cx),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops the account's sync and forgets it.
|
||||
pub fn stop_sync(&mut self, id: AccountId) {
|
||||
if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn read_synced(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| Ok((store.synced(id)?, store.folders(id)?)),
|
||||
move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) {
|
||||
(Ok((synced, folders)), Some(syncing)) => {
|
||||
syncing.synced = Some(synced);
|
||||
syncing.folders = folders;
|
||||
mail.read_mail(cx);
|
||||
}
|
||||
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
|
||||
(_, None) => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Accounts in the window: add, check, keep, remove (C12.5).
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{
|
||||
Account, Auth, Backend, Checked, Protocol, Security, Server, Store, TlsConnector, Vault,
|
||||
};
|
||||
use gpui::{App, Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use crate::{
|
||||
adding::Step,
|
||||
kinds::Kind,
|
||||
mail::{Mail, Parts},
|
||||
};
|
||||
|
||||
mod controls;
|
||||
mod flows;
|
||||
mod panes;
|
||||
mod syncs;
|
||||
|
||||
/// A credential store that keeps or refuses.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Keys {
|
||||
pub kept: Arc<Mutex<HashMap<String, String>>>,
|
||||
pub refuse: Option<&'static str>,
|
||||
pub deletes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Backend for Keys {
|
||||
fn get(&self, name: &str) -> Result<Option<String>, String> {
|
||||
Ok(self.kept.lock().unwrap().get(name).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, name: &str, secret: &str) -> Result<(), String> {
|
||||
if let Some(reason) = self.refuse {
|
||||
return Err(reason.into());
|
||||
}
|
||||
self.kept.lock().unwrap().insert(name.into(), secret.into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, name: &str) -> Result<(), String> {
|
||||
self.deletes.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(reason) = self.refuse {
|
||||
return Err(reason.into());
|
||||
}
|
||||
self.kept.lock().unwrap().remove(name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
if cx.update(|_, app| done(app)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
pub(crate) fn window<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
let tls = ely_mail_engine::system_tls().map_err(|err| err.to_string());
|
||||
window_with(cx, dir, keys, tls)
|
||||
}
|
||||
|
||||
pub(crate) fn window_with<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
tls: Result<TlsConnector, String>,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
cx.executor().allow_parking();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
cx.update(|cx| {
|
||||
ely_ui::settings::install_defaults(cx);
|
||||
ely_ui::i18n::install_as("en-US", cx);
|
||||
// As tool_root does: menus before the window.
|
||||
let menus = crate::menu::menus(cx);
|
||||
let menus = ely_ui::commands::menus(
|
||||
ely_platform::AppId::Mail,
|
||||
&ely_ui::commands::OPENING,
|
||||
false,
|
||||
menus,
|
||||
cx,
|
||||
);
|
||||
cx.set_menus(menus);
|
||||
});
|
||||
let parts = Parts {
|
||||
store: Store::open(dir).map_err(|err| err.to_string()),
|
||||
vault: Vault::new(Box::new(keys)),
|
||||
tls,
|
||||
};
|
||||
let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx));
|
||||
settle(cx, |app| mail.read(app).accounts.is_some());
|
||||
(mail, cx)
|
||||
}
|
||||
|
||||
/// A port nothing listens on.
|
||||
pub(crate) fn closed_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
/// On this machine's discard port, so syncs end at once.
|
||||
pub(crate) fn account(address: &str) -> Account {
|
||||
let server = |port| Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
security: Security::Tls,
|
||||
};
|
||||
Account {
|
||||
address: address.into(),
|
||||
user: address.into(),
|
||||
auth: Auth::Password,
|
||||
protocol: Protocol::Imap,
|
||||
incoming: server(9),
|
||||
outgoing: server(9),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the dialog on `kind`.
|
||||
pub(crate) fn adding(mail: &Entity<Mail>, kind: Kind, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.start_adding(window, cx);
|
||||
mail.adding.as_mut().unwrap().set_kind(kind, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn type_in(
|
||||
mail: &Entity<Mail>,
|
||||
cx: &mut VisualTestContext,
|
||||
pick: fn(&crate::adding::Fields) -> &Entity<ely_gpui_component::forms::TextInput>,
|
||||
words: &str,
|
||||
) {
|
||||
cx.update(|_, app| {
|
||||
let field = pick(&mail.read(app).adding.as_ref().unwrap().fields).clone();
|
||||
field.update(app, |field, cx| field.set_text(words.to_owned(), cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A passed check, as servers would answer.
|
||||
pub(crate) fn passed(
|
||||
mail: &Entity<Mail>,
|
||||
account: Account,
|
||||
kept: &str,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, _| {
|
||||
mail.adding.as_mut().unwrap().step = Step::Checked {
|
||||
account,
|
||||
kept: kept.into(),
|
||||
checked: Checked {
|
||||
incoming: Ok(()),
|
||||
outgoing: Ok(()),
|
||||
},
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn step_words(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(
|
||||
|_, app| match &mail.read(app).adding.as_ref().unwrap().step {
|
||||
Step::Failed(words) => words.to_string(),
|
||||
Step::Editing => "editing".into(),
|
||||
Step::Signing => "signing".into(),
|
||||
Step::Checking => "checking".into(),
|
||||
Step::Saving => "saving".into(),
|
||||
Step::Checked { .. } => "checked".into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Role, Store};
|
||||
use gpui::{
|
||||
Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, size,
|
||||
};
|
||||
|
||||
use super::{Keys, account, settle, window};
|
||||
use crate::{boxes::Open, mail::Mail, syncing::EVERY, view::imap_range};
|
||||
|
||||
/// An account on a server that never answers.
|
||||
fn silent(dir: &std::path::Path, port: u16) -> AccountId {
|
||||
let mut ann = account("ann@example.com");
|
||||
ann.incoming.port = port;
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let id = store.add_account(&ann).unwrap();
|
||||
store
|
||||
.set_folders(id, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn with_secret() -> Keys {
|
||||
let keys = Keys::default();
|
||||
let secret = ("account:ann@example.com".to_owned(), "pw".to_owned());
|
||||
keys.kept.lock().unwrap().extend([secret]);
|
||||
keys
|
||||
}
|
||||
|
||||
/// Waits on the network task alone, not the window.
|
||||
fn finished(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let done = |cx: &mut VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let task = mail.read(app).syncs[&id].task.as_ref();
|
||||
task.unwrap().is_finished()
|
||||
})
|
||||
};
|
||||
while !done(cx) {
|
||||
assert!(Instant::now() < deadline, "the sync never ended");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// An account whose sync fails at once: no secret saved.
|
||||
fn failing(dir: &std::path::Path) -> AccountId {
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
ann
|
||||
}
|
||||
|
||||
fn failed(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) -> bool {
|
||||
cx.update(|_, app| mail.read(app).syncs[&id].failed.is_some())
|
||||
}
|
||||
|
||||
fn forget(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, _| {
|
||||
mail.syncs.get_mut(&id).unwrap().failed = None
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn settled_failure(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&id)
|
||||
.is_some_and(|each| each.failed.is_some() && each.task.is_none())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_account_syncs_again_on_the_timer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settled_failure(&mail, ann, cx);
|
||||
forget(&mail, ann, cx);
|
||||
cx.executor().advance_clock(EVERY);
|
||||
settled_failure(&mail, ann, cx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_sync_waits_until_resumed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settled_failure(&mail, ann, cx);
|
||||
forget(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
|
||||
cx.executor().advance_clock(EVERY);
|
||||
cx.run_until_parked();
|
||||
assert!(!failed(&mail, ann, cx), "a paused account synced");
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(false, cx)));
|
||||
settled_failure(&mail, ann, cx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_kept_offline_is_saved_and_synced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.failed.is_some() && !each.folders.is_empty())
|
||||
});
|
||||
let inbox = cx.update(|_, app| mail.read(app).syncs[&ann].folders[0].clone());
|
||||
assert!(!inbox.offline);
|
||||
forget(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox.id, true, cx)));
|
||||
settle(cx, |app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
syncing.folders[0].offline && syncing.failed.is_some() && syncing.task.is_none()
|
||||
});
|
||||
assert!(Store::open(dir.path()).unwrap().offline(inbox.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_end_queued_across_a_resume_keeps_the_new_sync() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
settled_failure(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
|
||||
finished(&mail, ann, cx);
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let resumed = cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.set_paused(true, cx);
|
||||
mail.set_paused(false, cx);
|
||||
mail.syncs[&ann].task.clone().unwrap()
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
|
||||
"an old end cleared the new sync"
|
||||
);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !resumed.is_finished() {
|
||||
assert!(Instant::now() < deadline, "pausing left the sync running");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_end_after_a_failed_removal_keeps_the_new_sync() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
settled_failure(&mail, ann, cx);
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.stop_sync(ann);
|
||||
mail.sync(ann, cx);
|
||||
})
|
||||
});
|
||||
finished(&mail, ann, cx);
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let restarted = cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.stop_sync(ann);
|
||||
mail.sync(ann, cx);
|
||||
mail.syncs[&ann].task.clone().unwrap()
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
|
||||
"an old end cleared the restarted sync"
|
||||
);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !restarted.is_finished() {
|
||||
assert!(Instant::now() < deadline, "pausing left the sync running");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_kept_offline_mid_sync_syncs_once_more() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (hang_up, hung) = mpsc::channel::<()>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let held = listener.accept().unwrap();
|
||||
hung.recv().unwrap();
|
||||
drop((held, listener));
|
||||
});
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), port);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), with_secret());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.task.is_some() && !each.folders.is_empty())
|
||||
});
|
||||
let (inbox, run) = cx.update(|_, app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
(syncing.folders[0].id, syncing.run)
|
||||
});
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox, true, cx)));
|
||||
settle(cx, |app| mail.read(app).syncs[&ann].again);
|
||||
hang_up.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
settle(cx, |app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
syncing.run == run + 1 && syncing.task.is_none() && syncing.failed.is_some()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_page_scrolls_to_its_last_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
|
||||
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
|
||||
let last = *Store::open(dir.path())
|
||||
.unwrap()
|
||||
.set_folders(ann, &folders)
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(ann), cx)));
|
||||
cx.simulate_resize(size(px(1100.), px(800.)));
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.folders.len() == 40)
|
||||
});
|
||||
let selector: &'static str = format!("mail-offline-{}", last.0).leak();
|
||||
let height = cx.update(|window, _| window.viewport_size().height);
|
||||
let before = cx
|
||||
.debug_bounds(selector)
|
||||
.expect("the last switch is laid out");
|
||||
assert!(before.bottom() > height, "the folders fit the window");
|
||||
let at = point(px(400.), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
|
||||
..Default::default()
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let after = cx
|
||||
.debug_bounds(selector)
|
||||
.expect("the last switch is laid out");
|
||||
assert!(
|
||||
after.bottom() <= height,
|
||||
"the last switch is out of reach: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_range_names_the_folders_kept_whole() {
|
||||
let folder = |id, name: &str, offline| LocalFolder {
|
||||
id: MailboxId(id),
|
||||
name: name.into(),
|
||||
role: None,
|
||||
offline,
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
cx.update(|app| {
|
||||
ely_ui::i18n::install_as("en-US", app);
|
||||
let rows = |folders: &[LocalFolder]| -> Vec<String> {
|
||||
imap_range(folders, app)
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}: {value}"))
|
||||
.collect()
|
||||
};
|
||||
let both = [folder(1, "INBOX", true), folder(2, "Sent", false), folder(3, "Work", true)];
|
||||
assert_eq!(
|
||||
rows(&both),
|
||||
[
|
||||
"Downloaded: Other folders: headers of all mail; text from the last 90 days; attachments when asked",
|
||||
"Kept whole: Every message in INBOX, Work, with attachments",
|
||||
]
|
||||
);
|
||||
assert_eq!(rows(&[folder(1, "INBOX", true)]), ["Kept whole: Every message in INBOX, with attachments"]);
|
||||
assert_eq!(
|
||||
rows(&[folder(2, "Sent", false)]),
|
||||
["Downloaded: Headers of all mail; text from the last 90 days; attachments when asked"]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_needs_an_open_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert!(mail.removing.is_none());
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oauth};
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{Keys, account, adding, closed_port, passed, settle, step_words, type_in, window};
|
||||
use crate::{adding::Step, boxes::Open, kinds::Kind, mail::Mail};
|
||||
|
||||
#[test]
|
||||
fn an_imap_form_says_what_is_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
let check = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)))
|
||||
};
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the email address.");
|
||||
type_in(&mail, cx, |f| &f.address, "ann@example.com");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the user name.");
|
||||
type_in(&mail, cx, |f| &f.user, "ann");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type both server names.");
|
||||
type_in(&mail, cx, |f| &f.incoming, "imap.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "70000");
|
||||
check(cx);
|
||||
assert_eq!(
|
||||
step_words(&mail, cx),
|
||||
"70000 is not a port; use 1 to 65535."
|
||||
);
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "587");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the password.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_follow_security_but_a_typed_one_stays() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Pop, cx);
|
||||
let ports = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let fields = &mail.read(app).adding.as_ref().unwrap().fields;
|
||||
(
|
||||
fields.incoming_port.read(app).text().to_owned(),
|
||||
fields.outgoing_port.read(app).text().to_owned(),
|
||||
)
|
||||
})
|
||||
};
|
||||
assert_eq!(ports(cx), ("995".into(), "587".into()));
|
||||
let secure = |cx: &mut gpui::VisualTestContext, outgoing, security| {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.adding
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_security(outgoing, security, cx)
|
||||
})
|
||||
});
|
||||
};
|
||||
secure(cx, false, Security::StartTls);
|
||||
assert_eq!(ports(cx), ("110".into(), "587".into()));
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "2525");
|
||||
secure(cx, true, Security::Tls);
|
||||
assert_eq!(ports(cx), ("110".into(), "2525".into()));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.close_adding(cx)));
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
assert_eq!(ports(cx), ("993".into(), "587".into()));
|
||||
type_in(&mail, cx, |f| &f.incoming_port, "995");
|
||||
secure(cx, true, Security::Tls);
|
||||
assert_eq!(ports(cx), ("995".into(), "465".into()));
|
||||
secure(cx, false, Security::StartTls);
|
||||
assert_eq!(ports(cx), ("995".into(), "465".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_reports_each_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "ann@example.com");
|
||||
type_in(&mail, cx, |f| &f.user, "ann");
|
||||
type_in(&mail, cx, |f| &f.password, "pw");
|
||||
type_in(&mail, cx, |f| &f.incoming, "127.0.0.1");
|
||||
type_in(&mail, cx, |f| &f.incoming_port, &closed_port().to_string());
|
||||
type_in(&mail, cx, |f| &f.outgoing, "127.0.0.1");
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, &closed_port().to_string());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)));
|
||||
assert_eq!(step_words(&mail, cx), "checking");
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
mail.read(app).adding.as_ref().unwrap().step,
|
||||
Step::Checked { .. }
|
||||
)
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let Step::Checked {
|
||||
checked,
|
||||
account,
|
||||
kept,
|
||||
} = &mail.read(app).adding.as_ref().unwrap().step
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
matches!(checked.incoming, Err(Error::Connect(_))),
|
||||
"{:?}",
|
||||
checked.incoming
|
||||
);
|
||||
assert!(
|
||||
matches!(checked.outgoing, Err(Error::Connect(_))),
|
||||
"{:?}",
|
||||
checked.outgoing
|
||||
);
|
||||
assert_eq!(
|
||||
(account.user.as_str(), account.protocol, kept.as_str()),
|
||||
("ann", Protocol::Imap, "pw")
|
||||
);
|
||||
let said = crate::add_view::failure(checked.incoming.as_ref().unwrap_err(), app);
|
||||
assert!(said.starts_with("cannot connect ("), "{said}");
|
||||
});
|
||||
type_in(&mail, cx, |f| &f.password, "pw2");
|
||||
assert_eq!(step_words(&mail, cx), "editing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icloud_account_takes_apples_servers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::ICloud, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "ann@icloud.com");
|
||||
let account = cx
|
||||
.update(|_, app| mail.read(app).adding.as_ref().unwrap().account(None, app))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(account.incoming.host.as_str(), account.incoming.port),
|
||||
("imap.mail.me.com", 993)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
account.outgoing.host.as_str(),
|
||||
account.outgoing.port,
|
||||
account.outgoing.security
|
||||
),
|
||||
("smtp.mail.me.com", 587, Security::StartTls)
|
||||
);
|
||||
assert_eq!(
|
||||
(account.user.as_str(), account.auth),
|
||||
("ann@icloud.com", Auth::Password)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_without_a_client_id_says_so() {
|
||||
if oauth::google().is_ok() {
|
||||
return eprintln!("SKIP: this build has a Google client ID");
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Gmail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sign_in(cx)));
|
||||
assert_eq!(
|
||||
step_words(&mail, cx),
|
||||
"Gmail sign-in is not in this build: it was built without a Google client ID."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_keychain_keeps_the_account_and_says_why() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys {
|
||||
refuse: Some("locked"),
|
||||
..Keys::default()
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| mail.read(app).adding.is_none());
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
assert_eq!(mail.open, Open::Account(mail.accounts()[0].0));
|
||||
assert_eq!(
|
||||
mail.vault
|
||||
.get("account:ann@example.com")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("pw")
|
||||
);
|
||||
});
|
||||
assert!(keys.kept.lock().unwrap().is_empty());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
mail.vault
|
||||
.get("account:ann@example.com")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("pw")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_is_kept_and_removed_with_its_secret() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys::default();
|
||||
{
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| mail.read(app).adding.is_none());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ANN@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
}
|
||||
assert_eq!(
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("account:ann@example.com")
|
||||
.map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).accounts()[0].1, account("ann@example.com")));
|
||||
open_first(&mail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
cx.update(|_, app| {
|
||||
let removing = mail.read(app).removing.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(removing.address.as_str(), removing.cached.messages),
|
||||
("ann@example.com", 0)
|
||||
);
|
||||
});
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |app| mail.read(app).accounts().is_empty());
|
||||
assert!(keys.kept.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_secret_removal_keeps_the_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let keys = Keys {
|
||||
refuse: Some("locked"),
|
||||
..Keys::default()
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
open_first(&mail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
assert_eq!(
|
||||
Store::open(dir.path()).unwrap().accounts().unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_removal_puts_the_secret_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let inbox = store.mailbox(ann, "INBOX").unwrap();
|
||||
let raw: &[u8] = b"Subject: Kept
|
||||
|
||||
still here
|
||||
";
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid: 1,
|
||||
};
|
||||
let kept = store.put(inbox, &key, &[], Fetched::Whole(raw)).unwrap();
|
||||
drop(store);
|
||||
let keys = Keys::default();
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
// A folder where the original would be set aside.
|
||||
let shard = std::fs::read_dir(dir.path().join("content"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let name = std::fs::read_dir(&shard)
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.file_name();
|
||||
std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap();
|
||||
open_first(&mail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("account:ann@example.com")
|
||||
.map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
let store = store.lock().unwrap();
|
||||
assert_eq!(store.accounts().unwrap().len(), 1);
|
||||
assert_eq!(store.original(kept).unwrap().unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_saves_in_flight_add_an_address_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.save(cx);
|
||||
mail.close_adding(cx);
|
||||
mail.start_adding(window, cx);
|
||||
})
|
||||
});
|
||||
passed(&mail, account("ANN@example.com"), "pw2", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
// Either save may reach the store first.
|
||||
settle(cx, |app| {
|
||||
let mail = mail.read(app);
|
||||
mail.accounts().len() == 1
|
||||
&& !matches!(
|
||||
mail.adding.as_ref().map(|adding| &adding.step),
|
||||
Some(Step::Saving)
|
||||
)
|
||||
});
|
||||
let won = Store::open(dir.path()).unwrap().accounts().unwrap();
|
||||
assert_eq!(won.len(), 1);
|
||||
let (address, secret) = if won[0].1 == account("ann@example.com") {
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
("ann", "pw")
|
||||
} else {
|
||||
assert_eq!(won[0].1, account("ANN@example.com"));
|
||||
cx.update(|_, app| assert!(mail.read(app).adding.is_none()));
|
||||
("ANN", "pw2")
|
||||
};
|
||||
let kept = keys.kept.lock().unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(
|
||||
kept.get(&format!("account:{address}@example.com"))
|
||||
.map(String::as_str),
|
||||
Some(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyboard_opens_another_accounts_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bo = store.add_account(&account("bo@example.com")).unwrap();
|
||||
drop(store);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
// As `mail::open` does.
|
||||
cx.update(|window, app| window.focus(&mail.read(app).focus.clone(), app));
|
||||
for _ in 0..2 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
cx.simulate_keystrokes("tab space");
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| mail.read(app).open) == Open::Account(bo) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("no Tab stop let Space open the second account");
|
||||
}
|
||||
|
||||
/// Through the dialog: back to its last stop, Remove.
|
||||
fn press_remove(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
|
||||
for _ in 0..2 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.simulate_keystrokes("shift-tab enter");
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("enter").unwrap(),
|
||||
});
|
||||
settle(cx, |app| mail.read(app).removing.is_none());
|
||||
}
|
||||
|
||||
/// Opens the first account's page, as a user would.
|
||||
fn open_first(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
|
||||
settle(cx, |app| !mail.read(app).accounts().is_empty());
|
||||
let first = cx.update(|_, app| mail.read(app).accounts()[0].0);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(first), cx)));
|
||||
}
|
||||
|
||||
/// The save lands after a second dialog opened.
|
||||
fn save_then_reopen(fail: bool) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
if fail {
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
let added = store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"));
|
||||
added.unwrap();
|
||||
}
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.save(cx);
|
||||
mail.close_adding(cx);
|
||||
mail.start_adding(window, cx);
|
||||
let adding = mail.adding.as_mut().unwrap();
|
||||
adding.set_kind(Kind::Pop, cx);
|
||||
let address = adding.fields.address.clone();
|
||||
address.update(cx, |field, cx| {
|
||||
field.set_text("bob@example.com".to_owned(), cx)
|
||||
});
|
||||
})
|
||||
});
|
||||
if fail {
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| assert!(mail.read(app).accounts().is_empty()));
|
||||
} else {
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
}
|
||||
cx.update(|_, app| {
|
||||
let adding = mail.read(app).adding.as_ref().unwrap();
|
||||
assert_eq!(adding.kind, Kind::Pop);
|
||||
assert!(matches!(adding.step, Step::Editing));
|
||||
assert_eq!(adding.fields.address.read(app).text(), "bob@example.com");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_save_leaves_a_newer_dialog_alone() {
|
||||
save_then_reopen(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_failed_save_leaves_a_newer_dialog_alone() {
|
||||
save_then_reopen(true);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_gpui_component::mail::Mail as Row;
|
||||
use ely_mail_engine::{AccountId, Fetched, Filter, Key, MailboxId, Role, Store};
|
||||
use gpui::{
|
||||
Entity, ScrollDelta, ScrollWheelEvent, SharedString, TestAppContext, VisualTestContext, point,
|
||||
px, size,
|
||||
};
|
||||
|
||||
use super::{Keys, account, settle, window};
|
||||
use crate::{
|
||||
boxes::{self, Open},
|
||||
mail::Mail,
|
||||
};
|
||||
|
||||
fn message(subject: &str, date: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: Ann <ann@example.com>\r\nSubject: {subject}\r\nDate: {date}\r\n\
|
||||
Content-Type: text/plain\r\n\r\nhi\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], subject: &str, day: u8) {
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid,
|
||||
};
|
||||
let bytes = message(subject, &format!("{day} Jan 2024 10:00:00 +0000"));
|
||||
store
|
||||
.put(mailbox, &key, flags, Fetched::Whole(&bytes))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Ann has an inbox and an archive, Bob an inbox.
|
||||
fn seeded(dir: &std::path::Path) -> (AccountId, MailboxId) {
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bob = store.add_account(&account("bob@example.com")).unwrap();
|
||||
let [ann_inbox, archive] = store
|
||||
.set_folders(
|
||||
ann,
|
||||
&[
|
||||
("INBOX", Some(Role::Inbox)),
|
||||
("Archive", Some(Role::Archive)),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let [bob_inbox] = store
|
||||
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
put(&mut store, ann_inbox, 1, &["\\Seen"], "old", 1);
|
||||
put(&mut store, ann_inbox, 2, &[], "new", 3);
|
||||
put(&mut store, archive, 3, &[], "kept", 2);
|
||||
put(&mut store, bob_inbox, 4, &["\\Flagged"], "bob", 2);
|
||||
(ann, archive)
|
||||
}
|
||||
|
||||
/// The listed subjects, once a list is read.
|
||||
fn listed(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> Option<Vec<String>> {
|
||||
cx.update(|_, app| match &mail.read(app).listing.mails {
|
||||
Some(Ok(rows)) => Some(rows.iter().map(|row| row.subject.to_string()).collect()),
|
||||
Some(Err(error)) => panic!("the list failed: {error}"),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn shows(mail: &Entity<Mail>, cx: &mut VisualTestContext, subjects: &[&str]) {
|
||||
let want: Vec<String> = subjects.iter().map(|each| each.to_string()).collect();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while listed(mail, cx).as_ref() != Some(&want) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"listed {:?}, not {want:?}",
|
||||
listed(mail, cx)
|
||||
);
|
||||
cx.run_until_parked();
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn key_of(mail: &Entity<Mail>, cx: &mut VisualTestContext, subject: &str) -> SharedString {
|
||||
cx.update(|_, app| {
|
||||
let mails = mail.read(app).listing.mails.clone();
|
||||
let rows = mails.unwrap().unwrap();
|
||||
rows.iter()
|
||||
.find(|row| row.subject == subject)
|
||||
.unwrap()
|
||||
.key
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_inbox_is_one_list_newest_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
settle(cx, |app| mail.read(app).inboxes_unread() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_narrow_the_list() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
let unread = Filter {
|
||||
unread: true,
|
||||
..Filter::default()
|
||||
};
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(unread, cx)));
|
||||
shows(&mail, cx, &["new", "bob"]);
|
||||
let flagged = Filter {
|
||||
unread: true,
|
||||
flagged: true,
|
||||
..Filter::default()
|
||||
};
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(flagged, cx)));
|
||||
shows(&mail, cx, &["bob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_lists_its_own_mail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ann, archive) = seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
let open = Open::Mailbox(ann, archive);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
|
||||
shows(&mail, cx, &["kept"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pick_fills_the_reader() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ann, archive) = seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
let (new, bob) = (key_of(&mail, cx, "new"), key_of(&mail, cx, "bob"));
|
||||
let chosen = |cx: &mut VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let chosen = mail.read(app).listing.chosen.as_ref();
|
||||
chosen.map(|row| row.subject.to_string())
|
||||
})
|
||||
};
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(std::slice::from_ref(&new), cx)));
|
||||
assert_eq!(chosen(cx).as_deref(), Some("new"));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[new, bob], cx)));
|
||||
assert_eq!(chosen(cx), None);
|
||||
let open = Open::Mailbox(ann, archive);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
|
||||
cx.update(|_, app| assert!(mail.read(app).listing.selected.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebuilt_mailbox_drops_the_pick() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ann, archive) = seeded(dir.path());
|
||||
// The newest row, whose id SQLite would hand out again.
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
put(&mut store, archive, 9, &[], "picked", 4);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
let open = Open::Mailbox(ann, archive);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
|
||||
shows(&mail, cx, &["picked", "kept"]);
|
||||
let picked = key_of(&mail, cx, "picked");
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[picked], cx)));
|
||||
store.open_mailbox(archive, 2).unwrap();
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 2,
|
||||
uid: 3,
|
||||
};
|
||||
let bytes = message("other", "2 Jan 2024 10:00:00 +0000");
|
||||
store
|
||||
.put(archive, &key, &[], Fetched::Whole(&bytes))
|
||||
.unwrap();
|
||||
drop(store);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.read_mail(cx)));
|
||||
shows(&mail, cx, &["other"]);
|
||||
cx.update(|_, app| {
|
||||
let listing = &mail.read(app).listing;
|
||||
assert!(listing.selected.is_empty(), "kept {:?}", listing.selected);
|
||||
assert!(listing.chosen.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_every_one_of_many_messages_is_quick() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &[]);
|
||||
let rows: Vec<Row> = (0..100_000)
|
||||
.map(|n| Row::undated(n.to_string(), "Ann", "hi"))
|
||||
.collect();
|
||||
let keys: Vec<SharedString> = rows.iter().map(|row| row.key.clone()).collect();
|
||||
let listed = boxes::index(rows);
|
||||
cx.update(|_, app| mail.update(app, |mail, _| mail.listing.show(Some(Ok(listed)))));
|
||||
let started = Instant::now();
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
|
||||
let took = started.elapsed();
|
||||
assert!(took < Duration::from_secs(1), "picking took {took:?}");
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).listing.selected.len(), 100_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_side_scrolls_past_many_folders_to_the_next_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bob = store.add_account(&account("bob@example.com")).unwrap();
|
||||
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
|
||||
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
|
||||
store.set_folders(ann, &folders).unwrap();
|
||||
store
|
||||
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(bob), cx)));
|
||||
cx.simulate_resize(size(px(1100.), px(800.)));
|
||||
settle(cx, |app| {
|
||||
let syncs = &mail.read(app).syncs;
|
||||
syncs.get(&ann).is_some_and(|each| each.folders.len() == 40) && syncs.contains_key(&bob)
|
||||
});
|
||||
let selector = "item-selected-Account settings";
|
||||
let height = cx.update(|window, _| window.viewport_size().height);
|
||||
let before = cx.debug_bounds(selector).expect("Bob's settings row");
|
||||
assert!(before.bottom() > height, "the side fits the window");
|
||||
let at = point(px(100.), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
|
||||
..Default::default()
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let after = cx.debug_bounds(selector).expect("Bob's settings row");
|
||||
assert!(
|
||||
after.bottom() <= height,
|
||||
"Bob's settings are out of reach: {after:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use ely_mail_engine::{Protocol, Store};
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::{Keys, account, adding, closed_port, settle, type_in, window, window_with};
|
||||
use crate::kinds::Kind;
|
||||
|
||||
#[test]
|
||||
fn every_account_syncs_on_open_and_says_why_it_failed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
let mut ann = account("ann@example.com");
|
||||
ann.incoming.port = closed_port();
|
||||
let ann = store.add_account(&ann).unwrap();
|
||||
let mut pop = ely_mail_engine::Account {
|
||||
protocol: Protocol::Pop { keep: true },
|
||||
..account("bo@example.com")
|
||||
};
|
||||
pop.incoming.port = closed_port();
|
||||
let bo = store.add_account(&pop).unwrap();
|
||||
drop(store);
|
||||
let keys = Keys::default();
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend(["ann", "bo"].map(|name| (format!("account:{name}@example.com"), "pw".into())));
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys);
|
||||
settle(cx, |app| {
|
||||
[ann, bo].iter().all(|id| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(id)
|
||||
.is_some_and(|each| each.task.is_none() && each.synced.is_some())
|
||||
})
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
for id in [ann, bo] {
|
||||
let syncing = &mail.read(app).syncs[&id];
|
||||
let failed = syncing.failed.as_deref().unwrap();
|
||||
assert!(failed.starts_with("cannot connect"), "{failed}");
|
||||
assert_eq!(syncing.synced.unwrap().at, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_without_its_secret_says_so() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.failed.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let failed = mail.read(app).syncs[&ann].failed.clone().unwrap();
|
||||
assert_eq!(
|
||||
failed,
|
||||
"credential store failed: no saved secret for account:ann@example.com"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_tls_is_the_sync_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let tls = Err("platform trust store unavailable".to_owned());
|
||||
let (mail, cx) = window_with(&mut cx, dir.path(), Keys::default(), tls);
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.synced.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
assert_eq!(
|
||||
syncing.failed.as_deref(),
|
||||
Some("platform trust store unavailable")
|
||||
);
|
||||
assert!(syncing.task.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pop_account_keeps_a_server_copy_unless_told() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Pop, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "bo@example.com");
|
||||
type_in(&mail, cx, |f| &f.user, "bo");
|
||||
type_in(&mail, cx, |f| &f.incoming, "pop.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
|
||||
let protocol = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let adding = mail.read(app).adding.as_ref().unwrap();
|
||||
adding.account(None, app).unwrap().protocol
|
||||
})
|
||||
};
|
||||
assert_eq!(protocol(cx), Protocol::Pop { keep: true });
|
||||
cx.update(|_, app| mail.update(app, |mail, _| mail.adding.as_mut().unwrap().set_keep(false)));
|
||||
assert_eq!(protocol(cx), Protocol::Pop { keep: false });
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! What the window draws.
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, EmptyState, ErrorView},
|
||||
layout::ScrollArea,
|
||||
overlays::ConfirmDialog,
|
||||
primitives::{IconName, Severity},
|
||||
typography::format,
|
||||
};
|
||||
use ely_mail_engine::{AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Shell;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
boxes::Open,
|
||||
mail::{Mail, text},
|
||||
menu,
|
||||
};
|
||||
|
||||
fn server(server: &Server, cx: &App) -> SharedString {
|
||||
let security = match server.security {
|
||||
Security::Tls => "TLS",
|
||||
Security::StartTls => "STARTTLS",
|
||||
};
|
||||
text(
|
||||
cx,
|
||||
"mail.account.server",
|
||||
&[
|
||||
("host", &server.host),
|
||||
("port", &server.port.to_string()),
|
||||
("security", security),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn auth(auth: Auth, cx: &App) -> SharedString {
|
||||
let key = match auth {
|
||||
Auth::Password => "mail.auth.password",
|
||||
Auth::Google => "mail.auth.google",
|
||||
Auth::Microsoft => "mail.auth.microsoft",
|
||||
};
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
fn add_button(&self, cx: &App) -> Button {
|
||||
Button::new("mail-add", text(cx, "mail.add.open", &[]))
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx))
|
||||
}
|
||||
|
||||
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let error = |key: &str, detail: &str, cx: &App| {
|
||||
ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[]))
|
||||
.detail(detail.to_owned())
|
||||
.into_any_element()
|
||||
};
|
||||
if let Err(detail) = &self.store {
|
||||
return error("mail.store.unopened", detail, cx);
|
||||
}
|
||||
let accounts = match &self.accounts {
|
||||
None => return div().into_any_element(),
|
||||
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
|
||||
Some(Ok(accounts)) => accounts,
|
||||
};
|
||||
if accounts.is_empty() {
|
||||
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
|
||||
.action(self.add_button(cx))
|
||||
.into_any_element();
|
||||
}
|
||||
let Open::Account(open) = self.open else {
|
||||
return self.panes(cx);
|
||||
};
|
||||
let Some((id, account)) = accounts.iter().find(|(id, _)| *id == open) else {
|
||||
log::error!("mail: account {} open but not listed", open.0);
|
||||
return error("mail.store.unread", "", cx);
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let incoming = format!(
|
||||
"{} · {}",
|
||||
protocol_name(account.protocol),
|
||||
server(&account.incoming, cx)
|
||||
);
|
||||
let details = DescriptionList::new()
|
||||
.text(label("mail.account.address"), account.address.clone())
|
||||
.text(label("mail.account.user"), account.user.clone())
|
||||
.text(label("mail.account.incoming"), incoming)
|
||||
.text(
|
||||
label("mail.account.outgoing"),
|
||||
server(&account.outgoing, cx),
|
||||
)
|
||||
.text(label("mail.account.auth"), auth(account.auth, cx));
|
||||
let (id, protocol) = (*id, account.protocol);
|
||||
let (details, failed) = self.sync_rows(id, protocol, details, cx);
|
||||
let remove = Button::new("mail-remove", label("mail.remove.open"))
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
|
||||
let page = div()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.max_w_128()
|
||||
.children(self.paused_banner(cx))
|
||||
.children(failed)
|
||||
.child(details)
|
||||
.child(self.sync_buttons(id, cx))
|
||||
.children(self.offline_switches(id, protocol, cx))
|
||||
.child(div().child(remove));
|
||||
ScrollArea::new("mail-account-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(page)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// When it last synced, what is here, why it failed.
|
||||
fn sync_rows(
|
||||
&self,
|
||||
id: AccountId,
|
||||
protocol: Protocol,
|
||||
details: DescriptionList,
|
||||
cx: &App,
|
||||
) -> (DescriptionList, Option<Banner>) {
|
||||
let Some(syncing) = self.syncs.get(&id) else {
|
||||
return (details, None);
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let last = match (&syncing.task, syncing.synced.and_then(|synced| synced.at)) {
|
||||
(Some(_), _) => label("mail.sync.running"),
|
||||
(None, Some(at)) => local_time(at).into(),
|
||||
(None, None) => label("mail.sync.never"),
|
||||
};
|
||||
let mut details = details.text(label("mail.sync.last"), last);
|
||||
if let Protocol::Pop { keep } = protocol {
|
||||
let server = label(if keep {
|
||||
"mail.sync.kept"
|
||||
} else {
|
||||
"mail.sync.deleted"
|
||||
});
|
||||
details = details
|
||||
.text(label("mail.sync.range"), label("mail.sync.range_pop"))
|
||||
.text(label("mail.sync.server"), server);
|
||||
} else {
|
||||
for (key, value) in imap_range(&syncing.folders, cx) {
|
||||
details = details.text(key, value);
|
||||
}
|
||||
}
|
||||
if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) {
|
||||
for (key, n) in [
|
||||
("mail.sync.uncached", synced.uncached),
|
||||
("mail.sync.partial", synced.partial),
|
||||
] {
|
||||
let value = format!("{key}_value");
|
||||
let words = ely_ui::i18n::counted(cx, &value, n, &[("n", &n.to_string())]);
|
||||
details = details.text(label(key), words);
|
||||
}
|
||||
}
|
||||
let failed = syncing.failed.as_ref().map(|error| {
|
||||
Banner::new(
|
||||
"mail-sync-failed",
|
||||
Severity::Danger,
|
||||
text(cx, "mail.sync.failed", &[("error", error)]),
|
||||
)
|
||||
});
|
||||
(details, failed)
|
||||
}
|
||||
|
||||
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let removing = self.removing.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |mail, cx| {
|
||||
mail.removing = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
let size = format::file_size(removing.cached.bytes, false);
|
||||
let count = removing.cached.messages;
|
||||
let args = [
|
||||
("address", removing.address.as_str()),
|
||||
("n", &count.to_string()),
|
||||
("size", &size),
|
||||
];
|
||||
let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args);
|
||||
let dialog = ConfirmDialog::new(
|
||||
"mail-remove",
|
||||
text(cx, "mail.remove.title", &args),
|
||||
message,
|
||||
dismiss,
|
||||
)
|
||||
.confirm(text(cx, "mail.remove.go", &[]))
|
||||
.destructive()
|
||||
.on_confirm({
|
||||
let removing = removing.clone();
|
||||
move |_, cx| {
|
||||
let removing = removing.clone();
|
||||
this.update(cx, |mail, cx| mail.remove(removing, cx)).ok();
|
||||
}
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rest download, then folders kept whole.
|
||||
pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> {
|
||||
let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline);
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let days = sync::BODY_DAYS.to_string();
|
||||
let mut rows = Vec::new();
|
||||
if whole.is_empty() || !rest.is_empty() {
|
||||
let key = match whole.is_empty() {
|
||||
true => "mail.sync.range_value",
|
||||
false => "mail.sync.range_rest",
|
||||
};
|
||||
rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)])));
|
||||
}
|
||||
if !whole.is_empty() {
|
||||
let sep = label("mail.sync.whole_sep");
|
||||
let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect();
|
||||
let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]);
|
||||
rows.push((label("mail.sync.whole"), value));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn protocol_name(protocol: Protocol) -> &'static str {
|
||||
match protocol {
|
||||
Protocol::Imap => "IMAP",
|
||||
Protocol::Pop { .. } => "POP3",
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Mail {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let left = (!self.accounts().is_empty()).then(|| self.side.clone().into());
|
||||
let read_only = self.read_only.as_ref().map(|reason| {
|
||||
Banner::new(
|
||||
"mail-read-only",
|
||||
Severity::Warning,
|
||||
text(cx, "mail.store.read_only", &[("error", reason)]),
|
||||
)
|
||||
});
|
||||
let shell = Shell::new(AppId::Mail.name(), &self.focus)
|
||||
.left(left)
|
||||
.children(read_only)
|
||||
.child(self.body(cx));
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(menu::CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(
|
||||
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
|
||||
.on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx)))
|
||||
.child(shell)
|
||||
.children(self.add_dialog(cx))
|
||||
.children(self.remove_dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// Local time to the minute.
|
||||
pub fn local_time(at: i64) -> String {
|
||||
match jiff::Timestamp::from_second(at) {
|
||||
Ok(at) => at
|
||||
.to_zoned(jiff::tz::TimeZone::system())
|
||||
.strftime("%Y-%m-%d %H:%M")
|
||||
.to_string(),
|
||||
Err(error) => {
|
||||
log::error!("mail: a sync time out of range: {error}");
|
||||
at.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,7 @@ impl Book {
|
||||
Ok(Some(Clip::Html { html, .. })) => Some(html),
|
||||
Ok(_) => None,
|
||||
Err(error) => {
|
||||
let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]);
|
||||
let text = Book::text(cx, "table.data.clipboard", &[("error", &format!("{error:#}"))]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,6 +65,11 @@ impl Book {
|
||||
.bg(sunken)
|
||||
.on_mouse_move(cx.listener(Self::on_drag_move))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_drag_end))
|
||||
.on_mouse_up_out(MouseButton::Left, cx.listener(|book, _, _, cx| {
|
||||
if book.drag.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.child(div().flex().child(corner).child(column_strip))
|
||||
.child(
|
||||
div().flex_1().min_h_0().flex().child(row_strip).child(
|
||||
|
||||
@@ -9,8 +9,8 @@ use ely_gpui_component::{
|
||||
use ely_lok::{LokView, sheet};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div,
|
||||
px,
|
||||
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
|
||||
Point, Styled as _, div, px,
|
||||
};
|
||||
|
||||
use crate::book::Book;
|
||||
@@ -112,6 +112,7 @@ impl Book {
|
||||
.absolute()
|
||||
.left(corner.x)
|
||||
.top(corner.y)
|
||||
.block_mouse_except_scroll()
|
||||
.child(
|
||||
IconButton::new("table-choices-open", IconName::ChevronDown)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
@@ -121,7 +122,8 @@ impl Book {
|
||||
book.offer_choices(id, (column, row), cx)
|
||||
})),
|
||||
)
|
||||
.h(px(size.max(16.0)))
|
||||
.h(px(size))
|
||||
.min_h(cx.theme().control_height(ControlSize::Sm))
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
@@ -137,6 +139,7 @@ impl Book {
|
||||
.absolute()
|
||||
.left(below.x)
|
||||
.top(below.y)
|
||||
.block_mouse_except_scroll()
|
||||
.max_w(theme.tooltip_max_width())
|
||||
.px_2()
|
||||
.py_1()
|
||||
|
||||
@@ -394,6 +394,7 @@ fn a_delete_stays_with_its_workbook() {
|
||||
}
|
||||
|
||||
mod analysis;
|
||||
mod aids;
|
||||
mod data;
|
||||
mod menu;
|
||||
mod names;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::data::{cells, open, run};
|
||||
use super::*;
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
|
||||
use ely_lok::sheet::Validation;
|
||||
use gpui::{Modifiers, MouseButton, ParentElement as _, Styled as _, VisualTestContext, point, px};
|
||||
|
||||
struct Grid(Entity<Book>);
|
||||
|
||||
impl gpui::Render for Grid {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
|
||||
gpui::div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(grid)
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_help_and_arrow_do_not_select_the_cells_behind_them() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
open(&mut cx, &book);
|
||||
run(&mut cx, &book, "cell.goto", &["E2"]);
|
||||
let rule = Validation {
|
||||
kind: "list".into(),
|
||||
operator: "equal".into(),
|
||||
list: vec!["Red".into(), "Green".into()],
|
||||
dropdown: true,
|
||||
show_input: true,
|
||||
input_message: "Pick a color".into(),
|
||||
error_style: "stop".into(),
|
||||
..Validation::default()
|
||||
};
|
||||
run(&mut cx, &book, "rules.validation", &[&rule.to_json()]);
|
||||
settle(&cx, |app| {
|
||||
book.read(app)
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.extra.rule.as_ref())
|
||||
.is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color")
|
||||
});
|
||||
let window = cx.add_window(|_, _| Grid(book.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
frames(&mut shown);
|
||||
let (view, help, arrow, handle) = shown.update(|window, app| {
|
||||
let view = book.read(app).active_tab().expect("a tab").view.clone();
|
||||
let read = view.read(app);
|
||||
let (x, y, w, h) = read.cell().cursor.expect("the cell cursor");
|
||||
let help = read.origin() + read.place((x, y + h)) + point(px(8.0), px(8.0));
|
||||
// The arrow's lower corner hangs below a default row.
|
||||
let tall = app
|
||||
.theme()
|
||||
.control_height(ControlSize::Sm)
|
||||
.to_pixels(window.rem_size());
|
||||
let corner = point(tall - px(1.0), tall - px(1.0));
|
||||
let arrow = read.origin() + read.place((x + w, y)) + corner;
|
||||
let handle = read.origin() + read.place((x + w, y + h)) - point(px(1.0), px(1.0));
|
||||
(view.clone(), help, arrow, handle)
|
||||
});
|
||||
let sent = shown.update(|_, app| view.read(app).sent());
|
||||
shown.simulate_mouse_down(handle, MouseButton::Left, Modifiers::none());
|
||||
assert!(
|
||||
shown.update(|_, app| matches!(book.read(app).drag, Some(crate::grid::Drag::Fill { .. })))
|
||||
);
|
||||
shown.simulate_mouse_move(help, MouseButton::Left, Modifiers::none());
|
||||
shown.simulate_mouse_up(help, MouseButton::Left, Modifiers::none());
|
||||
assert!(
|
||||
shown.update(|_, app| book.read(app).drag.is_none()),
|
||||
"releasing over help must end the drag"
|
||||
);
|
||||
shown.simulate_click(help, Modifiers::none());
|
||||
assert_eq!(
|
||||
shown.update(|_, app| view.read(app).sent()),
|
||||
sent,
|
||||
"help never sends a grid click"
|
||||
);
|
||||
shown.simulate_mouse_down(arrow, MouseButton::Left, Modifiers::none());
|
||||
assert_eq!(
|
||||
shown.update(|_, app| view.read(app).sent()),
|
||||
sent,
|
||||
"the arrow press stays outside the grid"
|
||||
);
|
||||
shown.simulate_mouse_up(arrow, MouseButton::Left, Modifiers::none());
|
||||
settle(&shown, |app| book.read(app).choices.is_some());
|
||||
frames(&mut shown);
|
||||
shown.update(|_, app| {
|
||||
assert_eq!(view.read(app).cell().address, "E2");
|
||||
assert_eq!(
|
||||
view.read(app).sent(),
|
||||
sent,
|
||||
"the arrow release also stays outside the grid"
|
||||
);
|
||||
assert_eq!(
|
||||
book.read(app).choices.as_ref().expect("the menu").values,
|
||||
["Red", "Green"]
|
||||
);
|
||||
});
|
||||
let red = shown
|
||||
.debug_bounds("menu-row-Red")
|
||||
.expect("the Red menu item");
|
||||
shown.simulate_click(red.center(), Modifiers::none());
|
||||
// An empty F2 adds nothing after E2's tab.
|
||||
assert_eq!(
|
||||
cells(&mut cx, &book, "E2:F2"),
|
||||
"Red",
|
||||
"the value lands in E2 and F2 stays empty"
|
||||
);
|
||||
}
|
||||
@@ -43,11 +43,9 @@ pub(super) fn cells(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -
|
||||
worker
|
||||
.run(&tab.doc, "cell.goto", &[range])
|
||||
.expect("the range");
|
||||
tab.doc
|
||||
.selection_text(&mut worker)
|
||||
.expect("the text")
|
||||
.trim()
|
||||
.to_string()
|
||||
let text = tab.doc.selection_text(&mut worker).expect("the text");
|
||||
// The Windows kernel ends rows with CRLF.
|
||||
text.trim().lines().collect::<Vec<_>>().join("\n")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -113,15 +113,17 @@ fn exports_write_the_shown_sheet_as_text() {
|
||||
run(&mut cx, &book, "cell.goto", &["B1"]);
|
||||
run(&mut cx, &book, "cell.enter", &["a;b"]);
|
||||
let id = cx.update(|app| book.read(app).core.tabs[0].id);
|
||||
// The kernel ends rows the system's way.
|
||||
let end = if cfg!(windows) { "\r\n" } else { "\n" };
|
||||
for (format, name, text) in [
|
||||
(Format::Csv, "out.csv", "3,a;b\n"),
|
||||
(Format::Tsv, "out.tsv", "3\ta;b\n"),
|
||||
(Format::Csv, "out.csv", "3,a;b"),
|
||||
(Format::Tsv, "out.tsv", "3\ta;b"),
|
||||
] {
|
||||
let path = dir.path().join(name);
|
||||
update(&mut cx, &book, |book, cx| book.export_to(id, path.clone(), format, cx));
|
||||
settle(&cx, |_| path.exists());
|
||||
quiet(&cx, &book);
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("reads"), text);
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("reads"), format!("{text}{end}"));
|
||||
}
|
||||
let pdf = dir.path().join("out.pdf");
|
||||
update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx));
|
||||
|
||||
@@ -76,12 +76,26 @@ impl Doc {
|
||||
Unpack::new(&payload).str()
|
||||
}
|
||||
|
||||
/// Completes the function name being typed (TABLE-03).
|
||||
pub fn complete_function(&self, worker: &mut Worker, name: &str) -> io::Result<()> {
|
||||
log::info!("lok: completes a function");
|
||||
let request = Pack::new().u64(worker.doc_id(self)?).str(name).bytes();
|
||||
worker.request(tag::COMPLETE_FUNCTION, &request)?;
|
||||
Ok(())
|
||||
/// Completes `name` if offered; a key takes any, else goes in (TABLE-03).
|
||||
pub fn complete_function(
|
||||
&self,
|
||||
worker: &mut Worker,
|
||||
name: Option<&str>,
|
||||
key: Option<(u32, i32)>,
|
||||
) -> io::Result<bool> {
|
||||
let pack = Pack::new().u64(worker.doc_id(self)?);
|
||||
let pack = match name {
|
||||
Some(name) => pack.u64(1).str(name),
|
||||
None => pack.u64(0),
|
||||
};
|
||||
// Key code 0: no key, as for a click.
|
||||
let (char_code, key_code) = key.unwrap_or((0, 0));
|
||||
let request = pack
|
||||
.i64(i64::from(char_code))
|
||||
.i64(i64::from(key_code))
|
||||
.bytes();
|
||||
let reply = worker.request(tag::COMPLETE_FUNCTION, &request)?;
|
||||
Ok(Unpack::new(&reply).u64()? == 1)
|
||||
}
|
||||
|
||||
/// Shows sheet `part`, from 0.
|
||||
|
||||
@@ -152,3 +152,30 @@ fn one_cell_in_another_block_filters_that_block() {
|
||||
let entries: Vec<String> = sheet::filter_values(&json).expect("parses").entries.into_iter().map(|e| e.text).collect();
|
||||
assert_eq!(entries, ["9", "10"], "an AutoFilter from D1 is D's");
|
||||
}
|
||||
|
||||
/// A text-only column: no cell looks like a header.
|
||||
const WORDS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
|
||||
<office:body><office:spreadsheet><table:table table:name="S">
|
||||
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row>
|
||||
</table:table></office:spreadsheet></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn a_whole_column_without_a_clear_header_filters_from_its_first_row() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open_book(WORDS);
|
||||
worker.run(&doc, "cell.goto", &["A:A"]).expect("marks the column");
|
||||
worker.run(&doc, "data.autofilter", &[]).expect("filters without asking");
|
||||
doc.paint_tile(&mut worker, (0, 0, 3840, 3840), (256, 256))
|
||||
.expect("the buttons paint");
|
||||
// The whole column holds blanks too.
|
||||
assert_eq!(entries(&mut worker, &doc), ["", "a", "b"], "the first row heads");
|
||||
}
|
||||
@@ -50,8 +50,8 @@ fn typing_a_formula_offers_functions_usage_and_marks() {
|
||||
sum.is_some_and(|f| f.signature.contains(", ")),
|
||||
"the signature takes the Excel separator: {sum:?}"
|
||||
);
|
||||
doc.complete_function(&mut worker, "SUMIF")
|
||||
.expect("the completion");
|
||||
let taken = doc.complete_function(&mut worker, Some("SUMIF"), None);
|
||||
assert!(taken.expect("the completion"), "SUMIF is on offer");
|
||||
let done = poll_until(&mut worker, &doc, &offered, SETTLE, |seen| {
|
||||
seen.cell.formula == "=SUMIF()"
|
||||
});
|
||||
|
||||
@@ -103,6 +103,24 @@ fn size_layout_master_and_theme_change_in_one_step() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #2: more masters than slides, a slide hidden.
|
||||
#[test]
|
||||
fn the_master_view_lists_its_masters() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open_deck();
|
||||
worker.run(&doc, "slide.hide", &[]).expect("hides One");
|
||||
worker.run(&doc, "master.edit", &[]).expect("the master view");
|
||||
worker.run(&doc, "master.insert", &[]).expect("a master");
|
||||
worker.run(&doc, "master.insert", &[]).expect("another");
|
||||
let masters = design(&mut worker, &doc).masters;
|
||||
assert_eq!(masters.len(), 3, "more masters than slides");
|
||||
let shown: Vec<(String, bool)> = masters.into_iter().map(|name| (name, true)).collect();
|
||||
assert_eq!(slides(&mut worker, &doc), shown, "masters, all shown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_master_edit_shows_on_its_slides() {
|
||||
if install().is_none() {
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::ffi::Document;
|
||||
|
||||
mod cell;
|
||||
mod parse;
|
||||
pub use cell::{Cell, Function, Mark, Request};
|
||||
pub use cell::{Cell, Function, Mark, Request, offered};
|
||||
use cell::{functions_of, marks_of, request_of, usage_of};
|
||||
pub use parse::A11y;
|
||||
use parse::{
|
||||
|
||||
@@ -5,6 +5,19 @@ use serde_json::Value;
|
||||
use super::Rect;
|
||||
use crate::ipc::{Pack, Unpack};
|
||||
|
||||
/// The function names `id` offers now.
|
||||
pub fn offered(id: u64) -> Vec<String> {
|
||||
let slots = super::slots().lock().expect("the events lock");
|
||||
slots.get(&id).map_or_else(Vec::new, |slot| {
|
||||
slot.batch
|
||||
.cell
|
||||
.functions
|
||||
.iter()
|
||||
.map(|function| function.name.clone())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// What a spreadsheet reports about the current cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Cell {
|
||||
|
||||
@@ -123,6 +123,7 @@ pub struct LokView {
|
||||
pointer: gpui::CursorStyle,
|
||||
selection: Vec<Rect>,
|
||||
cell: crate::Cell,
|
||||
completion: formula::Completion,
|
||||
/// Whether the kernel was told Ely's scheme.
|
||||
lit: bool,
|
||||
/// The zoom follows the viewport so a page fits.
|
||||
@@ -218,6 +219,7 @@ impl LokView {
|
||||
pointer: gpui::CursorStyle::Arrow,
|
||||
selection: Vec::new(),
|
||||
cell: crate::Cell::default(),
|
||||
completion: formula::Completion::default(),
|
||||
lit: false,
|
||||
fit: false,
|
||||
turning: None,
|
||||
@@ -392,6 +394,7 @@ impl LokView {
|
||||
self.picked = 0;
|
||||
}
|
||||
self.cell = batch.cell;
|
||||
self.completion.reported(batch.revision);
|
||||
self.graphic = batch.graphic;
|
||||
self.read_borders(batch.table, batch.revision);
|
||||
self.review = batch.review;
|
||||
|
||||
@@ -82,7 +82,7 @@ pub(super) fn mark_of(html: &str) -> Option<&str> {
|
||||
|
||||
/// Pastes the system clipboard.
|
||||
pub(super) fn paste(worker: &mut Worker, doc: &Doc) -> io::Result<()> {
|
||||
let Some(clip) = clipboard::read().map_err(io::Error::other)? else {
|
||||
let Some(clip) = clipboard::read().map_err(|error| io::Error::other(format!("{error:#}")))? else {
|
||||
return Ok(());
|
||||
};
|
||||
let (mime, data) = flavor_of(clip);
|
||||
|
||||
@@ -5,11 +5,17 @@ use ely_gpui_component::{
|
||||
primitives::raise,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, Hsla, IntoElement, Keystroke, Modifiers, ParentElement, Pixels,
|
||||
anchored, rgb,
|
||||
AnyElement, Bounds, Context, Hsla, InteractiveElement, IntoElement, Keystroke, Modifiers,
|
||||
ParentElement, Pixels, anchored, div, rgb,
|
||||
};
|
||||
|
||||
use super::{LokView, input::Input};
|
||||
use super::{
|
||||
LokView,
|
||||
input::{Input, vcl_key},
|
||||
};
|
||||
|
||||
mod completion;
|
||||
pub(super) use completion::Completion;
|
||||
|
||||
impl LokView {
|
||||
/// The sheet shown, which reference marks follow.
|
||||
@@ -48,7 +54,7 @@ impl LokView {
|
||||
/// Up and Down walk the offered functions.
|
||||
pub(super) fn walk(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
|
||||
let count = self.cell.functions.len();
|
||||
if count == 0 || keystroke.modifiers != Modifiers::none() {
|
||||
if count == 0 || self.completion.pending() || keystroke.modifiers != Modifiers::none() {
|
||||
return false;
|
||||
}
|
||||
self.picked = match keystroke.key.as_str() {
|
||||
@@ -60,32 +66,46 @@ impl LokView {
|
||||
true
|
||||
}
|
||||
|
||||
/// Takes the chosen function, in order with typing.
|
||||
pub(super) fn complete(&mut self) -> bool {
|
||||
let Some(item) = self.offered().into_iter().nth(self.picked) else {
|
||||
return false;
|
||||
};
|
||||
log::info!("lok: completes {}", item.label);
|
||||
self.send(Input::Complete(item.label.to_string()));
|
||||
true
|
||||
/// Enter or Tab: the kernel's function then, else the key.
|
||||
pub(super) fn complete_or_press(&mut self, keystroke: &Keystroke) {
|
||||
let key = vcl_key(keystroke).expect("Enter and Tab are vcl keys");
|
||||
let name = (!self.completion.pending())
|
||||
.then(|| self.offered().into_iter().nth(self.picked))
|
||||
.flatten()
|
||||
.map(|item| item.label.to_string());
|
||||
self.take(name, Some(key));
|
||||
}
|
||||
|
||||
/// A clicked function, if the kernel still offers it then.
|
||||
fn complete_function(&mut self, name: &str) {
|
||||
if !self.completion.pending() {
|
||||
self.take(Some(name.to_string()), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues the choice in order with typing; the worker decides.
|
||||
fn take(&mut self, name: Option<String>, key: Option<(u32, i32)>) {
|
||||
self.send(Input::Complete { name, key });
|
||||
self.completion.queued(self.sent);
|
||||
self.picked = 0;
|
||||
}
|
||||
|
||||
/// The offered functions, else the call's usage, under the cell.
|
||||
pub(super) fn hints(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
if self.completion.pending() {
|
||||
return None;
|
||||
}
|
||||
let cell = self.screen_rect(self.cell.cursor?);
|
||||
let hint = if self.cell.functions.is_empty() {
|
||||
SignatureHelp::new("lok-view-usage", [usage(&self.cell.usage)?]).into_any_element()
|
||||
} else {
|
||||
let this = cx.entity();
|
||||
let offered = self.offered();
|
||||
let labels: Vec<_> = offered.iter().map(|item| item.label.clone()).collect();
|
||||
CompletionMenu::new("lok-view-functions", offered)
|
||||
.selected(self.picked)
|
||||
.on_pick(move |item, _, cx| {
|
||||
let at = labels.iter().position(|label| *label == item.label);
|
||||
this.update(cx, |view, cx| {
|
||||
view.picked = at.expect("a picked item is offered");
|
||||
view.complete();
|
||||
view.complete_function(&item.label);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
@@ -94,7 +114,7 @@ impl LokView {
|
||||
let place = anchored()
|
||||
.position(cell.bottom_left())
|
||||
.snap_to_window()
|
||||
.child(hint);
|
||||
.child(div().block_mouse_except_scroll().child(hint));
|
||||
Some(raise("lok-view-hints", place).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Completion {
|
||||
pending: Option<(u64, Option<u64>)>,
|
||||
reported: u64,
|
||||
}
|
||||
|
||||
impl Completion {
|
||||
pub(crate) fn pending(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn queued(&mut self, seq: u64) {
|
||||
self.pending = Some((seq, None));
|
||||
}
|
||||
|
||||
pub(crate) fn reported(&mut self, revision: u64) {
|
||||
self.reported = revision;
|
||||
self.finish();
|
||||
}
|
||||
|
||||
pub(crate) fn landed(&mut self, seq: u64, revision: Option<u64>) {
|
||||
if self.pending.is_some_and(|(waiting, _)| waiting == seq) {
|
||||
self.pending = revision.map(|revision| (seq, Some(revision)));
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
// Inline callbacks and serialized output preserve completion revisions.
|
||||
if self
|
||||
.pending
|
||||
.is_some_and(|(_, revision)| revision.is_some_and(|revision| self.reported >= revision))
|
||||
{
|
||||
self.pending = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Completion;
|
||||
|
||||
#[test]
|
||||
fn an_old_snapshot_cannot_reopen_an_accepted_menu() {
|
||||
let mut completion = Completion::default();
|
||||
completion.reported(10);
|
||||
completion.queued(7);
|
||||
completion.reported(10);
|
||||
assert!(completion.pending());
|
||||
completion.landed(7, Some(11));
|
||||
assert!(completion.pending());
|
||||
completion.reported(10);
|
||||
assert!(completion.pending());
|
||||
completion.reported(11);
|
||||
assert!(!completion.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_before_the_reply_waits_for_the_reply() {
|
||||
let mut completion = Completion::default();
|
||||
completion.queued(7);
|
||||
completion.reported(11);
|
||||
assert!(completion.pending());
|
||||
completion.landed(7, Some(11));
|
||||
assert!(!completion.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_before_the_snapshot_waits_for_the_snapshot() {
|
||||
let mut completion = Completion::default();
|
||||
completion.queued(7);
|
||||
completion.landed(7, Some(11));
|
||||
assert!(completion.pending());
|
||||
completion.reported(11);
|
||||
assert!(!completion.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_input_cannot_acknowledge_the_completion() {
|
||||
let mut completion = Completion::default();
|
||||
completion.queued(7);
|
||||
completion.reported(20);
|
||||
completion.landed(6, Some(20));
|
||||
assert!(completion.pending());
|
||||
completion.landed(8, None);
|
||||
assert!(completion.pending());
|
||||
completion.landed(7, Some(19));
|
||||
assert!(!completion.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_completion_allows_retry() {
|
||||
let mut completion = Completion::default();
|
||||
completion.queued(7);
|
||||
completion.landed(7, None);
|
||||
assert!(!completion.pending());
|
||||
completion.queued(8);
|
||||
assert!(completion.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_completion_requires_its_own_snapshot() {
|
||||
let mut completion = Completion::default();
|
||||
completion.reported(11);
|
||||
completion.queued(7);
|
||||
completion.landed(7, Some(11));
|
||||
assert!(!completion.pending());
|
||||
completion.queued(8);
|
||||
completion.landed(8, Some(12));
|
||||
assert!(completion.pending());
|
||||
completion.reported(11);
|
||||
assert!(completion.pending());
|
||||
completion.reported(12);
|
||||
assert!(!completion.pending());
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,11 @@ pub(super) enum Input {
|
||||
Paste,
|
||||
/// Values or formulas alone: `paste.values` or `paste.formulas`.
|
||||
PasteOnly(&'static str),
|
||||
/// A function the kernel offered, taken.
|
||||
Complete(String),
|
||||
/// A function the kernel offers then, else Enter or Tab.
|
||||
Complete {
|
||||
name: Option<String>,
|
||||
key: Option<(u32, i32)>,
|
||||
},
|
||||
/// The kernel's color scheme; not an edit.
|
||||
Theme(&'static str),
|
||||
/// Commands in order, from one read.
|
||||
@@ -72,6 +75,8 @@ pub(super) enum Input {
|
||||
pub(super) enum Back {
|
||||
Copied(String),
|
||||
Shown(usize),
|
||||
/// Whether a function was taken.
|
||||
Completed(bool),
|
||||
}
|
||||
|
||||
impl Input {
|
||||
@@ -92,7 +97,7 @@ impl Input {
|
||||
| Input::Paste
|
||||
| Input::PasteOnly(_)
|
||||
| Input::Drag { .. }
|
||||
| Input::Complete(_) => true,
|
||||
| Input::Complete { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +206,10 @@ fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<Back
|
||||
Input::Drag { from, to } => worker.drag_graphic(doc, from, to),
|
||||
Input::Visible(area, scale) => doc.visible_area(worker, area, scale),
|
||||
Input::Part(part) => doc.show_sheet(worker, part),
|
||||
Input::Complete(name) => doc.complete_function(worker, &name),
|
||||
Input::Complete { name, key } => {
|
||||
let taken = doc.complete_function(worker, name.as_deref(), key)?;
|
||||
return Ok(Some(Back::Completed(taken)));
|
||||
}
|
||||
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
|
||||
Input::Text { commit, text } => worker.post_text(doc, commit, &text),
|
||||
Input::Copy { cut } => {
|
||||
@@ -270,6 +278,10 @@ impl LokView {
|
||||
|
||||
/// Posts a non-text key; text waits for the IME.
|
||||
pub(super) fn key_down(&mut self, keystroke: &Keystroke) -> bool {
|
||||
if keystroke.key == "enter" && keystroke.modifiers == Modifiers::none() {
|
||||
self.complete_or_press(keystroke);
|
||||
return true;
|
||||
}
|
||||
let Some((char_code, key_code)) = vcl_key(keystroke) else {
|
||||
return false;
|
||||
};
|
||||
@@ -307,10 +319,14 @@ impl LokView {
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let completing = self.completion.pending();
|
||||
if self.walk(&event.keystroke, cx)
|
||||
|| self.turn_key(&event.keystroke)
|
||||
|| self.key_down(&event.keystroke)
|
||||
{
|
||||
if !completing && self.completion.pending() {
|
||||
cx.notify();
|
||||
}
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
@@ -425,10 +441,14 @@ impl LokView {
|
||||
self.send(Input::Paste);
|
||||
}
|
||||
|
||||
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
|
||||
if !self.complete() {
|
||||
self.key_down(&Keystroke::parse("tab").expect("a keystroke"));
|
||||
}
|
||||
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.next_field();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Tab takes the offered function, else moves on.
|
||||
pub(super) fn next_field(&mut self) {
|
||||
self.complete_or_press(&Keystroke::parse("tab").expect("a keystroke"));
|
||||
}
|
||||
|
||||
pub(super) fn on_previous_field(
|
||||
|
||||
@@ -107,6 +107,9 @@ impl LokView {
|
||||
}
|
||||
self.edited += u64::from(edits);
|
||||
let (outcome, now) = outcome;
|
||||
// A plain key took nothing; the menu may return.
|
||||
let taken = matches!(outcome, Ok(Some(Back::Completed(true))));
|
||||
self.completion.landed(seq, if taken { now } else { None });
|
||||
if edits {
|
||||
self.clock.landed(now);
|
||||
}
|
||||
@@ -118,7 +121,7 @@ impl LokView {
|
||||
self.part = part;
|
||||
self.invalidate(cx);
|
||||
}
|
||||
Ok(Some(Back::Shown(_))) => {}
|
||||
Ok(Some(Back::Shown(_) | Back::Completed(_))) => {}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
log::error!("lok: input failed ({:?})", error.kind());
|
||||
|
||||
@@ -46,7 +46,9 @@ impl Render for LokView {
|
||||
let focus = self.focus.clone();
|
||||
let colors = &cx.theme().colors;
|
||||
let paper = super::paper();
|
||||
let (selection_color, caret_color) = (colors.selection, colors.focus);
|
||||
// Opaque overlays hide glyphs already painted in the tiles.
|
||||
let selection_color = colors.selection.alpha(colors.selection.a.min(0.3));
|
||||
let caret_color = colors.focus;
|
||||
let canvas_color = colors.sunken;
|
||||
let caret_width = cx.theme().caret_width().to_pixels(window.rem_size());
|
||||
self.handle_size = cx.theme().handle_hit().to_pixels(window.rem_size());
|
||||
|
||||
@@ -4,6 +4,7 @@ mod borders;
|
||||
mod clip;
|
||||
mod commands;
|
||||
mod fit;
|
||||
mod formula;
|
||||
mod input;
|
||||
mod large;
|
||||
mod show_borders;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::*;
|
||||
use gpui::{Keystroke, TestAppContext};
|
||||
|
||||
fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !cx.executor().tick() {
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_millis(20));
|
||||
}
|
||||
if cx.update(|app| done(app)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_completes_the_selected_function_without_leaving_the_cell() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
let (view, _) = cx.update(|app| {
|
||||
live_view_of(
|
||||
app,
|
||||
&install,
|
||||
"book.fods",
|
||||
crate::client::tests::input::FODS,
|
||||
)
|
||||
});
|
||||
let (_, window) = cx.add_window_view(|_, _| Host(view.clone()));
|
||||
window.run_until_parked();
|
||||
window.update(|window, app| {
|
||||
let focus = view.read(app).focus_handle().clone();
|
||||
focus.focus(window, app);
|
||||
});
|
||||
assert!(settle(window, |app| !view
|
||||
.read(app)
|
||||
.cell
|
||||
.address
|
||||
.is_empty()));
|
||||
let address = window.update(|_, app| view.read(app).cell.address.clone());
|
||||
view.update(window, |view, _| view.commit(None, "=lo"));
|
||||
assert!(settle(window, |app| {
|
||||
let functions = &view.read(app).cell.functions;
|
||||
functions.iter().any(|item| item.name == "LOOKUP")
|
||||
&& functions.iter().any(|item| item.name == "LOG")
|
||||
}));
|
||||
// Keep two valid offers to exercise keyboard selection.
|
||||
view.update(window, |view, cx| {
|
||||
view.cell
|
||||
.functions
|
||||
.retain(|item| item.name == "LOG" || item.name == "LOOKUP");
|
||||
cx.notify();
|
||||
});
|
||||
window.simulate_keystrokes("down enter");
|
||||
assert!(
|
||||
settle(window, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.formula == "=LOOKUP()" && view.cell.functions.is_empty()
|
||||
}),
|
||||
"Enter inserts the selected function"
|
||||
);
|
||||
window.update(|_, app| {
|
||||
assert_eq!(
|
||||
view.read(app).cell.address,
|
||||
address,
|
||||
"completion stays in the cell"
|
||||
);
|
||||
view.update(app, |view, _| {
|
||||
view.commit(None, "1,1,1");
|
||||
});
|
||||
});
|
||||
assert!(settle(window, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.formula == "=LOOKUP(1,1,1)"
|
||||
}));
|
||||
window.simulate_keystrokes("enter");
|
||||
assert!(
|
||||
settle(window, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.address != address
|
||||
}),
|
||||
"Enter without candidates commits and moves to the next cell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepting_a_candidate_consumes_it_before_the_worker_answers() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| {
|
||||
live_view_of(
|
||||
app,
|
||||
&install,
|
||||
"book.fods",
|
||||
crate::client::tests::input::FODS,
|
||||
)
|
||||
});
|
||||
cx.update(|app| view.update(app, |view, _| view.commit(None, "=look")));
|
||||
assert!(settle(&cx, |app| view
|
||||
.read(app)
|
||||
.cell
|
||||
.functions
|
||||
.iter()
|
||||
.any(|item| item.name == "LOOKUP")));
|
||||
let held = worker.lock().expect("the worker lock");
|
||||
cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
let old = Events {
|
||||
revision: view.doc.rev,
|
||||
cell: view.cell.clone(),
|
||||
..Events::default()
|
||||
};
|
||||
let before = view.sent();
|
||||
assert!(view.key_down(&Keystroke::parse("enter").expect("Enter")));
|
||||
view.apply_batch(old, None, cx);
|
||||
assert!(
|
||||
view.completion.pending(),
|
||||
"an old event must not reoffer an accepted candidate"
|
||||
);
|
||||
assert_eq!(view.sent(), before + 1);
|
||||
assert!(view.key_down(&Keystroke::parse("enter").expect("Enter")));
|
||||
assert_eq!(
|
||||
view.sent(),
|
||||
before + 2,
|
||||
"the second Enter is queued normally"
|
||||
);
|
||||
})
|
||||
});
|
||||
drop(held);
|
||||
assert!(
|
||||
settle(&cx, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.address == "A2"
|
||||
}),
|
||||
"the second Enter commits and moves down"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_or_tab_after_queued_typing_follows_the_kernel_not_the_menu() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
for (key, to) in [("enter", "A2"), ("tab", "B1")] {
|
||||
let cx = TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| {
|
||||
live_view_of(
|
||||
app,
|
||||
&install,
|
||||
"book.fods",
|
||||
crate::client::tests::input::FODS,
|
||||
)
|
||||
});
|
||||
cx.update(|app| view.update(app, |view, _| view.commit(None, "=sum")));
|
||||
assert!(settle(&cx, |app| view
|
||||
.read(app)
|
||||
.cell
|
||||
.functions
|
||||
.iter()
|
||||
.any(|item| item.name == "SUM")));
|
||||
// The worker waits; the menu still shows SUM.
|
||||
let held = worker.lock().expect("the worker lock");
|
||||
cx.update(|app| {
|
||||
view.update(app, |view, _| {
|
||||
for text in ["(", "1", ")"] {
|
||||
view.commit(None, text);
|
||||
}
|
||||
match key {
|
||||
"enter" => assert!(view.key_down(&Keystroke::parse(key).expect("a key"))),
|
||||
_ => view.next_field(),
|
||||
}
|
||||
})
|
||||
});
|
||||
drop(held);
|
||||
assert!(
|
||||
settle(&cx, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.address == to
|
||||
}),
|
||||
"{key} commits =sum(1) and moves to {to}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct Host(Entity<LokView>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_function_does_not_send_mouse_input_to_the_sheet() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
let (view, _) = cx.update(|app| {
|
||||
live_view_of(
|
||||
app,
|
||||
&install,
|
||||
"book.fods",
|
||||
crate::client::tests::input::FODS,
|
||||
)
|
||||
});
|
||||
let (_, window) = cx.add_window_view(|_, _| Host(view.clone()));
|
||||
window.run_until_parked();
|
||||
view.update(window, |view, _| view.commit(None, "=look"));
|
||||
assert!(settle(window, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.functions.len() == 1 && view.cell.functions[0].name == "LOOKUP"
|
||||
}));
|
||||
window.run_until_parked();
|
||||
let (on, address, sent) = window.update(|_, app| {
|
||||
let view = view.read(app);
|
||||
let cell = view.screen_rect(view.cell.cursor.expect("the cell cursor"));
|
||||
(
|
||||
cell.bottom_left() + point(px(12.0), px(12.0)),
|
||||
view.cell.address.clone(),
|
||||
view.sent(),
|
||||
)
|
||||
});
|
||||
window.simulate_mouse_down(on, gpui::MouseButton::Left, Default::default());
|
||||
window.update(|_, app| {
|
||||
let view = view.read(app);
|
||||
assert_eq!(view.sent(), sent, "the sheet never receives the press");
|
||||
assert!(
|
||||
!view.dragging,
|
||||
"the candidate press never starts a sheet drag"
|
||||
);
|
||||
});
|
||||
// Change candidates without repainting the already pressed row.
|
||||
view.update(window, |view, _| {
|
||||
view.cell.functions.insert(
|
||||
0,
|
||||
crate::Function {
|
||||
name: "LOG".into(),
|
||||
signature: String::new(),
|
||||
description: String::new(),
|
||||
},
|
||||
);
|
||||
});
|
||||
window.simulate_mouse_up(on, gpui::MouseButton::Left, Default::default());
|
||||
assert!(
|
||||
settle(window, |app| {
|
||||
let view = view.read(app);
|
||||
view.settled() && view.cell.formula == "=LOOKUP()"
|
||||
}),
|
||||
"the candidate receives the click"
|
||||
);
|
||||
window.update(|_, app| {
|
||||
let view = view.read(app);
|
||||
assert_eq!(view.cell.address, address, "the active cell stays put");
|
||||
assert_eq!(view.sent(), sent + 1, "only completion reaches the sheet");
|
||||
});
|
||||
}
|
||||
@@ -247,6 +247,12 @@ fn the_clipboard_moves_html_tables_and_images() {
|
||||
text.contains(&"left".to_string()) && text.contains(&"right".to_string()),
|
||||
"{text:?}"
|
||||
);
|
||||
// Plain text from another app, with no HTML beside it.
|
||||
clipboard::write(&Clip::Text("outside words".into())).expect("the text writes");
|
||||
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
|
||||
flush(&cx);
|
||||
let text = saved(&view, &mut cx);
|
||||
assert!(text.iter().any(|line| line.contains("outside words")), "{text:?}");
|
||||
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
|
||||
|
||||
@@ -83,6 +83,93 @@ fn a_dark_app_paints_the_page_white() {
|
||||
assert!(light > 240, "the view paints the page white: {light}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_keeps_document_text_readable_across_themes() {
|
||||
use ely_gpui_component::{
|
||||
devtools::{contrast, over},
|
||||
theme::{ActiveTheme, Mode, Palette, Theme},
|
||||
};
|
||||
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
let (view, worker) = cx.update(|app| live_view(app, &install));
|
||||
let doc = cx.update(|app| view.read(app).doc.clone());
|
||||
let shown = view.clone();
|
||||
let window = cx.add_window(move |_, _| Host(shown));
|
||||
worker
|
||||
.lock()
|
||||
.expect("the worker")
|
||||
.run(&doc, "edit.select_all", &[])
|
||||
.expect("select all");
|
||||
assert!(
|
||||
settle(&cx, |app| !view.read(app).selection.is_empty()),
|
||||
"the selection arrives"
|
||||
);
|
||||
|
||||
let mut themes = vec![
|
||||
("dark".to_string(), Mode::Dark, None),
|
||||
("light".to_string(), Mode::Light, None),
|
||||
];
|
||||
for (name, mode, color) in [
|
||||
("omarchy dark", Mode::Dark, 0x292e42),
|
||||
("omarchy light", Mode::Light, 0xccd0da),
|
||||
] {
|
||||
let mut palette = match mode {
|
||||
Mode::Dark => Palette::dark(false),
|
||||
Mode::Light => Palette::light(false),
|
||||
};
|
||||
palette.selection = gpui::rgb(color).into();
|
||||
themes.push((name.to_string(), mode, Some(palette)));
|
||||
}
|
||||
let mut translucent = Palette::dark(false);
|
||||
translucent.selection = gpui::rgb(0x292e42).into();
|
||||
translucent.selection.a = 0.15;
|
||||
themes.push(("translucent".to_string(), Mode::Dark, Some(translucent)));
|
||||
|
||||
for (name, mode, palette) in themes {
|
||||
cx.update(|app| {
|
||||
Theme::set_palette(mode, palette, app);
|
||||
Theme::set_mode_now(mode, app);
|
||||
});
|
||||
cx.update_window(window.into(), |_, window, app| {
|
||||
window.draw(app).clear(app);
|
||||
let source = app.theme().colors.selection;
|
||||
let selected: Vec<_> = window
|
||||
.painted_quads()
|
||||
.into_iter()
|
||||
.filter_map(|quad| quad.background.as_solid())
|
||||
.filter(|color| color.alpha(1.0) == source.alpha(1.0))
|
||||
.collect();
|
||||
assert!(!selected.is_empty(), "{name}: the selection is painted");
|
||||
for color in selected {
|
||||
for (text, background) in [(gpui::black(), paper()), (gpui::white(), gpui::black())]
|
||||
{
|
||||
let ratio = contrast(over(color, text), over(color, background));
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{name}: selected text stays readable: {ratio}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
color.a > 0.0 && color.a <= source.a,
|
||||
"{name}: the theme's opacity is respected"
|
||||
);
|
||||
if source.a < 0.3 {
|
||||
assert_eq!(
|
||||
color.a, source.a,
|
||||
"{name}: a translucent theme stays unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("the window lives");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_tile_reports_and_retries_on_a_move() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{
|
||||
use crate::ffi::{Document, Lok};
|
||||
use crate::ipc::{self, Pack, Unpack, tag};
|
||||
|
||||
mod complete;
|
||||
mod events;
|
||||
#[cfg(unix)]
|
||||
mod fatal;
|
||||
@@ -264,13 +265,7 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
|
||||
.and_then(|id| with_doc(id, |doc| doc.selection_text()))
|
||||
.and_then(|text| text)
|
||||
.map(|text| Pack::new().str(&text).bytes()),
|
||||
tag::COMPLETE_FUNCTION => unpack
|
||||
.u64()
|
||||
.and_then(|id| {
|
||||
let name = unpack.str()?;
|
||||
with_doc(id, |doc| doc.complete_function(&name)).and_then(|done| done)
|
||||
})
|
||||
.map(|()| Vec::new()),
|
||||
tag::COMPLETE_FUNCTION => complete::complete(unpack),
|
||||
tag::SET_AUTHOR => unpack
|
||||
.str()
|
||||
.and_then(|name| lok.set_option("author", &name))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! A function the kernel offers, taken in input order (TABLE-03).
|
||||
|
||||
use std::io;
|
||||
|
||||
use super::with_doc;
|
||||
use crate::ipc::{Pack, Unpack};
|
||||
|
||||
/// Takes an offered function, else posts Enter or Tab; 1 if taken.
|
||||
pub(super) fn complete(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
|
||||
let id = unpack.u64()?;
|
||||
let name = match unpack.u64()? {
|
||||
0 => None,
|
||||
_ => Some(unpack.str()?),
|
||||
};
|
||||
let (char_code, key_code) = (unpack.i64()?, unpack.i64()?);
|
||||
// No key: a click; else Enter (1280) or Tab (1282).
|
||||
if !matches!(key_code, 0 | 1280 | 1282) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"a completion takes Enter or Tab",
|
||||
));
|
||||
}
|
||||
// Earlier input's callbacks already landed here.
|
||||
let offered = crate::events::offered(id);
|
||||
let take = match name {
|
||||
Some(name) if offered.contains(&name) => Some(name),
|
||||
// As the menu orders them: shortest first.
|
||||
_ if key_code != 0 => offered.into_iter().min_by_key(String::len),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(name) = take {
|
||||
log::info!("lok-worker: completes a function");
|
||||
with_doc(id, |doc| doc.complete_function(&name))??;
|
||||
return Ok(Pack::new().u64(1).bytes());
|
||||
}
|
||||
if key_code != 0 {
|
||||
with_doc(id, |doc| {
|
||||
doc.post_key(0, char_code as i32, key_code as i32);
|
||||
doc.post_key(1, char_code as i32, key_code as i32);
|
||||
})?;
|
||||
}
|
||||
Ok(Pack::new().u64(0).bytes())
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "ely-mail-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
form_urlencoded = "1"
|
||||
getrandom.workspace = true
|
||||
html5ever.workspace = true
|
||||
imap-proto.workspace = true
|
||||
jiff.workspace = true
|
||||
log.workspace = true
|
||||
mail-builder.workspace = true
|
||||
mail-parser.workspace = true
|
||||
mail-send.workspace = true
|
||||
nom.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-platform-verifier.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
smtp-proto = "0.2"
|
||||
tokio.workspace = true
|
||||
tokio-rustls.workspace = true
|
||||
ureq.workspace = true
|
||||
|
||||
[dependencies.keyring]
|
||||
version = "3"
|
||||
features = ["apple-native", "windows-native"]
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.keyring]
|
||||
version = "3"
|
||||
features = ["linux-native-sync-persistent"]
|
||||
|
||||
[dev-dependencies]
|
||||
ely-platform.workspace = true
|
||||
rcgen.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::{env, fs, io::ErrorKind, path::Path};
|
||||
|
||||
const KEYS: [&str; 3] = [
|
||||
"ELY_GOOGLE_CLIENT_ID",
|
||||
"ELY_GOOGLE_CLIENT_SECRET",
|
||||
"ELY_MICROSOFT_CLIENT_ID",
|
||||
];
|
||||
|
||||
/// Client IDs: CI secrets, else the local oauth.env.
|
||||
fn main() {
|
||||
let file = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../oauth.env");
|
||||
println!("cargo:rerun-if-changed={}", file.display());
|
||||
for key in KEYS {
|
||||
println!("cargo:rerun-if-env-changed={key}");
|
||||
}
|
||||
let text = match fs::read_to_string(&file) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return,
|
||||
Err(err) => panic!("oauth.env unreadable: {err}"),
|
||||
};
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
panic!("oauth.env line {}: expected KEY=value", number + 1);
|
||||
};
|
||||
let key = key.trim();
|
||||
assert!(
|
||||
KEYS.contains(&key),
|
||||
"oauth.env line {}: unknown key {key}",
|
||||
number + 1
|
||||
);
|
||||
if env::var_os(key).is_none() {
|
||||
println!("cargo:rustc-env={key}={}", value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! An account's servers and how it signs in (MAIL-01, 02).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Imap, Login, Pop, Result, Secret, Server, Smtp, TlsConnector, oauth};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
Imap,
|
||||
/// `keep` leaves each message on the server (§12.3).
|
||||
Pop {
|
||||
keep: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// A password, or OAuth with one provider.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Auth {
|
||||
Password,
|
||||
Google,
|
||||
Microsoft,
|
||||
}
|
||||
|
||||
impl Auth {
|
||||
/// The OAuth server; None for a password.
|
||||
pub fn provider(self) -> Option<Result<oauth::Provider>> {
|
||||
match self {
|
||||
Auth::Password => None,
|
||||
Auth::Google => Some(oauth::google()),
|
||||
Auth::Microsoft => Some(oauth::microsoft()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
pub address: String,
|
||||
pub user: String,
|
||||
pub auth: Auth,
|
||||
pub protocol: Protocol,
|
||||
pub incoming: Server,
|
||||
pub outgoing: Server,
|
||||
}
|
||||
|
||||
/// What each server answered.
|
||||
pub struct Checked {
|
||||
pub incoming: Result<()>,
|
||||
pub outgoing: Result<()>,
|
||||
}
|
||||
|
||||
impl Checked {
|
||||
pub fn passed(&self) -> bool {
|
||||
self.incoming.is_ok() && self.outgoing.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// The vault name of its password or refresh token.
|
||||
pub fn secret_name(&self) -> String {
|
||||
format!("account:{}", self.address)
|
||||
}
|
||||
|
||||
/// Refuses what no server could carry.
|
||||
pub fn valid(&self) -> Result<()> {
|
||||
crate::smtp::mailbox(&self.address)?;
|
||||
if self.user.is_empty() || self.user.contains(['\r', '\n']) {
|
||||
return Err(Error::Invalid("a user name on one line is needed".into()));
|
||||
}
|
||||
for server in [&self.incoming, &self.outgoing] {
|
||||
if server.host.is_empty() || server.host.contains(char::is_whitespace) {
|
||||
return Err(Error::Invalid(format!("a server host: {:?}", server.host)));
|
||||
}
|
||||
if server.port == 0 {
|
||||
return Err(Error::Invalid("port 0".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn login(&self, secret: Secret) -> Login {
|
||||
Login {
|
||||
user: self.user.clone(),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs in to both servers at once, then leaves.
|
||||
pub async fn check(&self, secret: Secret, tls: &TlsConnector) -> Checked {
|
||||
let login = self.login(secret);
|
||||
let (incoming, outgoing) = tokio::join!(
|
||||
self.check_incoming(&login, tls),
|
||||
check_outgoing(&self.outgoing, &login, tls)
|
||||
);
|
||||
log::info!(
|
||||
"mail: check: incoming {}, outgoing {}",
|
||||
outcome(&incoming),
|
||||
outcome(&outgoing)
|
||||
);
|
||||
Checked { incoming, outgoing }
|
||||
}
|
||||
|
||||
async fn check_incoming(&self, login: &Login, tls: &TlsConnector) -> Result<()> {
|
||||
match self.protocol {
|
||||
Protocol::Imap => {
|
||||
Imap::connect(&self.incoming, login, tls)
|
||||
.await?
|
||||
.logout()
|
||||
.await
|
||||
}
|
||||
Protocol::Pop { .. } => Pop::connect(&self.incoming, login, tls).await?.quit().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_outgoing(server: &Server, login: &Login, tls: &TlsConnector) -> Result<()> {
|
||||
Smtp::connect(server, login, tls).await?.quit().await
|
||||
}
|
||||
|
||||
fn outcome(result: &Result<()>) -> &'static str {
|
||||
match result {
|
||||
Ok(()) => "passed",
|
||||
Err(_) => "failed",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::{fmt, io};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Why a mail server conversation failed; server words kept.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// No TCP connection: name lookup, refusal, reset.
|
||||
Connect(io::Error),
|
||||
/// TLS handshake or certificate check failed.
|
||||
Tls(String),
|
||||
/// The server refused the credentials.
|
||||
Rejected(String),
|
||||
/// The server refused a command after login.
|
||||
Server(String),
|
||||
/// The server said something the client cannot read.
|
||||
Protocol(String),
|
||||
/// The server did not answer in time.
|
||||
Timeout(&'static str),
|
||||
/// An address or login the protocol cannot carry.
|
||||
Invalid(String),
|
||||
/// A message that cannot be read or written.
|
||||
Mime(String),
|
||||
/// The local store failed: database, disk or damage.
|
||||
Store(String),
|
||||
/// An account with this address is already kept.
|
||||
Taken(String),
|
||||
/// The store refuses writes; the reason says why.
|
||||
ReadOnly(String),
|
||||
/// The system credential store failed.
|
||||
Vault(String),
|
||||
/// This build has no client ID for the provider.
|
||||
Unavailable(&'static str),
|
||||
/// Sign-in did not finish; the reason says why.
|
||||
SignIn(String),
|
||||
/// The provider revoked the refresh token.
|
||||
SignInAgain(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Connect(err) => write!(f, "cannot connect: {err}"),
|
||||
Self::Tls(text) => write!(f, "TLS failed: {text}"),
|
||||
Self::Rejected(text) => write!(f, "login refused: {text}"),
|
||||
Self::Server(text) => write!(f, "server refused: {text}"),
|
||||
Self::Protocol(text) => write!(f, "unreadable reply: {text}"),
|
||||
Self::Timeout(step) => write!(f, "no answer while {step}"),
|
||||
Self::Invalid(text) => write!(f, "cannot be sent: {text}"),
|
||||
Self::Mime(text) => write!(f, "unreadable message: {text}"),
|
||||
Self::Store(text) => write!(f, "local store failed: {text}"),
|
||||
Self::Taken(address) => write!(f, "{address} is already added"),
|
||||
Self::ReadOnly(text) => write!(f, "local store is read-only: {text}"),
|
||||
Self::Vault(text) => write!(f, "credential store failed: {text}"),
|
||||
Self::Unavailable(name) => write!(f, "{name} sign-in is not in this build"),
|
||||
Self::SignIn(text) => write!(f, "sign-in failed: {text}"),
|
||||
Self::SignInAgain(text) => write!(f, "sign in again: {text}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(err: rusqlite::Error) -> Self {
|
||||
Self::Store(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::Store(format!("a stored field: {err}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use imap_proto::{AttributeValue, Capability, MailboxDatum, NameAttribute, Response, ResponseCode};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
net::{self, Login, Secret, Security, Server, Stream, within},
|
||||
};
|
||||
|
||||
mod conn;
|
||||
mod parts;
|
||||
|
||||
use conn::{Arg, Conn};
|
||||
pub use parts::Node;
|
||||
|
||||
/// A logged-in IMAP session over TLS.
|
||||
pub struct Imap {
|
||||
conn: Conn<Stream>,
|
||||
}
|
||||
|
||||
/// What a folder is for (RFC 6154; INBOX by name).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
Inbox,
|
||||
Sent,
|
||||
Drafts,
|
||||
Trash,
|
||||
Junk,
|
||||
Archive,
|
||||
All,
|
||||
Flagged,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Role::Inbox => "inbox",
|
||||
Role::Sent => "sent",
|
||||
Role::Drafts => "drafts",
|
||||
Role::Trash => "trash",
|
||||
Role::Junk => "junk",
|
||||
Role::Archive => "archive",
|
||||
Role::All => "all",
|
||||
Role::Flagged => "flagged",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
[
|
||||
Role::Inbox,
|
||||
Role::Sent,
|
||||
Role::Drafts,
|
||||
Role::Trash,
|
||||
Role::Junk,
|
||||
Role::Archive,
|
||||
Role::All,
|
||||
Role::Flagged,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|role| role.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// One listed folder.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Folder {
|
||||
pub name: String,
|
||||
pub role: Option<Role>,
|
||||
/// False for `\Noselect` and `\NonExistent`.
|
||||
pub selectable: bool,
|
||||
}
|
||||
|
||||
/// A folder opened read-only.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Opened {
|
||||
pub uidvalidity: u32,
|
||||
pub exists: u32,
|
||||
}
|
||||
|
||||
/// One fetched message: header, text or whole.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Got {
|
||||
pub uid: u32,
|
||||
pub flags: Vec<String>,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Imap {
|
||||
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
|
||||
let tcp = net::tcp(server).await?;
|
||||
let stream = match server.security {
|
||||
Security::Tls => net::tls(tcp, server, tls).await?,
|
||||
Security::StartTls => {
|
||||
let mut plain = Conn::new(tcp);
|
||||
within("waiting for the greeting", plain.greeting()).await??;
|
||||
within("starting TLS", plain.run("STARTTLS", &[], |_, _| {}))
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
net::tls(plain.wire.into_inner()?, server, tls).await?
|
||||
}
|
||||
};
|
||||
let mut conn = Conn::new(stream);
|
||||
if server.security == Security::Tls {
|
||||
within("waiting for the greeting", conn.greeting()).await??;
|
||||
}
|
||||
let outcome = match &login.secret {
|
||||
Secret::Password(password) => {
|
||||
let args = [Arg::Text(&login.user), Arg::Text(password)];
|
||||
within("logging in", conn.run("LOGIN", &args, |_, _| {})).await??
|
||||
}
|
||||
Secret::Token(token) => {
|
||||
within("logging in", conn.xoauth2(&login.xoauth2(token))).await??
|
||||
}
|
||||
};
|
||||
outcome.map_err(Error::Rejected)?;
|
||||
log::info!("mail: IMAP login on {}", server.host);
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Every folder the server lists, with its role.
|
||||
pub async fn folders(&mut self) -> Result<Vec<Folder>> {
|
||||
let can = self.capabilities().await?;
|
||||
let has = |name: &str| can.iter().any(|it| it.eq_ignore_ascii_case(name));
|
||||
// RFC 6154 §2: roles may come only on request.
|
||||
let extended = has("LIST-EXTENDED") && has("SPECIAL-USE");
|
||||
let mut folders = Vec::new();
|
||||
let all = [
|
||||
Arg::Text(""),
|
||||
Arg::Atom("*"),
|
||||
Arg::Atom("RETURN (SPECIAL-USE)"),
|
||||
];
|
||||
let args = if extended { &all[..] } else { &all[..2] };
|
||||
within(
|
||||
"listing mailboxes",
|
||||
self.conn.run("LIST", args, |response, raw| {
|
||||
if let Response::MailboxData(MailboxDatum::List(list)) = response {
|
||||
let name = mailbox_name(&list.name, raw);
|
||||
folders.push(folder(name, &list.name_attributes));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
async fn capabilities(&mut self) -> Result<Vec<String>> {
|
||||
let mut can = Vec::new();
|
||||
within(
|
||||
"reading capabilities",
|
||||
self.conn.run("CAPABILITY", &[], |response, _| {
|
||||
if let Response::Capabilities(list) = response {
|
||||
can.extend(list.iter().filter_map(|it| match it {
|
||||
Capability::Atom(atom) => Some(atom.to_string()),
|
||||
_ => None,
|
||||
}));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
Ok(can)
|
||||
}
|
||||
|
||||
pub async fn examine(&mut self, mailbox: &str) -> Result<Opened> {
|
||||
let (mut uidvalidity, mut exists) = (None, None);
|
||||
within(
|
||||
"opening the mailbox",
|
||||
self.conn.run(
|
||||
"EXAMINE",
|
||||
&[Arg::Text(mailbox)],
|
||||
|response, _| match response {
|
||||
Response::Data { outcome, .. } => {
|
||||
if let Some(ResponseCode::UidValidity(value)) = &outcome.code {
|
||||
uidvalidity = Some(*value);
|
||||
}
|
||||
}
|
||||
Response::MailboxData(MailboxDatum::Exists(count)) => exists = Some(*count),
|
||||
_ => {}
|
||||
},
|
||||
),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
let uidvalidity = uidvalidity
|
||||
.ok_or_else(|| Error::Protocol("the mailbox opened without a UIDVALIDITY".into()))?;
|
||||
// RFC 3501 §6.3.1: EXISTS is required.
|
||||
let exists =
|
||||
exists.ok_or_else(|| Error::Protocol("the mailbox opened without EXISTS".into()))?;
|
||||
Ok(Opened {
|
||||
uidvalidity,
|
||||
exists,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every UID in the open mailbox with its flags.
|
||||
pub async fn flags(&mut self) -> Result<Vec<(u32, Vec<String>)>> {
|
||||
let got = self
|
||||
.uid_fetch("1:*", "(UID FLAGS)", "reading flags")
|
||||
.await?;
|
||||
Ok(got.into_iter().map(|(uid, it)| (uid, it.flags)).collect())
|
||||
}
|
||||
|
||||
/// Headers of `uids`; a UID gone meanwhile is absent.
|
||||
pub async fn headers(&mut self, uids: &[u32]) -> Result<Vec<Got>> {
|
||||
let what = "(UID FLAGS BODY.PEEK[HEADER])";
|
||||
let got = self.uid_fetch(&set(uids), what, "fetching headers").await?;
|
||||
asked(got, uids, "HEADER")
|
||||
}
|
||||
|
||||
/// Whole messages of `uids`; a UID gone meanwhile is absent.
|
||||
pub async fn bodies(&mut self, uids: &[u32]) -> Result<Vec<Got>> {
|
||||
let what = "(UID FLAGS BODY.PEEK[])";
|
||||
let got = self
|
||||
.uid_fetch(&set(uids), what, "fetching messages")
|
||||
.await?;
|
||||
asked(got, uids, "")
|
||||
}
|
||||
|
||||
/// Each UID's MIME tree; a UID gone meanwhile is absent.
|
||||
pub async fn structures(&mut self, uids: &[u32]) -> Result<Vec<(u32, Node)>> {
|
||||
let what = "(UID BODYSTRUCTURE)";
|
||||
let mut got = self
|
||||
.uid_fetch(&set(uids), what, "reading structures")
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
for &uid in uids {
|
||||
let Some(it) = got.remove(&uid) else {
|
||||
continue;
|
||||
};
|
||||
let node = it
|
||||
.structure
|
||||
.ok_or_else(|| Error::Protocol(format!("UID {uid} came without a structure")))?;
|
||||
out.push((uid, node?));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The message without its left-out parts; `None` once gone.
|
||||
pub async fn text(&mut self, uid: u32, node: &Node) -> Result<Option<Got>> {
|
||||
let peeks: Vec<String> = node
|
||||
.sections()
|
||||
.iter()
|
||||
.map(|section| format!("BODY.PEEK[{section}]"))
|
||||
.collect();
|
||||
let what = format!("(UID FLAGS {})", peeks.join(" "));
|
||||
let mut got = self
|
||||
.uid_fetch(&uid.to_string(), &what, "fetching message text")
|
||||
.await?;
|
||||
let Some(it) = got.remove(&uid) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Got {
|
||||
uid,
|
||||
bytes: node.build(&it.sections)?,
|
||||
flags: it.flags,
|
||||
}))
|
||||
}
|
||||
|
||||
/// UIDs of messages that arrived on or after `day` (`19-Jul-2026`).
|
||||
pub async fn since(&mut self, day: &str) -> Result<Vec<u32>> {
|
||||
let mut uids = Vec::new();
|
||||
let args = [Arg::Atom("SINCE"), Arg::Atom(day)];
|
||||
within(
|
||||
"searching by date",
|
||||
self.conn.run("UID SEARCH", &args, |response, _| {
|
||||
if let Response::MailboxData(MailboxDatum::Search(found)) = response {
|
||||
uids.extend_from_slice(found);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
uids.sort_unstable();
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
/// The raw message; `None` once the UID is gone.
|
||||
pub async fn fetch(&mut self, mailbox: &str, uid: u32) -> Result<Option<Vec<u8>>> {
|
||||
self.examine(mailbox).await?;
|
||||
Ok(self.bodies(&[uid]).await?.pop().map(|got| got.bytes))
|
||||
}
|
||||
|
||||
pub async fn logout(mut self) -> Result<()> {
|
||||
within("logging out", self.conn.run("LOGOUT", &[], |_, _| {}))
|
||||
.await??
|
||||
.map_err(Error::Server)
|
||||
}
|
||||
|
||||
/// Every FETCH for a UID, merged; flags may come apart.
|
||||
async fn uid_fetch(
|
||||
&mut self,
|
||||
uids: &str,
|
||||
what: &str,
|
||||
step: &'static str,
|
||||
) -> Result<HashMap<u32, Fetch>> {
|
||||
let mut got: HashMap<u32, Fetch> = HashMap::new();
|
||||
let args = [Arg::Atom(uids), Arg::Atom(what)];
|
||||
within(
|
||||
step,
|
||||
self.conn.run("UID FETCH", &args, |response, _| {
|
||||
if let Response::Fetch(_, values) = response {
|
||||
merge(&mut got, values);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
Ok(got)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fetch {
|
||||
flags: Vec<String>,
|
||||
sections: HashMap<String, Vec<u8>>,
|
||||
structure: Option<Result<Node>>,
|
||||
}
|
||||
|
||||
/// Unsolicited FETCH responses without a UID are skipped.
|
||||
fn merge(got: &mut HashMap<u32, Fetch>, values: &[AttributeValue]) {
|
||||
let Some(uid) = values.iter().find_map(|value| match value {
|
||||
AttributeValue::Uid(uid) => Some(*uid),
|
||||
_ => None,
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let it = got.entry(uid).or_default();
|
||||
for value in values {
|
||||
match value {
|
||||
AttributeValue::Flags(flags) => {
|
||||
// \Recent belongs to one session.
|
||||
it.flags = flags
|
||||
.iter()
|
||||
.filter(|flag| !flag.eq_ignore_ascii_case(r"\Recent"))
|
||||
.map(|flag| flag.to_string())
|
||||
.collect();
|
||||
it.flags.sort();
|
||||
}
|
||||
AttributeValue::BodySection {
|
||||
section,
|
||||
data: Some(data),
|
||||
..
|
||||
} => {
|
||||
it.sections
|
||||
.insert(parts::section_key(section), data.to_vec());
|
||||
}
|
||||
AttributeValue::BodyStructure(structure) => {
|
||||
it.structure = Some(Node::read(structure));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The asked UIDs in order; each needs `section`.
|
||||
fn asked(mut got: HashMap<u32, Fetch>, uids: &[u32], section: &str) -> Result<Vec<Got>> {
|
||||
let mut out = Vec::new();
|
||||
for &uid in uids {
|
||||
let Some(mut it) = got.remove(&uid) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = it
|
||||
.sections
|
||||
.remove(section)
|
||||
.ok_or_else(|| Error::Protocol(format!("UID {uid} came without {section:?}")))?;
|
||||
out.push(Got {
|
||||
uid,
|
||||
flags: it.flags,
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `1:3,7` from sorted UIDs.
|
||||
fn set(uids: &[u32]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while at < uids.len() {
|
||||
let start = uids[at];
|
||||
let mut end = start;
|
||||
while at + 1 < uids.len() && uids[at + 1] == end + 1 {
|
||||
at += 1;
|
||||
end = uids[at];
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push(',');
|
||||
}
|
||||
match start == end {
|
||||
true => out.push_str(&start.to_string()),
|
||||
false => out.push_str(&format!("{start}:{end}")),
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn folder(name: String, attributes: &[NameAttribute]) -> Folder {
|
||||
let mut role = name.eq_ignore_ascii_case("INBOX").then_some(Role::Inbox);
|
||||
let mut selectable = true;
|
||||
for attribute in attributes {
|
||||
match attribute {
|
||||
NameAttribute::NoSelect => selectable = false,
|
||||
NameAttribute::Extension(other) if other.eq_ignore_ascii_case("\\NonExistent") => {
|
||||
selectable = false
|
||||
}
|
||||
NameAttribute::Sent => role = Some(Role::Sent),
|
||||
NameAttribute::Drafts => role = Some(Role::Drafts),
|
||||
NameAttribute::Trash => role = Some(Role::Trash),
|
||||
NameAttribute::Junk => role = Some(Role::Junk),
|
||||
NameAttribute::Archive => role = Some(Role::Archive),
|
||||
NameAttribute::All => role = Some(Role::All),
|
||||
NameAttribute::Flagged => role = Some(Role::Flagged),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Folder {
|
||||
name,
|
||||
role,
|
||||
selectable,
|
||||
}
|
||||
}
|
||||
|
||||
/// imap-proto keeps quoted escapes; a literal is as sent.
|
||||
fn mailbox_name(name: &str, raw: &[u8]) -> String {
|
||||
let line = raw.strip_suffix(b"\r\n").unwrap_or(raw);
|
||||
let name_end = |end: &[u8], mark: &[u8]| {
|
||||
end.strip_suffix(name.as_bytes())
|
||||
.is_some_and(|rest| rest.ends_with(mark))
|
||||
};
|
||||
let literal = name_end(line, b"}\r\n");
|
||||
let quoted = line
|
||||
.strip_suffix(b"\"")
|
||||
.is_some_and(|end| name_end(end, b"\""));
|
||||
if literal || !quoted {
|
||||
return name.to_owned();
|
||||
}
|
||||
let mut plain = String::with_capacity(name.len());
|
||||
let mut chars = name.chars();
|
||||
while let Some(char) = chars.next() {
|
||||
match char {
|
||||
'\\' => plain.extend(chars.next()),
|
||||
_ => plain.push(char),
|
||||
}
|
||||
}
|
||||
plain
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_uid_set_joins_runs() {
|
||||
assert_eq!(super::set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10");
|
||||
assert_eq!(super::set(&[5]), "5");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Tagged commands, literals and replies on one stream.
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use imap_proto::{Response, Status};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
wire::{Wire, words},
|
||||
};
|
||||
|
||||
/// One command argument.
|
||||
pub(super) enum Arg<'a> {
|
||||
/// Written as is.
|
||||
Atom(&'a str),
|
||||
/// Quoted, or a literal when quotes cannot carry it.
|
||||
Text(&'a str),
|
||||
}
|
||||
|
||||
/// OK, or the words of NO or BAD.
|
||||
pub(super) type Outcome = std::result::Result<(), String>;
|
||||
|
||||
pub(super) struct Conn<S> {
|
||||
pub(super) wire: Wire<S>,
|
||||
tags: u32,
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + AsyncWrite + Unpin> Conn<S> {
|
||||
pub(super) fn new(io: S) -> Self {
|
||||
Self {
|
||||
wire: Wire::new(io),
|
||||
tags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `* BYE` ends with the server's words.
|
||||
pub(super) async fn greeting(&mut self) -> Result<()> {
|
||||
self.wire
|
||||
.next(|response, raw| match response {
|
||||
Response::Data {
|
||||
status: Status::Ok, ..
|
||||
} => Ok(()),
|
||||
Response::Data {
|
||||
status: Status::Bye,
|
||||
..
|
||||
} => Err(Error::Server(words(raw))),
|
||||
Response::Data { status, .. } => Err(Error::Protocol(format!(
|
||||
"the greeting was {status:?}, not OK"
|
||||
))),
|
||||
_ => Err(Error::Protocol("the first line was not a greeting".into())),
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn tag(&mut self) -> String {
|
||||
self.tags += 1;
|
||||
format!("A{}", self.tags)
|
||||
}
|
||||
|
||||
/// Sends a command; untagged responses go to `each`.
|
||||
pub(super) async fn run(
|
||||
&mut self,
|
||||
verb: &str,
|
||||
args: &[Arg<'_>],
|
||||
mut each: impl FnMut(&Response, &[u8]),
|
||||
) -> Result<Outcome> {
|
||||
let tag = self.tag();
|
||||
log::debug!("mail: IMAP {tag} {verb}");
|
||||
let mut line = format!("{tag} {verb}").into_bytes();
|
||||
for arg in args {
|
||||
line.push(b' ');
|
||||
match arg {
|
||||
Arg::Atom(atom) => line.extend_from_slice(atom.as_bytes()),
|
||||
Arg::Text(text) => match quoted(text)? {
|
||||
Some(quoted) => line.extend_from_slice(quoted.as_bytes()),
|
||||
None => {
|
||||
line.extend_from_slice(format!("{{{}}}\r\n", text.len()).as_bytes());
|
||||
self.wire.write(&line).await?;
|
||||
line.clear();
|
||||
if let Err(words) = self.proceed(&tag, &mut each).await? {
|
||||
return Ok(Err(words));
|
||||
}
|
||||
line.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
line.extend_from_slice(b"\r\n");
|
||||
self.wire.write(&line).await?;
|
||||
loop {
|
||||
match self.step(&tag, &mut each).await? {
|
||||
Step::Done(outcome) => return Ok(outcome),
|
||||
Step::Untagged => {}
|
||||
Step::Continue => {
|
||||
return Err(Error::Protocol(
|
||||
"the server asked for more than was sent".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for `+` before a literal.
|
||||
async fn proceed(
|
||||
&mut self,
|
||||
tag: &str,
|
||||
each: &mut impl FnMut(&Response, &[u8]),
|
||||
) -> Result<Outcome> {
|
||||
loop {
|
||||
match self.step(tag, each).await? {
|
||||
Step::Continue => return Ok(Ok(())),
|
||||
Step::Done(Ok(())) => {
|
||||
return Err(Error::Protocol(
|
||||
"the command ended before its literal".into(),
|
||||
));
|
||||
}
|
||||
Step::Done(refused) => return Ok(refused),
|
||||
Step::Untagged => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the first challenge; empty after, so a refusal ends.
|
||||
pub(super) async fn xoauth2(&mut self, initial: &str) -> Result<Outcome> {
|
||||
let tag = self.tag();
|
||||
self.wire
|
||||
.write(format!("{tag} AUTHENTICATE XOAUTH2\r\n").as_bytes())
|
||||
.await?;
|
||||
let mut answer = Some(STANDARD.encode(initial));
|
||||
loop {
|
||||
match self.step(&tag, &mut |_, _| {}).await? {
|
||||
Step::Continue => {
|
||||
let line = answer.take().unwrap_or_default() + "\r\n";
|
||||
self.wire.write(line.as_bytes()).await?;
|
||||
}
|
||||
Step::Done(outcome) => return Ok(outcome),
|
||||
Step::Untagged => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn step(&mut self, tag: &str, each: &mut impl FnMut(&Response, &[u8])) -> Result<Step> {
|
||||
self.wire
|
||||
.next(|response, raw| match response {
|
||||
Response::Continue(_) => Ok(Step::Continue),
|
||||
Response::Done {
|
||||
tag: done, status, ..
|
||||
} => {
|
||||
if done.0 != tag {
|
||||
return Err(Error::Protocol(format!(
|
||||
"a reply for {} came while waiting for {tag}",
|
||||
done.0
|
||||
)));
|
||||
}
|
||||
match status {
|
||||
Status::Ok => Ok(Step::Done(Ok(()))),
|
||||
Status::No | Status::Bad => Ok(Step::Done(Err(words(raw)))),
|
||||
other => Err(Error::Protocol(format!("{tag} ended with {other:?}"))),
|
||||
}
|
||||
}
|
||||
untagged => {
|
||||
each(untagged, raw);
|
||||
Ok(Step::Untagged)
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Continue,
|
||||
Untagged,
|
||||
Done(Outcome),
|
||||
}
|
||||
|
||||
/// `None` when only a literal can carry the text.
|
||||
fn quoted(text: &str) -> Result<Option<String>> {
|
||||
if text.contains('\0') {
|
||||
return Err(Error::Invalid("IMAP cannot carry a NUL byte".into()));
|
||||
}
|
||||
if !text
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii() && byte != b'\r' && byte != b'\n')
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut quoted = String::with_capacity(text.len() + 2);
|
||||
quoted.push('"');
|
||||
for char in text.chars() {
|
||||
if char == '"' || char == '\\' {
|
||||
quoted.push('\\');
|
||||
}
|
||||
quoted.push(char);
|
||||
}
|
||||
quoted.push('"');
|
||||
Ok(Some(quoted))
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Text without attachments, from BODYSTRUCTURE (§12.3).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use imap_proto::{BodyParams, BodyStructure, MessageSection, SectionPath};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A message's MIME tree; paths are IMAP part numbers.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Node {
|
||||
/// `body` parts come down; the rest wait for a request.
|
||||
Leaf { path: Vec<u32>, body: bool },
|
||||
Multi {
|
||||
path: Vec<u32>,
|
||||
boundary: String,
|
||||
children: Vec<Node>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The reader's rules: `mime/walk.rs`.
|
||||
pub(super) fn read(structure: &BodyStructure) -> Result<Self> {
|
||||
match structure {
|
||||
BodyStructure::Multipart { .. } => body(structure, Vec::new()),
|
||||
single => Ok(Node::Leaf {
|
||||
path: vec![1],
|
||||
body: !attachment(single) && shown(single),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when nothing is left out.
|
||||
pub fn complete(&self) -> bool {
|
||||
match self {
|
||||
Node::Leaf { body, .. } => *body,
|
||||
Node::Multi { children, .. } => children.iter().all(Node::complete),
|
||||
}
|
||||
}
|
||||
|
||||
/// Part numbers left out, as `2` or `1.3`.
|
||||
pub fn missing(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
self.walk(&mut |node| {
|
||||
if let Node::Leaf { path, body: false } = node {
|
||||
out.push(number(path));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The FETCH sections that rebuild it.
|
||||
pub fn sections(&self) -> Vec<String> {
|
||||
let mut out = vec!["HEADER".to_owned()];
|
||||
if let Node::Leaf { body, .. } = self {
|
||||
if *body {
|
||||
out.push("1".into());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
self.walk(&mut |node| match node {
|
||||
Node::Leaf { path, body } => {
|
||||
out.push(mime(path));
|
||||
if *body {
|
||||
out.push(number(path));
|
||||
}
|
||||
}
|
||||
Node::Multi { path, .. } if !path.is_empty() => out.push(mime(path)),
|
||||
Node::Multi { .. } => {}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The message with left-out parts empty.
|
||||
pub fn build(&self, got: &HashMap<String, Vec<u8>>) -> Result<Vec<u8>> {
|
||||
let mut out = header(take(got, "HEADER")?);
|
||||
match self {
|
||||
Node::Leaf { body: true, .. } => out.extend_from_slice(take(got, "1")?),
|
||||
Node::Leaf { body: false, .. } => {}
|
||||
multi => content(multi, got, &mut out)?,
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn walk(&self, each: &mut impl FnMut(&Node)) {
|
||||
each(self);
|
||||
if let Node::Multi { children, .. } = self {
|
||||
for child in children {
|
||||
child.walk(each);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a FETCH section comes back under.
|
||||
pub(super) fn section_key(section: &Option<SectionPath>) -> String {
|
||||
let named = |part: &MessageSection| match part {
|
||||
MessageSection::Header => "HEADER",
|
||||
MessageSection::Mime => "MIME",
|
||||
MessageSection::Text => "TEXT",
|
||||
};
|
||||
match section {
|
||||
None => String::new(),
|
||||
Some(SectionPath::Full(part)) => named(part).to_owned(),
|
||||
Some(SectionPath::Part(path, None)) => number(path),
|
||||
Some(SectionPath::Part(path, Some(part))) => format!("{}.{}", number(path), named(part)),
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2183 §2.8, §2.9: the whole entity waits.
|
||||
fn body(structure: &BodyStructure, path: Vec<u32>) -> Result<Node> {
|
||||
if attachment(structure) {
|
||||
return Ok(Node::Leaf { path, body: false });
|
||||
}
|
||||
inside(structure, path)
|
||||
}
|
||||
|
||||
fn inside(structure: &BodyStructure, path: Vec<u32>) -> Result<Node> {
|
||||
let BodyStructure::Multipart { common, bodies, .. } = structure else {
|
||||
return Ok(Node::Leaf {
|
||||
body: shown(structure),
|
||||
path,
|
||||
});
|
||||
};
|
||||
let boundary = param(&common.ty.params, "boundary")
|
||||
.ok_or_else(|| Error::Protocol("a multipart came without a boundary".into()))?;
|
||||
let related = common.ty.subtype.eq_ignore_ascii_case("related");
|
||||
let root = related.then(|| root(&common.ty.params, bodies));
|
||||
let children = bodies
|
||||
.iter()
|
||||
.zip(1..)
|
||||
.enumerate()
|
||||
.map(|(at, (child, number))| {
|
||||
let mut next = path.clone();
|
||||
next.push(number);
|
||||
match root {
|
||||
None => body(child, next),
|
||||
Some(Root::At(root)) if root == at => inside(child, next),
|
||||
Some(Root::Unseen) if multipart(child) => inside(child, next),
|
||||
// RFC 2387 §4, §6.1: one entity, no disposition.
|
||||
Some(_) => Ok(Node::Leaf {
|
||||
body: id(child).is_some(),
|
||||
path: next,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
Ok(Node::Multi {
|
||||
path,
|
||||
boundary: boundary.to_owned(),
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
/// The related root, or one hidden in a multipart.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Root {
|
||||
At(usize),
|
||||
Unseen,
|
||||
}
|
||||
|
||||
/// RFC 2387: `start` names the root, else the first.
|
||||
fn root(params: &BodyParams, bodies: &[BodyStructure]) -> Root {
|
||||
let Some(start) = param(params, "start") else {
|
||||
return Root::At(0);
|
||||
};
|
||||
if let Some(at) = bodies
|
||||
.iter()
|
||||
.position(|child| id(child).is_some_and(|id| bare(id) == bare(start)))
|
||||
{
|
||||
return Root::At(at);
|
||||
}
|
||||
if bodies.iter().any(multipart) {
|
||||
return Root::Unseen;
|
||||
}
|
||||
log::warn!("mail: related start names no part; first part is the root");
|
||||
Root::At(0)
|
||||
}
|
||||
|
||||
fn multipart(structure: &BodyStructure) -> bool {
|
||||
matches!(structure, BodyStructure::Multipart { .. })
|
||||
}
|
||||
|
||||
/// Plain and HTML text, and `cid:` targets.
|
||||
fn shown(structure: &BodyStructure) -> bool {
|
||||
match structure {
|
||||
BodyStructure::Text { common, .. }
|
||||
if ["plain", "html"]
|
||||
.iter()
|
||||
.any(|kind| common.ty.subtype.eq_ignore_ascii_case(kind)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
BodyStructure::Text { .. } | BodyStructure::Basic { .. } => id(structure).is_some(),
|
||||
BodyStructure::Message { .. } | BodyStructure::Multipart { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2183 §2.8: unknown dispositions are attachments.
|
||||
fn attachment(structure: &BodyStructure) -> bool {
|
||||
let common = match structure {
|
||||
BodyStructure::Basic { common, .. }
|
||||
| BodyStructure::Text { common, .. }
|
||||
| BodyStructure::Message { common, .. }
|
||||
| BodyStructure::Multipart { common, .. } => common,
|
||||
};
|
||||
common
|
||||
.disposition
|
||||
.as_ref()
|
||||
.is_some_and(|it| !it.ty.eq_ignore_ascii_case("inline"))
|
||||
}
|
||||
|
||||
fn id<'a>(structure: &'a BodyStructure) -> Option<&'a str> {
|
||||
match structure {
|
||||
BodyStructure::Basic { other, .. }
|
||||
| BodyStructure::Text { other, .. }
|
||||
| BodyStructure::Message { other, .. } => other.id.as_deref(),
|
||||
BodyStructure::Multipart { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bare(id: &str) -> &str {
|
||||
id.trim().trim_start_matches('<').trim_end_matches('>')
|
||||
}
|
||||
|
||||
fn param<'a>(params: &'a BodyParams, name: &str) -> Option<&'a str> {
|
||||
params
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_ref())
|
||||
}
|
||||
|
||||
fn content(node: &Node, got: &HashMap<String, Vec<u8>>, out: &mut Vec<u8>) -> Result<()> {
|
||||
match node {
|
||||
Node::Leaf { path, body: true } => out.extend_from_slice(take(got, &number(path))?),
|
||||
Node::Leaf { body: false, .. } => {}
|
||||
Node::Multi {
|
||||
boundary, children, ..
|
||||
} => {
|
||||
for child in children {
|
||||
let path = match child {
|
||||
Node::Leaf { path, .. } | Node::Multi { path, .. } => path,
|
||||
};
|
||||
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
out.extend(header(take(got, &mime(path))?));
|
||||
content(child, got, out)?;
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A header block ends with a blank line.
|
||||
fn header(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = bytes.to_vec();
|
||||
if out.is_empty() {
|
||||
return b"\r\n".to_vec();
|
||||
}
|
||||
while !out.ends_with(b"\r\n\r\n") {
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn take<'a>(got: &'a HashMap<String, Vec<u8>>, key: &str) -> Result<&'a [u8]> {
|
||||
got.get(key)
|
||||
.map(Vec::as_slice)
|
||||
.ok_or_else(|| Error::Protocol(format!("section {key} did not come")))
|
||||
}
|
||||
|
||||
/// A whole message's body is `TEXT`.
|
||||
fn number(path: &[u32]) -> String {
|
||||
if path.is_empty() {
|
||||
return "TEXT".into();
|
||||
}
|
||||
path.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
fn mime(path: &[u32]) -> String {
|
||||
format!("{}.MIME", number(path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,151 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use imap_proto::{AttributeValue, Response};
|
||||
|
||||
use super::Node;
|
||||
|
||||
/// The node of a `* 1 FETCH (BODYSTRUCTURE …)` line.
|
||||
fn node(structure: &str) -> Node {
|
||||
let line = format!("* 1 FETCH (BODYSTRUCTURE {structure})\r\n");
|
||||
let (_, response) = Response::parse(line.as_bytes()).unwrap();
|
||||
let Response::Fetch(_, attributes) = response else {
|
||||
panic!("not a FETCH: {line}");
|
||||
};
|
||||
let [AttributeValue::BodyStructure(structure)] = attributes.as_slice() else {
|
||||
panic!("no structure: {line}");
|
||||
};
|
||||
Node::read(structure).unwrap()
|
||||
}
|
||||
|
||||
/// Reads the rebuilt message; sections not given come empty.
|
||||
fn rebuilt(node: &Node, given: &[(&str, &str)]) -> crate::Mail {
|
||||
let mut got: HashMap<String, Vec<u8>> = given
|
||||
.iter()
|
||||
.map(|(key, text)| ((*key).to_owned(), text.replace('\n', "\r\n").into_bytes()))
|
||||
.collect();
|
||||
for section in node.sections() {
|
||||
got.entry(section).or_default();
|
||||
}
|
||||
crate::read(&node.build(&got).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
const TEXT: &str = "(\"TEXT\" \"PLAIN\" (\"CHARSET\" \"us-ascii\") NIL NIL \"7BIT\" 6 1)";
|
||||
const HTML: &str = "(\"TEXT\" \"HTML\" (\"CHARSET\" \"us-ascii\") NIL NIL \"7BIT\" 9 1)";
|
||||
|
||||
#[test]
|
||||
fn an_attached_multipart_waits_whole() {
|
||||
let structure = format!(
|
||||
"({TEXT}({TEXT}{HTML} \"ALTERNATIVE\" (\"BOUNDARY\" \"b2\") \
|
||||
(\"ATTACHMENT\" (\"FILENAME\" \"note\")) NIL NIL) \"MIXED\" (\"BOUNDARY\" \"b1\") NIL NIL NIL)"
|
||||
);
|
||||
let node = node(&structure);
|
||||
assert!(!node.complete());
|
||||
assert_eq!(node.missing(), ["2"]);
|
||||
assert_eq!(node.sections(), ["HEADER", "1.MIME", "1", "2.MIME"]);
|
||||
let mail = rebuilt(
|
||||
&node,
|
||||
&[
|
||||
(
|
||||
"HEADER",
|
||||
"MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"b1\"\n\n",
|
||||
),
|
||||
("1.MIME", "Content-Type: text/plain\n\n"),
|
||||
("1", "hello\n"),
|
||||
(
|
||||
"2.MIME",
|
||||
"Content-Type: multipart/alternative; boundary=\"b2\"\n\
|
||||
Content-Disposition: attachment; filename=\"note\"\n\n",
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(mail.text.as_deref().map(str::trim_end), Some("hello"));
|
||||
assert_eq!(mail.attachments.len(), 1);
|
||||
assert_eq!(mail.attachments[0].content_type, "multipart/alternative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_image_marked_attachment_comes_down() {
|
||||
let structure = format!(
|
||||
"({HTML}(\"IMAGE\" \"PNG\" NIL \"<logo>\" NIL \"BASE64\" 12 NIL \
|
||||
(\"ATTACHMENT\" (\"FILENAME\" \"logo.png\")) NIL NIL) \
|
||||
\"RELATED\" (\"BOUNDARY\" \"r1\") NIL NIL NIL)"
|
||||
);
|
||||
let node = node(&structure);
|
||||
assert!(node.complete());
|
||||
assert!(node.missing().is_empty());
|
||||
let mail = rebuilt(
|
||||
&node,
|
||||
&[
|
||||
(
|
||||
"HEADER",
|
||||
"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"r1\"\n\n",
|
||||
),
|
||||
("1.MIME", "Content-Type: text/html\n\n"),
|
||||
("1", "<img src=\"cid:logo\">\n"),
|
||||
(
|
||||
"2.MIME",
|
||||
"Content-Type: image/png\nContent-ID: <logo>\n\
|
||||
Content-Disposition: attachment; filename=\"logo.png\"\n\
|
||||
Content-Transfer-Encoding: base64\n\n",
|
||||
),
|
||||
("2", "iVBORw0KGgo=\n"),
|
||||
],
|
||||
);
|
||||
assert_eq!(mail.inline.len(), 1);
|
||||
assert_eq!(mail.inline[0].bytes, b"\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_start_root_ignores_its_disposition() {
|
||||
let structure = "((\"IMAGE\" \"PNG\" NIL NIL NIL \"BASE64\" 8 NIL NIL NIL NIL)\
|
||||
(\"TEXT\" \"HTML\" NIL \"<root>\" NIL \"7BIT\" 9 1 NIL (\"ATTACHMENT\" NIL) NIL NIL) \
|
||||
\"RELATED\" (\"BOUNDARY\" \"r1\" \"START\" \"<root>\") NIL NIL NIL)";
|
||||
let node = node(structure);
|
||||
assert_eq!(node.missing(), ["1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_message_root_waits_whole() {
|
||||
let structure =
|
||||
format!("({TEXT}{TEXT} \"MIXED\" (\"BOUNDARY\" \"b1\") (\"ATTACHMENT\" NIL) NIL NIL)");
|
||||
let node = node(&structure);
|
||||
assert_eq!(node.missing(), ["TEXT"]);
|
||||
assert_eq!(node.sections(), ["HEADER"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_start_may_name_a_multipart() {
|
||||
let structure = format!(
|
||||
"((\"IMAGE\" \"PNG\" NIL \"<logo>\" NIL \"BASE64\" 12 NIL NIL NIL NIL)\
|
||||
({TEXT}{HTML} \"ALTERNATIVE\" (\"BOUNDARY\" \"b2\") NIL NIL NIL) \
|
||||
\"RELATED\" (\"BOUNDARY\" \"r1\" \"START\" \"<root>\") NIL NIL NIL)"
|
||||
);
|
||||
let node = node(&structure);
|
||||
assert!(node.complete());
|
||||
let mail = rebuilt(
|
||||
&node,
|
||||
&[
|
||||
(
|
||||
"HEADER",
|
||||
"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"r1\"; \
|
||||
start=\"<root>\"\n\n",
|
||||
),
|
||||
(
|
||||
"1.MIME",
|
||||
"Content-Type: image/png\nContent-ID: <logo>\n\
|
||||
Content-Transfer-Encoding: base64\n\n",
|
||||
),
|
||||
("1", "iVBORw0KGgo=\n"),
|
||||
(
|
||||
"2.MIME",
|
||||
"Content-Type: multipart/alternative; boundary=\"b2\"\nContent-ID: <root>\n\n",
|
||||
),
|
||||
("2.1.MIME", "Content-Type: text/plain\n\n"),
|
||||
("2.1", "hello\n"),
|
||||
("2.2.MIME", "Content-Type: text/html\n\n"),
|
||||
("2.2", "<p>hello</p>\n"),
|
||||
],
|
||||
);
|
||||
assert_eq!(mail.text.as_deref().map(str::trim_end), Some("hello"));
|
||||
assert!(mail.html.is_some());
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Mail protocols, sync and local storage for ElyMail.
|
||||
|
||||
mod account;
|
||||
mod error;
|
||||
mod imap;
|
||||
mod mime;
|
||||
mod net;
|
||||
pub mod oauth;
|
||||
mod pop;
|
||||
mod smtp;
|
||||
mod store;
|
||||
pub mod sync;
|
||||
mod vault;
|
||||
mod wire;
|
||||
|
||||
pub use account::{Account, Auth, Checked, Protocol};
|
||||
pub use error::{Error, Result};
|
||||
pub use imap::{Folder, Got, Imap, Opened, Role};
|
||||
pub use mime::{Built, Draft, Mail, Mailbox, Part, build, read};
|
||||
pub use net::{Login, Secret, Security, Server, system_tls};
|
||||
pub use pop::Pop;
|
||||
pub use smtp::Smtp;
|
||||
pub use store::{
|
||||
AccountId, Cached, Fetched, Filter, Held, Key, LocalFolder, MailboxId, MessageId, Scope, Sizes,
|
||||
Store, Stored, Summary, Synced,
|
||||
};
|
||||
pub use tokio_rustls::TlsConnector;
|
||||
pub use vault::{Backend, Kept, Keyring, Vault};
|
||||
@@ -0,0 +1,275 @@
|
||||
use mail_builder::{
|
||||
MessageBuilder,
|
||||
headers::{address::Address as Out, content_type::ContentType, message_id::MessageId},
|
||||
mime::MimePart,
|
||||
};
|
||||
use mail_parser::{Address, HeaderValue, MessageParser};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Error, Result, smtp::mailbox};
|
||||
|
||||
mod walk;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Mailbox {
|
||||
pub name: Option<String>,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// An attachment or a `cid:` target.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Part {
|
||||
pub name: Option<String>,
|
||||
pub content_type: String,
|
||||
/// Content-Type parameters, such as charset.
|
||||
pub params: Vec<(String, String)>,
|
||||
pub content_id: Option<String>,
|
||||
/// Transfer-decoded; a multipart keeps its headers.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A message as read; bodies are the sender's own parts.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Mail {
|
||||
pub subject: Option<String>,
|
||||
pub from: Vec<Mailbox>,
|
||||
pub to: Vec<Mailbox>,
|
||||
pub cc: Vec<Mailbox>,
|
||||
/// Seconds since the Unix epoch.
|
||||
pub date: Option<i64>,
|
||||
pub message_id: Option<String>,
|
||||
pub in_reply_to: Vec<String>,
|
||||
pub references: Vec<String>,
|
||||
pub text: Option<String>,
|
||||
pub html: Option<String>,
|
||||
pub inline: Vec<Part>,
|
||||
pub attachments: Vec<Part>,
|
||||
/// The header block as it arrived.
|
||||
pub headers: String,
|
||||
}
|
||||
|
||||
/// A message to write; Bcc stays in the SMTP envelope.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub from: Mailbox,
|
||||
pub to: Vec<Mailbox>,
|
||||
pub cc: Vec<Mailbox>,
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
pub html: Option<String>,
|
||||
pub attachments: Vec<Part>,
|
||||
pub in_reply_to: Option<String>,
|
||||
pub references: Vec<String>,
|
||||
/// Seconds since the Unix epoch.
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
pub struct Built {
|
||||
/// Without angle brackets, as `Mail::message_id` reads it.
|
||||
pub message_id: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn read(raw: &[u8]) -> Result<Mail> {
|
||||
let message = MessageParser::default()
|
||||
.parse(raw)
|
||||
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
|
||||
let problems = message
|
||||
.parts
|
||||
.iter()
|
||||
.filter(|part| part.is_encoding_problem)
|
||||
.count();
|
||||
if problems > 0 {
|
||||
log::warn!("mail: {problems} MIME parts did not decode cleanly");
|
||||
}
|
||||
if let Err(err) = walk::depth(&message) {
|
||||
walk::dispose(message);
|
||||
return Err(err);
|
||||
}
|
||||
let header_end = message.root_part().offset_body as usize;
|
||||
let shape = walk::shape(&message);
|
||||
Ok(Mail {
|
||||
subject: message.subject().map(str::to_owned),
|
||||
from: mailboxes(message.from().into_iter()),
|
||||
to: mailboxes(message.all_to()),
|
||||
cc: mailboxes(message.all_cc()),
|
||||
date: message.date().map(|date| date.to_timestamp()),
|
||||
message_id: message.message_id().map(str::to_owned),
|
||||
in_reply_to: ids(message.in_reply_to()),
|
||||
references: ids(message.references()),
|
||||
text: joined(shape.text),
|
||||
html: joined(shape.html),
|
||||
inline: shape.inline,
|
||||
attachments: shape.attachments,
|
||||
headers: String::from_utf8_lossy(&raw[..header_end.min(raw.len())]).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any header block needs SMTPUTF8.
|
||||
pub(crate) fn utf8_headers(raw: &[u8]) -> Result<bool> {
|
||||
let message = MessageParser::default()
|
||||
.parse(raw)
|
||||
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
|
||||
let checked = walk::depth(&message).map(|()| walk::utf8_headers(&message));
|
||||
walk::dispose(message);
|
||||
checked
|
||||
}
|
||||
|
||||
pub fn build(draft: &Draft) -> Result<Built> {
|
||||
let people = [&draft.from].into_iter().chain(&draft.to).chain(&draft.cc);
|
||||
for person in people {
|
||||
mailbox(&person.email)?;
|
||||
line("a name", person.name.as_deref().unwrap_or_default())?;
|
||||
}
|
||||
line("the subject", &draft.subject)?;
|
||||
for id in draft.in_reply_to.iter().chain(&draft.references) {
|
||||
line("a message ID", id)?;
|
||||
}
|
||||
let message_id = message_id(&draft.from.email)?;
|
||||
let mut builder = MessageBuilder::new()
|
||||
.message_id(message_id.as_str())
|
||||
.date(draft.date)
|
||||
.from(address(&draft.from))
|
||||
.subject(draft.subject.as_str())
|
||||
.body(body(draft)?);
|
||||
if !draft.to.is_empty() {
|
||||
builder = builder.to(Out::new_list(draft.to.iter().map(address).collect()));
|
||||
}
|
||||
if !draft.cc.is_empty() {
|
||||
builder = builder.cc(Out::new_list(draft.cc.iter().map(address).collect()));
|
||||
}
|
||||
if let Some(id) = &draft.in_reply_to {
|
||||
builder = builder.in_reply_to(id.as_str());
|
||||
}
|
||||
if !draft.references.is_empty() {
|
||||
let ids = draft.references.iter().map(String::as_str);
|
||||
builder = builder.references(MessageId::new_list(ids));
|
||||
}
|
||||
let bytes = builder
|
||||
.write_to_vec()
|
||||
.map_err(|err| Error::Mime(err.to_string()))?;
|
||||
Ok(Built { message_id, bytes })
|
||||
}
|
||||
|
||||
/// Mixed of the alternative bodies and the attachments.
|
||||
fn body(draft: &Draft) -> Result<MimePart<'_>> {
|
||||
let text = MimePart::new("text/plain", draft.text.as_str());
|
||||
let body = match &draft.html {
|
||||
Some(html) => {
|
||||
let html = MimePart::new("text/html", html.as_str());
|
||||
MimePart::new("multipart/alternative", vec![text, html])
|
||||
}
|
||||
None => text,
|
||||
};
|
||||
if draft.attachments.is_empty() {
|
||||
return Ok(body);
|
||||
}
|
||||
let mut parts = vec![body];
|
||||
for part in &draft.attachments {
|
||||
parts.push(attachment(part)?);
|
||||
}
|
||||
Ok(MimePart::new("multipart/mixed", parts))
|
||||
}
|
||||
|
||||
fn attachment(part: &Part) -> Result<MimePart<'_>> {
|
||||
let name = part
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Invalid("an attachment needs a file name".into()))?;
|
||||
line("a file name", name)?;
|
||||
line("a content type", &part.content_type)?;
|
||||
let mut kind = ContentType::new(part.content_type.as_str());
|
||||
for (key, value) in &part.params {
|
||||
line("a content type parameter", key)?;
|
||||
line("a content type parameter", value)?;
|
||||
kind = kind.attribute(key.as_str(), value.as_str());
|
||||
}
|
||||
let composite = ["multipart/", "message/"]
|
||||
.iter()
|
||||
.any(|prefix| part.content_type.starts_with(prefix));
|
||||
let mut attachment = if composite {
|
||||
// RFC 2045 §6.4: composites stay unencoded.
|
||||
let body = composite_body(part)?;
|
||||
let encoding = if body.is_ascii() { "7bit" } else { "8bit" };
|
||||
MimePart::new(kind, body).transfer_encoding(encoding)
|
||||
} else {
|
||||
MimePart::new(kind, part.bytes.as_slice())
|
||||
}
|
||||
.attachment(name);
|
||||
if let Some(id) = &part.content_id {
|
||||
line("a content ID", id)?;
|
||||
attachment = attachment.cid(id.as_str());
|
||||
}
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// A multipart as read carries its headers.
|
||||
fn composite_body(part: &Part) -> Result<&[u8]> {
|
||||
if !part.content_type.starts_with("multipart/") {
|
||||
return Ok(&part.bytes);
|
||||
}
|
||||
if !part.params.iter().any(|(key, _)| key == "boundary") {
|
||||
return Err(Error::Invalid(
|
||||
"a multipart attachment has no boundary".into(),
|
||||
));
|
||||
}
|
||||
if let Some(body) = part.bytes.strip_prefix(b"\r\n") {
|
||||
return Ok(body);
|
||||
}
|
||||
part.bytes
|
||||
.windows(4)
|
||||
.position(|end| end == b"\r\n\r\n")
|
||||
.map(|at| &part.bytes[at + 4..])
|
||||
.ok_or_else(|| Error::Invalid("a multipart attachment has no header end".into()))
|
||||
}
|
||||
|
||||
/// A random left side at the sender's domain.
|
||||
fn message_id(from: &str) -> Result<String> {
|
||||
let (_, domain) = from
|
||||
.rsplit_once('@')
|
||||
.ok_or_else(|| Error::Invalid(format!("not a mail address: {from:?}")))?;
|
||||
let mut random = [0u8; 16];
|
||||
getrandom::fill(&mut random).map_err(|err| Error::Mime(err.to_string()))?;
|
||||
let left: String = random.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
Ok(format!("{left}@{domain}"))
|
||||
}
|
||||
|
||||
/// A line break would start a new header.
|
||||
fn line(what: &str, text: &str) -> Result<()> {
|
||||
if text.contains(['\r', '\n']) {
|
||||
return Err(Error::Invalid(format!("{what} has a line break")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn address(mailbox: &Mailbox) -> Out<'_> {
|
||||
Out::new_address(mailbox.name.as_deref(), mailbox.email.as_str())
|
||||
}
|
||||
|
||||
fn mailboxes<'a>(lists: impl Iterator<Item = &'a Address<'a>>) -> Vec<Mailbox> {
|
||||
lists
|
||||
.flat_map(Address::iter)
|
||||
.filter_map(|addr| {
|
||||
let Some(email) = addr.address.as_deref() else {
|
||||
log::warn!("mail: an address without an email was skipped");
|
||||
return None;
|
||||
};
|
||||
Some(Mailbox {
|
||||
name: addr.name.as_deref().map(str::to_owned),
|
||||
email: email.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ids(value: &HeaderValue) -> Vec<String> {
|
||||
match value {
|
||||
HeaderValue::Text(id) => vec![id.to_string()],
|
||||
HeaderValue::TextList(ids) => ids.iter().map(ToString::to_string).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn joined(parts: Vec<String>) -> Option<String> {
|
||||
(!parts.is_empty()).then(|| parts.join("\n"))
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use mail_parser::{
|
||||
Encoding, Message, MessagePart, MimeHeaders, PartType,
|
||||
decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode},
|
||||
};
|
||||
|
||||
use super::Part;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Deeper trees are refused before the walk.
|
||||
const DEPTH: usize = 64;
|
||||
|
||||
/// What a MIME tree shows: bodies, `cid:` targets, attachments.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shape {
|
||||
pub text: Vec<String>,
|
||||
pub html: Vec<String>,
|
||||
pub inline: Vec<Part>,
|
||||
pub attachments: Vec<Part>,
|
||||
}
|
||||
|
||||
pub(super) fn shape(message: &Message) -> Shape {
|
||||
let mut shape = Shape::default();
|
||||
Walk { message }.body(0, &mut shape);
|
||||
shape
|
||||
}
|
||||
|
||||
/// Iterative, so no tree can exhaust the stack.
|
||||
pub(super) fn depth(message: &Message) -> Result<()> {
|
||||
let mut stack = vec![(message, 0, 1)];
|
||||
while let Some((message, id, level)) = stack.pop() {
|
||||
if level > DEPTH {
|
||||
return Err(Error::Mime(format!("MIME parts nest deeper than {DEPTH}")));
|
||||
}
|
||||
match &message.parts[id as usize].body {
|
||||
PartType::Multipart(children) => {
|
||||
stack.extend(children.iter().map(|&child| (message, child, level + 1)));
|
||||
}
|
||||
PartType::Message(inner) => stack.push((inner, 0, level + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a deep tree without recursion.
|
||||
pub(super) fn dispose(message: Message) {
|
||||
let mut stack = vec![message];
|
||||
while let Some(mut message) = stack.pop() {
|
||||
for part in &mut message.parts {
|
||||
if let PartType::Message(inner) = std::mem::take(&mut part.body) {
|
||||
stack.push(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 6531 §3.4: 8-bit header bytes at any depth.
|
||||
pub(super) fn utf8_headers(message: &Message) -> bool {
|
||||
let mut stack = vec![message];
|
||||
while let Some(message) = stack.pop() {
|
||||
// Part offsets index the whole buffer.
|
||||
let raw = message.raw_message.as_ref();
|
||||
for part in &message.parts {
|
||||
let header = &raw[part.offset_header as usize..part.offset_body as usize];
|
||||
if !header.is_ascii() {
|
||||
return true;
|
||||
}
|
||||
// Encoded parts carry no 8-bit bytes.
|
||||
match &part.body {
|
||||
PartType::Message(inner) if part.encoding == Encoding::None => stack.push(inner),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
struct Walk<'a, 'x> {
|
||||
message: &'a Message<'x>,
|
||||
}
|
||||
|
||||
impl Walk<'_, '_> {
|
||||
fn part(&self, id: u32) -> &MessagePart<'_> {
|
||||
&self.message.parts[id as usize]
|
||||
}
|
||||
|
||||
fn body(&self, id: u32, out: &mut Shape) {
|
||||
let part = self.part(id);
|
||||
// RFC 2183 §2.8, §2.9: the whole entity.
|
||||
if !inline(part) {
|
||||
out.attachments.push(self.leaf(part));
|
||||
return;
|
||||
}
|
||||
self.content(id, out, true);
|
||||
}
|
||||
|
||||
/// A related root is no `cid:` target.
|
||||
fn content(&self, id: u32, out: &mut Shape, target: bool) {
|
||||
let part = self.part(id);
|
||||
match &part.body {
|
||||
PartType::Multipart(children) => {
|
||||
self.target(part, target, out);
|
||||
match multipart(part).as_str() {
|
||||
"alternative" => self.alternative(children, out),
|
||||
"related" => self.related(part, children, out),
|
||||
_ => children.iter().for_each(|&child| self.body(child, out)),
|
||||
}
|
||||
}
|
||||
PartType::Text(text) if plain(part) => {
|
||||
out.text.push(text.to_string());
|
||||
self.target(part, target, out);
|
||||
}
|
||||
PartType::Html(html) => {
|
||||
out.html.push(html.to_string());
|
||||
self.target(part, target, out);
|
||||
}
|
||||
_ => {
|
||||
let leaf = self.leaf(part);
|
||||
let message = matches!(part.body, PartType::Message(_));
|
||||
match leaf.content_id {
|
||||
Some(_) if !message => out.inline.push(leaf),
|
||||
_ => out.attachments.push(leaf),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2392: any part with a Content-ID.
|
||||
fn target(&self, part: &MessagePart, target: bool, out: &mut Shape) {
|
||||
if target && part.content_id().is_some() {
|
||||
out.inline.push(self.leaf(part));
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2046 §5.1.4: the last supported version wins.
|
||||
fn alternative(&self, children: &[u32], out: &mut Shape) {
|
||||
let versions: Vec<Shape> = children
|
||||
.iter()
|
||||
.map(|&child| {
|
||||
let mut version = Shape::default();
|
||||
self.body(child, &mut version);
|
||||
version
|
||||
})
|
||||
.collect();
|
||||
let text = versions.iter().rposition(|v| !v.text.is_empty());
|
||||
let html = versions.iter().rposition(|v| !v.html.is_empty());
|
||||
for (at, version) in versions.into_iter().enumerate() {
|
||||
let bodiless = version.text.is_empty() && version.html.is_empty();
|
||||
if Some(at) != text && Some(at) != html && !bodiless {
|
||||
continue;
|
||||
}
|
||||
if Some(at) == text {
|
||||
out.text.extend(version.text);
|
||||
}
|
||||
if Some(at) == html {
|
||||
out.html.extend(version.html);
|
||||
}
|
||||
out.inline.extend(version.inline);
|
||||
out.attachments.extend(version.attachments);
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2387: `start` names the root; §4 voids dispositions.
|
||||
fn related(&self, part: &MessagePart, children: &[u32], out: &mut Shape) {
|
||||
let start = part
|
||||
.content_type()
|
||||
.and_then(|kind| kind.attribute("start"))
|
||||
.map(|start| start.trim().trim_start_matches('<').trim_end_matches('>'));
|
||||
let root = match start {
|
||||
None => 0,
|
||||
Some(start) => children
|
||||
.iter()
|
||||
.position(|&child| self.part(child).content_id() == Some(start))
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("mail: related start names no part; first part is the root");
|
||||
0
|
||||
}),
|
||||
};
|
||||
for (at, &child) in children.iter().enumerate() {
|
||||
if at == root {
|
||||
self.content(child, out, false);
|
||||
continue;
|
||||
}
|
||||
// RFC 2387 §6.1: a resource is one entity.
|
||||
let leaf = self.leaf(self.part(child));
|
||||
match leaf.content_id {
|
||||
Some(_) => out.inline.push(leaf),
|
||||
None => out.attachments.push(leaf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf(&self, part: &MessagePart) -> Part {
|
||||
let kind = part.content_type();
|
||||
let content_type = kind.map_or_else(
|
||||
// RFC 2046 §5.1.5: digest children are messages.
|
||||
|| match part.body {
|
||||
PartType::Message(_) => "message/rfc822".to_owned(),
|
||||
_ => "text/plain".to_owned(),
|
||||
},
|
||||
|kind| match &kind.c_subtype {
|
||||
Some(subtype) => format!("{}/{subtype}", kind.c_type),
|
||||
None => kind.c_type.to_string(),
|
||||
},
|
||||
);
|
||||
let params = kind
|
||||
.and_then(|kind| kind.attributes())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|attr| !attr.name.eq_ignore_ascii_case("name"))
|
||||
.map(|attr| (attr.name.to_ascii_lowercase(), attr.value.to_string()))
|
||||
.collect();
|
||||
Part {
|
||||
name: part.attachment_name().map(str::to_owned),
|
||||
content_type: content_type.to_ascii_lowercase(),
|
||||
params,
|
||||
content_id: part.content_id().map(str::to_owned),
|
||||
bytes: self.octets(part),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer-decoded bytes; text keeps its own charset.
|
||||
fn octets(&self, part: &MessagePart) -> Vec<u8> {
|
||||
let raw = self.message.raw_message();
|
||||
let body = &raw[part.offset_body as usize..part.offset_end as usize];
|
||||
match &part.body {
|
||||
PartType::Binary(bytes) | PartType::InlineBinary(bytes) => bytes.to_vec(),
|
||||
PartType::Message(message) => message.raw_message().to_vec(),
|
||||
PartType::Multipart(_) => {
|
||||
raw[part.offset_header as usize..part.offset_end as usize].to_vec()
|
||||
}
|
||||
PartType::Text(_) | PartType::Html(_) => {
|
||||
let decoded = match part.encoding {
|
||||
Encoding::None => Some(body.to_vec()),
|
||||
Encoding::QuotedPrintable => quoted_printable_decode(body),
|
||||
Encoding::Base64 => base64_decode(body),
|
||||
};
|
||||
decoded.unwrap_or_else(|| {
|
||||
log::warn!("mail: a text attachment kept its encoded bytes");
|
||||
body.to_vec()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2183 §2.8: unknown types are attachments.
|
||||
fn inline(part: &MessagePart) -> bool {
|
||||
part.content_disposition()
|
||||
.is_none_or(|disposition| disposition.c_type.eq_ignore_ascii_case("inline"))
|
||||
}
|
||||
|
||||
fn multipart(part: &MessagePart) -> String {
|
||||
part.content_type()
|
||||
.and_then(|kind| kind.c_subtype.as_deref())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// RFC 2045 §5.2: no Content-Type is text/plain.
|
||||
fn plain(part: &MessagePart) -> bool {
|
||||
part.content_type().is_none_or(|kind| {
|
||||
kind.c_subtype
|
||||
.as_deref()
|
||||
.is_none_or(|subtype| subtype.eq_ignore_ascii_case("plain"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::{future::Future, sync::Arc, time::Duration};
|
||||
|
||||
use rustls::{ClientConfig, crypto::ring, pki_types::ServerName};
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::{TlsConnector, client::TlsStream};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub(crate) type Stream = TlsStream<TcpStream>;
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Server {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub security: Security,
|
||||
}
|
||||
|
||||
/// No plaintext: every mode ends in TLS.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Security {
|
||||
Tls,
|
||||
StartTls,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Login {
|
||||
pub user: String,
|
||||
pub secret: Secret,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Secret {
|
||||
Password(String),
|
||||
/// An OAuth access token, sent as XOAUTH2.
|
||||
Token(String),
|
||||
}
|
||||
|
||||
impl Login {
|
||||
/// The SASL XOAUTH2 initial response, before base64.
|
||||
pub(crate) fn xoauth2(&self, token: &str) -> String {
|
||||
format!("user={}\x01auth=Bearer {token}\x01\x01", self.user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies servers with the system's trust store.
|
||||
pub fn system_tls() -> Result<TlsConnector> {
|
||||
let config = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.and_then(|builder| builder.with_platform_verifier())
|
||||
.map_err(|err| Error::Tls(err.to_string()))?
|
||||
.with_no_client_auth();
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
pub(crate) async fn within<T>(step: &'static str, work: impl Future<Output = T>) -> Result<T> {
|
||||
tokio::time::timeout(WAIT, work)
|
||||
.await
|
||||
.map_err(|_| Error::Timeout(step))
|
||||
}
|
||||
|
||||
pub(crate) async fn tcp(server: &Server) -> Result<TcpStream> {
|
||||
let stream = within(
|
||||
"connecting",
|
||||
TcpStream::connect((server.host.as_str(), server.port)),
|
||||
)
|
||||
.await?
|
||||
.map_err(Error::Connect)?;
|
||||
log::info!("mail: connected to {}:{}", server.host, server.port);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub(crate) async fn tls(tcp: TcpStream, server: &Server, tls: &TlsConnector) -> Result<Stream> {
|
||||
let name = ServerName::try_from(server.host.clone())
|
||||
.map_err(|err| Error::Tls(format!("{}: {err}", server.host)))?;
|
||||
let stream = within("starting TLS", tls.connect(name, tcp))
|
||||
.await?
|
||||
.map_err(|err| Error::Tls(err.to_string()))?;
|
||||
log::info!("mail: TLS with {}:{}", server.host, server.port);
|
||||
Ok(stream)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use std::time::SystemTime;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
mod loopback;
|
||||
mod token;
|
||||
|
||||
/// One OAuth authorization server and this app's client.
|
||||
pub struct Provider {
|
||||
pub name: &'static str,
|
||||
pub authorize: String,
|
||||
pub token: String,
|
||||
pub client_id: String,
|
||||
/// Google's desktop clients carry one; Microsoft's do not.
|
||||
pub client_secret: Option<String>,
|
||||
pub scope: String,
|
||||
/// Authorize parameters this server adds.
|
||||
pub extra: &'static [(&'static str, &'static str)],
|
||||
/// The registered loopback host.
|
||||
pub redirect_host: &'static str,
|
||||
}
|
||||
|
||||
/// Tokens from one exchange or refresh.
|
||||
pub struct Tokens {
|
||||
pub access: String,
|
||||
pub expires_at: SystemTime,
|
||||
/// A new refresh token; store it, drop the old one.
|
||||
pub refresh: Option<String>,
|
||||
/// The signed-in address, from the ID token.
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// Gmail: IMAP, POP3 and SMTP under one scope.
|
||||
pub fn google() -> Result<Provider> {
|
||||
Ok(gmail(
|
||||
built("google", option_env!("ELY_GOOGLE_CLIENT_ID"))?,
|
||||
built("google", option_env!("ELY_GOOGLE_CLIENT_SECRET"))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn gmail(client_id: String, secret: String) -> Provider {
|
||||
Provider {
|
||||
name: "google",
|
||||
authorize: "https://accounts.google.com/o/oauth2/v2/auth".into(),
|
||||
token: "https://oauth2.googleapis.com/token".into(),
|
||||
client_id,
|
||||
client_secret: Some(secret),
|
||||
scope: "https://mail.google.com/ openid email".into(),
|
||||
// Refresh token on every consent.
|
||||
extra: &[("access_type", "offline"), ("prompt", "consent")],
|
||||
redirect_host: "127.0.0.1",
|
||||
}
|
||||
}
|
||||
|
||||
/// Outlook.com and Microsoft 365.
|
||||
pub fn microsoft() -> Result<Provider> {
|
||||
Ok(outlook(built(
|
||||
"microsoft",
|
||||
option_env!("ELY_MICROSOFT_CLIENT_ID"),
|
||||
)?))
|
||||
}
|
||||
|
||||
fn outlook(client_id: String) -> Provider {
|
||||
Provider {
|
||||
name: "microsoft",
|
||||
authorize: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(),
|
||||
token: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(),
|
||||
client_id,
|
||||
client_secret: None,
|
||||
scope: [
|
||||
"https://outlook.office.com/IMAP.AccessAsUser.All",
|
||||
"https://outlook.office.com/POP.AccessAsUser.All",
|
||||
"https://outlook.office.com/SMTP.Send",
|
||||
"offline_access openid email profile",
|
||||
]
|
||||
.join(" "),
|
||||
extra: &[],
|
||||
// Port ignored only for localhost.
|
||||
redirect_host: "localhost",
|
||||
}
|
||||
}
|
||||
|
||||
fn built(name: &'static str, value: Option<&str>) -> Result<String> {
|
||||
match value {
|
||||
Some(value) if !value.is_empty() => Ok(value.to_owned()),
|
||||
_ => Err(Error::Unavailable(name)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sign-in waiting for the browser to come back.
|
||||
pub struct SignIn {
|
||||
provider: Provider,
|
||||
listener: TcpListener,
|
||||
url: String,
|
||||
redirect: String,
|
||||
state: String,
|
||||
verifier: String,
|
||||
}
|
||||
|
||||
impl SignIn {
|
||||
/// Opens the loopback port and builds the browser URL.
|
||||
pub async fn start(provider: Provider, hint: &str) -> Result<Self> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?
|
||||
.port();
|
||||
let redirect = format!("http://{}:{port}", provider.redirect_host);
|
||||
let state = random();
|
||||
let verifier = random();
|
||||
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||
let mut query = vec![
|
||||
("client_id", provider.client_id.as_str()),
|
||||
("response_type", "code"),
|
||||
("redirect_uri", redirect.as_str()),
|
||||
("scope", provider.scope.as_str()),
|
||||
("state", state.as_str()),
|
||||
("code_challenge", challenge.as_str()),
|
||||
("code_challenge_method", "S256"),
|
||||
];
|
||||
query.extend_from_slice(provider.extra);
|
||||
if !hint.is_empty() {
|
||||
query.push(("login_hint", hint));
|
||||
}
|
||||
let url = format!("{}?{}", provider.authorize, encode(&query));
|
||||
log::info!("oauth: {} sign-in waits on 127.0.0.1:{port}", provider.name);
|
||||
Ok(Self {
|
||||
provider,
|
||||
listener,
|
||||
url,
|
||||
redirect,
|
||||
state,
|
||||
verifier,
|
||||
})
|
||||
}
|
||||
|
||||
/// The page to open in the system browser.
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Waits for the redirect, then trades the code for tokens.
|
||||
pub async fn finish(self) -> Result<Tokens> {
|
||||
let code = loopback::code(&self.listener, &self.state).await?;
|
||||
log::info!("oauth: {} returned a code", self.provider.name);
|
||||
drop(self.listener);
|
||||
let mut form = vec![
|
||||
("grant_type", "authorization_code".to_owned()),
|
||||
("client_id", self.provider.client_id.clone()),
|
||||
("code", code),
|
||||
("code_verifier", self.verifier),
|
||||
("redirect_uri", self.redirect),
|
||||
];
|
||||
if let Some(secret) = &self.provider.client_secret {
|
||||
form.push(("client_secret", secret.clone()));
|
||||
}
|
||||
let tokens = token::post(&self.provider, form, false).await?;
|
||||
if tokens.refresh.is_none() {
|
||||
return Err(Error::SignIn("no refresh token granted".into()));
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh access token; a refused refresh needs sign-in again.
|
||||
pub async fn refresh(provider: &Provider, refresh: &str) -> Result<Tokens> {
|
||||
let mut form = vec![
|
||||
("grant_type", "refresh_token".to_owned()),
|
||||
("client_id", provider.client_id.clone()),
|
||||
("refresh_token", refresh.to_owned()),
|
||||
];
|
||||
if let Some(secret) = &provider.client_secret {
|
||||
form.push(("client_secret", secret.clone()));
|
||||
}
|
||||
token::post(provider, form, true).await
|
||||
}
|
||||
|
||||
/// 32 random bytes, base64url: a PKCE verifier or a state.
|
||||
fn random() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).expect("system randomness");
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn encode(pairs: &[(&str, &str)]) -> String {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}={}", escape(key), escape(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in text.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
|
||||
out.push(byte as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_missing_client_id_makes_the_provider_unavailable() {
|
||||
assert!(matches!(
|
||||
built("google", None),
|
||||
Err(Error::Unavailable("google"))
|
||||
));
|
||||
assert!(matches!(
|
||||
built("google", Some("")),
|
||||
Err(Error::Unavailable("google"))
|
||||
));
|
||||
assert_eq!(built("google", Some("id-1")).unwrap(), "id-1");
|
||||
}
|
||||
|
||||
async fn asked(provider: Provider) -> Vec<(String, String)> {
|
||||
let sign_in = SignIn::start(provider, "").await.unwrap();
|
||||
let (_, query) = sign_in.url().split_once('?').unwrap();
|
||||
form_urlencoded::parse(query.as_bytes())
|
||||
.into_owned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gmail_asks_for_a_refresh_token_every_time() {
|
||||
let pairs = asked(gmail("id".into(), "s".into())).await;
|
||||
assert_eq!(value(&pairs, "access_type"), Some("offline"));
|
||||
assert_eq!(value(&pairs, "prompt"), Some("consent"));
|
||||
assert!(
|
||||
value(&pairs, "scope")
|
||||
.unwrap()
|
||||
.contains("https://mail.google.com/")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outlook_asks_for_offline_access_and_the_address_claims() {
|
||||
let pairs = asked(outlook("id".into())).await;
|
||||
let scope: Vec<&str> = value(&pairs, "scope").unwrap().split(' ').collect();
|
||||
for needed in ["offline_access", "openid", "email", "profile"] {
|
||||
assert!(scope.contains(&needed), "{needed}");
|
||||
}
|
||||
assert_eq!(value(&pairs, "access_type"), None);
|
||||
assert!(
|
||||
value(&pairs, "redirect_uri")
|
||||
.unwrap()
|
||||
.starts_with("http://localhost:")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_values_are_escaped() {
|
||||
assert_eq!(
|
||||
encode(&[("scope", "a b/c"), ("x", "é~")]),
|
||||
"scope=a%20b%2Fc&x=%C3%A9~"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const LIMIT: usize = 8 * 1024;
|
||||
const READ: Duration = Duration::from_secs(10);
|
||||
const PAGE: &str = "ElyMail: sign-in finished. You can close this tab.";
|
||||
|
||||
/// Serves redirects until one carries a code or error.
|
||||
pub(super) async fn code(listener: &TcpListener, state: &str) -> Result<String> {
|
||||
loop {
|
||||
let (mut stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|err| Error::SignIn(format!("loopback accept failed: {err}")))?;
|
||||
let Some(target) = request(&mut stream).await else {
|
||||
continue;
|
||||
};
|
||||
let query = target.split_once('?').map_or("", |(_, query)| query);
|
||||
let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes())
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
let field = |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
if field("code").is_none() && field("error").is_none() {
|
||||
reply(&mut stream, "404 Not Found", "").await;
|
||||
continue;
|
||||
}
|
||||
reply(&mut stream, "200 OK", PAGE).await;
|
||||
if field("state").as_deref() != Some(state) {
|
||||
return Err(Error::SignIn("redirect state does not match".into()));
|
||||
}
|
||||
if let Some(error) = field("error") {
|
||||
return Err(Error::SignIn(match field("error_description") {
|
||||
Some(text) => format!("{error}: {text}"),
|
||||
None => error,
|
||||
}));
|
||||
}
|
||||
return field("code").ok_or_else(|| Error::SignIn("redirect without a code".into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The request target of one GET, None for anything else.
|
||||
async fn request(stream: &mut TcpStream) -> Option<String> {
|
||||
let mut head = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !head.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let read = tokio::time::timeout(READ, stream.read(&mut chunk)).await;
|
||||
match read {
|
||||
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
|
||||
Ok(Ok(count)) => head.extend_from_slice(&chunk[..count]),
|
||||
}
|
||||
if head.len() > LIMIT {
|
||||
log::warn!("oauth: loopback request over {LIMIT} bytes dropped");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let line = head.split(|byte| *byte == b'\n').next()?;
|
||||
let line = std::str::from_utf8(line).ok()?.trim_end();
|
||||
let mut parts = line.split(' ');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some("GET"), Some(target)) => Some(target.to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(stream: &mut TcpStream, status: &str, body: &str) {
|
||||
let text = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
if let Err(err) = stream.write_all(text.as_bytes()).await {
|
||||
log::warn!("oauth: loopback reply failed: {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use serde::Deserialize;
|
||||
use ureq::Agent;
|
||||
|
||||
use super::{Provider, Tokens};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Granted {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Refused {
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
email: Option<String>,
|
||||
preferred_username: Option<String>,
|
||||
}
|
||||
|
||||
/// Posts to the token endpoint off the async thread.
|
||||
pub(super) async fn post(
|
||||
provider: &Provider,
|
||||
form: Vec<(&'static str, String)>,
|
||||
refreshing: bool,
|
||||
) -> Result<Tokens> {
|
||||
let url = provider.token.clone();
|
||||
let name = provider.name;
|
||||
let started = SystemTime::now();
|
||||
let (status, body) = tokio::task::spawn_blocking(move || send(&url, &form))
|
||||
.await
|
||||
.map_err(|err| Error::SignIn(format!("token request stopped: {err}")))??;
|
||||
if status == 200 {
|
||||
let tokens = granted(&body, started)?;
|
||||
log::info!(
|
||||
"oauth: {name} token granted, refresh {}",
|
||||
tokens.refresh.is_some()
|
||||
);
|
||||
return Ok(tokens);
|
||||
}
|
||||
let refused: Refused = serde_json::from_str(&body)
|
||||
.map_err(|_| Error::SignIn(format!("token endpoint answered {status}")))?;
|
||||
let reason = match refused.error_description {
|
||||
Some(text) => format!("{}: {text}", refused.error),
|
||||
None => refused.error.clone(),
|
||||
};
|
||||
log::warn!("oauth: {name} token refused ({status}, {})", refused.error);
|
||||
if refreshing
|
||||
&& matches!(
|
||||
refused.error.as_str(),
|
||||
"invalid_grant" | "interaction_required"
|
||||
)
|
||||
{
|
||||
return Err(Error::SignInAgain(reason));
|
||||
}
|
||||
Err(Error::SignIn(reason))
|
||||
}
|
||||
|
||||
fn send(url: &str, form: &[(&'static str, String)]) -> Result<(u16, String)> {
|
||||
let agent: Agent = Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
let mut response = agent
|
||||
.post(url)
|
||||
.send_form(form.iter().map(|(key, value)| (*key, value.as_str())))
|
||||
.map_err(|err| Error::SignIn(format!("token endpoint unreachable: {err}")))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
fn granted(body: &str, started: SystemTime) -> Result<Tokens> {
|
||||
let granted: Granted = serde_json::from_str(body)
|
||||
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
|
||||
let email = match &granted.id_token {
|
||||
Some(token) => email(token)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(Tokens {
|
||||
access: granted.access_token,
|
||||
expires_at: started + Duration::from_secs(granted.expires_in),
|
||||
refresh: granted.refresh_token,
|
||||
email,
|
||||
})
|
||||
}
|
||||
|
||||
/// The address claim; the token came straight from the endpoint.
|
||||
fn email(token: &str) -> Result<Option<String>> {
|
||||
let unreadable = || Error::SignIn("ID token unreadable".into());
|
||||
let payload = token.split('.').nth(1).ok_or_else(unreadable)?;
|
||||
let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|_| unreadable())?;
|
||||
let claims: Claims = serde_json::from_slice(&bytes).map_err(|_| unreadable())?;
|
||||
Ok(claims.email.or(claims.preferred_username))
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use std::io;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
net::{self, Login, Secret, Security, Server, Stream, within},
|
||||
};
|
||||
|
||||
/// A logged-in POP3 session (RFC 1939) over TLS.
|
||||
pub struct Pop {
|
||||
io: BufReader<Stream>,
|
||||
}
|
||||
|
||||
impl Pop {
|
||||
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
|
||||
let tcp = net::tcp(server).await?;
|
||||
let stream = match server.security {
|
||||
Security::Tls => net::tls(tcp, server, tls).await?,
|
||||
Security::StartTls => {
|
||||
let mut plain = BufReader::new(tcp);
|
||||
within("waiting for the greeting", status(&mut plain)).await??;
|
||||
within("starting TLS", ask(&mut plain, "STLS")).await??;
|
||||
// Plain bytes here would pass as TLS data.
|
||||
if !plain.buffer().is_empty() {
|
||||
return Err(Error::Protocol("the server sent data after STLS".into()));
|
||||
}
|
||||
net::tls(plain.into_inner(), server, tls).await?
|
||||
}
|
||||
};
|
||||
let mut io = BufReader::new(stream);
|
||||
if server.security == Security::Tls {
|
||||
within("waiting for the greeting", status(&mut io)).await??;
|
||||
}
|
||||
within("logging in", authenticate(&mut io, login)).await??;
|
||||
log::info!("mail: POP3 login on {}", server.host);
|
||||
Ok(Self { io })
|
||||
}
|
||||
|
||||
/// Message numbers with their unique ids.
|
||||
pub async fn uids(&mut self) -> Result<Vec<(usize, String)>> {
|
||||
let list = within("listing messages", async {
|
||||
ask(&mut self.io, "UIDL").await?;
|
||||
lines(&mut self.io).await
|
||||
})
|
||||
.await??;
|
||||
list.split(|&byte| byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
let line = std::str::from_utf8(line.strip_suffix(b"\r").unwrap_or(line))
|
||||
.map_err(|_| Error::Protocol("UIDL sent a line that is not text".into()))?;
|
||||
let unreadable = || Error::Protocol(format!("UIDL sent {line:?}"));
|
||||
let (number, id) = line.split_once(' ').ok_or_else(unreadable)?;
|
||||
let number = number.parse().map_err(|_| unreadable())?;
|
||||
Ok((number, id.trim_end().to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn fetch(&mut self, number: usize) -> Result<Vec<u8>> {
|
||||
within("fetching the message", async {
|
||||
ask(&mut self.io, &format!("RETR {number}")).await?;
|
||||
lines(&mut self.io).await
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Marks it deleted; QUIT carries that out.
|
||||
pub async fn delete(&mut self, number: usize) -> Result<()> {
|
||||
within(
|
||||
"deleting the message",
|
||||
ask(&mut self.io, &format!("DELE {number}")),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
pub async fn quit(mut self) -> Result<()> {
|
||||
within("logging out", ask(&mut self.io, "QUIT")).await??;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate<S>(io: &mut BufReader<S>, login: &Login) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
match &login.secret {
|
||||
Secret::Password(password) => {
|
||||
one_line(&login.user)?;
|
||||
one_line(password)?;
|
||||
ask(io, &format!("USER {}", login.user))
|
||||
.await
|
||||
.map_err(refused)?;
|
||||
ask(io, &format!("PASS {password}"))
|
||||
.await
|
||||
.map_err(refused)?;
|
||||
}
|
||||
Secret::Token(token) => {
|
||||
send(io, "AUTH XOAUTH2").await?;
|
||||
if status(io).await.map_err(refused)?.is_none() {
|
||||
return Err(Error::Protocol(
|
||||
"XOAUTH2 was accepted before the token".into(),
|
||||
));
|
||||
}
|
||||
send(io, &STANDARD.encode(login.xoauth2(token))).await?;
|
||||
if status(io).await.map_err(refused)?.is_some() {
|
||||
// An error challenge; the empty answer draws -ERR.
|
||||
send(io, "").await?;
|
||||
status(io).await.map_err(refused)?;
|
||||
return Err(Error::Protocol(
|
||||
"the token was challenged, then accepted".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Text after `+OK`, or `Some` after a `+` challenge.
|
||||
async fn status<S>(io: &mut BufReader<S>) -> Result<Option<String>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let line = line(io).await?;
|
||||
let words = |rest: &[u8]| String::from_utf8_lossy(rest).trim().to_owned();
|
||||
if line.starts_with(b"+OK") {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix(b"-ERR") {
|
||||
return Err(Error::Server(words(rest)));
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix(b"+") {
|
||||
return Ok(Some(words(rest)));
|
||||
}
|
||||
Err(Error::Protocol(
|
||||
"the server's line was neither +OK nor -ERR".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// One command that must end in `+OK`.
|
||||
async fn ask<S>(io: &mut BufReader<S>, command: &str) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
send(io, command).await?;
|
||||
match status(io).await? {
|
||||
None => Ok(()),
|
||||
Some(_) => Err(Error::Protocol(
|
||||
"the server sent a challenge to a command".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send<S>(io: &mut BufReader<S>, command: &str) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let stream = io.get_mut();
|
||||
stream
|
||||
.write_all(command.as_bytes())
|
||||
.await
|
||||
.map_err(Error::Connect)?;
|
||||
stream.write_all(b"\r\n").await.map_err(Error::Connect)?;
|
||||
stream.flush().await.map_err(Error::Connect)
|
||||
}
|
||||
|
||||
async fn line<S>(io: &mut BufReader<S>) -> Result<Vec<u8>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = Vec::new();
|
||||
io.read_until(b'\n', &mut line)
|
||||
.await
|
||||
.map_err(Error::Connect)?;
|
||||
if line.last() != Some(&b'\n') {
|
||||
return Err(Error::Connect(io::ErrorKind::UnexpectedEof.into()));
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// A multi-line answer, dot-unstuffed, line ends kept.
|
||||
async fn lines<S>(io: &mut BufReader<S>) -> Result<Vec<u8>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
let line = line(io).await?;
|
||||
if line == b".\r\n" || line == b".\n" {
|
||||
return Ok(body);
|
||||
}
|
||||
body.extend_from_slice(line.strip_prefix(b".").unwrap_or(&line));
|
||||
}
|
||||
}
|
||||
|
||||
/// A line break would start a second command.
|
||||
fn one_line(text: &str) -> Result<()> {
|
||||
if text.contains(['\r', '\n']) {
|
||||
return Err(Error::Invalid(
|
||||
"a user name or password holds a line break".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refused(err: Error) -> Error {
|
||||
match err {
|
||||
Error::Server(text) => Error::Rejected(text),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use mail_send::{
|
||||
Credentials, Error as SendError, SmtpClient,
|
||||
smtp::{AssertReply, message::Parameters},
|
||||
};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
net::{self, Login, Secret, Security, Server, Stream, within},
|
||||
};
|
||||
|
||||
/// EHLO name: an address literal, so no host name leaks.
|
||||
const HELLO: &str = "[127.0.0.1]";
|
||||
const WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// A logged-in SMTP session over TLS.
|
||||
pub struct Smtp {
|
||||
client: SmtpClient<Stream>,
|
||||
/// The server takes UTF-8 addresses (SMTPUTF8).
|
||||
utf8: bool,
|
||||
/// The server takes 8-bit bodies (8BITMIME).
|
||||
eight: bool,
|
||||
}
|
||||
|
||||
impl Smtp {
|
||||
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
|
||||
let tcp = net::tcp(server).await?;
|
||||
let mut client = match server.security {
|
||||
Security::Tls => {
|
||||
let mut client = smtp(net::tls(tcp, server, tls).await?);
|
||||
greeting(&mut client).await?;
|
||||
client
|
||||
}
|
||||
Security::StartTls => {
|
||||
let mut plain = smtp(tcp);
|
||||
greeting(&mut plain).await?;
|
||||
let hello = within("greeting", plain.ehlo(HELLO))
|
||||
.await?
|
||||
.map_err(failed)?;
|
||||
if !hello.has_capability(smtp_proto::EXT_START_TLS) {
|
||||
return Err(Error::Server("the server does not offer STARTTLS".into()));
|
||||
}
|
||||
within("starting TLS", plain.start_tls(tls, &server.host))
|
||||
.await?
|
||||
.map_err(failed)?
|
||||
}
|
||||
};
|
||||
let hello = within("greeting", client.capabilities(HELLO, false))
|
||||
.await?
|
||||
.map_err(failed)?;
|
||||
match &login.secret {
|
||||
Secret::Password(password) => {
|
||||
let credentials = Credentials::Plain {
|
||||
username: login.user.clone(),
|
||||
secret: password.clone(),
|
||||
};
|
||||
within("logging in", client.authenticate(&credentials, &hello))
|
||||
.await?
|
||||
.map_err(refused)?;
|
||||
}
|
||||
Secret::Token(token) => {
|
||||
if hello.auth_mechanisms & smtp_proto::AUTH_XOAUTH2 == 0 {
|
||||
return Err(Error::Rejected("the server does not offer XOAUTH2".into()));
|
||||
}
|
||||
within("logging in", xoauth2(&mut client, &login.xoauth2(token))).await??;
|
||||
}
|
||||
}
|
||||
log::info!("mail: SMTP login on {}", server.host);
|
||||
let utf8 = hello.has_capability(smtp_proto::EXT_SMTP_UTF8);
|
||||
let eight = hello.has_capability(smtp_proto::EXT_8BIT_MIME);
|
||||
Ok(Self {
|
||||
client,
|
||||
utf8,
|
||||
eight,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hands one message to the server for these recipients.
|
||||
pub async fn send(&mut self, from: &str, to: &[String], message: &[u8]) -> Result<()> {
|
||||
if to.is_empty() {
|
||||
return Err(Error::Invalid("a message needs a recipient".into()));
|
||||
}
|
||||
for email in to.iter().map(String::as_str).chain([from]) {
|
||||
mailbox(email)?;
|
||||
}
|
||||
let data = data(message)?;
|
||||
let headers = crate::mime::utf8_headers(message)?;
|
||||
let mut params = Parameters::new();
|
||||
let body = message
|
||||
.windows(4)
|
||||
.position(|end| end == b"\r\n\r\n")
|
||||
.map_or(&[][..], |at| &message[at..]);
|
||||
if !body.is_ascii() {
|
||||
if !self.eight {
|
||||
return Err(Error::Invalid(
|
||||
"the server does not take 8-bit message bodies".into(),
|
||||
));
|
||||
}
|
||||
params.add(("BODY", "8BITMIME"));
|
||||
}
|
||||
if !from.is_ascii() || to.iter().any(|to| !to.is_ascii()) || headers {
|
||||
if !self.utf8 {
|
||||
return Err(Error::Invalid(
|
||||
"the server does not take non-ASCII addresses or headers".into(),
|
||||
));
|
||||
}
|
||||
params.add("SMTPUTF8");
|
||||
}
|
||||
let sent = within("sending", async {
|
||||
self.client.mail_from(from, ¶ms).await?;
|
||||
for to in to {
|
||||
self.client.rcpt_to(to, &Parameters::new()).await?;
|
||||
}
|
||||
self.client.cmd(b"DATA\r\n").await?.assert_code(354)?;
|
||||
self.client.stream.write_all(&data).await?;
|
||||
self.client.stream.flush().await?;
|
||||
self.client.read().await?.assert_positive_completion()
|
||||
})
|
||||
.await?;
|
||||
if let Err(SendError::UnexpectedReply(reply)) = sent {
|
||||
// A refusal leaves the transaction open.
|
||||
let words = format!("{} {}", reply.code(), reply.message());
|
||||
return Err(match within("resetting", self.client.rset()).await? {
|
||||
Ok(()) => Error::Server(words),
|
||||
Err(err) => Error::Server(format!("{words}; RSET failed: {}", failed(err))),
|
||||
});
|
||||
}
|
||||
sent.map_err(failed)?;
|
||||
log::info!("mail: SMTP accepted a message for {} recipients", to.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn quit(self) -> Result<()> {
|
||||
within("logging out", self.client.quit())
|
||||
.await?
|
||||
.map_err(failed)
|
||||
}
|
||||
}
|
||||
|
||||
fn smtp<T: tokio::io::AsyncRead + tokio::io::AsyncWrite>(stream: T) -> SmtpClient<T> {
|
||||
SmtpClient {
|
||||
stream,
|
||||
timeout: WAIT,
|
||||
}
|
||||
}
|
||||
|
||||
async fn greeting<T>(client: &mut SmtpClient<T>) -> Result<()>
|
||||
where
|
||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let reply = within("waiting for the greeting", client.read())
|
||||
.await?
|
||||
.map_err(failed)?;
|
||||
reply.assert_positive_completion().map_err(failed)
|
||||
}
|
||||
|
||||
/// Gmail's exchange: an error challenge draws an empty line.
|
||||
async fn xoauth2<T>(client: &mut SmtpClient<T>, initial: &str) -> Result<()>
|
||||
where
|
||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let command = format!("AUTH XOAUTH2 {}\r\n", STANDARD.encode(initial));
|
||||
let mut reply = client.cmd(command).await.map_err(failed)?;
|
||||
if reply.code() == 334 {
|
||||
reply = client.cmd("\r\n").await.map_err(failed)?;
|
||||
}
|
||||
match reply.code() {
|
||||
235 => Ok(()),
|
||||
code => Err(Error::Rejected(format!("{code} {}", reply.message()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 5321 §4.5.2: a leading dot doubles.
|
||||
fn data(message: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(message.len() + 8);
|
||||
let mut start = true;
|
||||
for (at, &byte) in message.iter().enumerate() {
|
||||
let bare = match byte {
|
||||
b'\r' => message.get(at + 1) != Some(&b'\n'),
|
||||
b'\n' => at == 0 || message[at - 1] != b'\r',
|
||||
_ => false,
|
||||
};
|
||||
if bare {
|
||||
return Err(Error::Invalid("the message has a bare CR or LF".into()));
|
||||
}
|
||||
if start && byte == b'.' {
|
||||
out.push(b'.');
|
||||
}
|
||||
out.push(byte);
|
||||
start = byte == b'\n';
|
||||
}
|
||||
if !out.is_empty() && !out.ends_with(b"\r\n") {
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out.extend_from_slice(b".\r\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// RFC 5321 §4.1.2 Mailbox, with RFC 6531 UTF-8.
|
||||
pub(crate) fn mailbox(email: &str) -> Result<()> {
|
||||
let readable = email.rsplit_once('@').is_some_and(|(local, domain)| {
|
||||
let local = match local
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
{
|
||||
Some(inner) => quoted(inner),
|
||||
None => dotted(local),
|
||||
};
|
||||
local && domain_ok(domain)
|
||||
});
|
||||
if !readable {
|
||||
return Err(Error::Invalid(format!("not a mail address: {email:?}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quoted(inner: &str) -> bool {
|
||||
let mut chars = inner.chars();
|
||||
while let Some(char) = chars.next() {
|
||||
let fine = match char {
|
||||
'\\' => chars.next().is_some_and(|next| matches!(next, ' '..='~')),
|
||||
'"' => false,
|
||||
' '..='~' => true,
|
||||
_ => !char.is_ascii(),
|
||||
};
|
||||
if !fine {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn dotted(local: &str) -> bool {
|
||||
let atext = |char: char| {
|
||||
char.is_ascii_alphanumeric() || "!#$%&'*+-/=?^_`{|}~".contains(char) || !char.is_ascii()
|
||||
};
|
||||
local
|
||||
.split('.')
|
||||
.all(|atom| !atom.is_empty() && atom.chars().all(atext))
|
||||
}
|
||||
|
||||
fn domain_ok(domain: &str) -> bool {
|
||||
if let Some(literal) = domain
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
{
|
||||
return !literal.is_empty()
|
||||
&& literal
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, 33..=90 | 94..=126));
|
||||
}
|
||||
domain.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
&& label
|
||||
.chars()
|
||||
.all(|char| char.is_ascii_alphanumeric() || char == '-' || !char.is_ascii())
|
||||
})
|
||||
}
|
||||
|
||||
fn refused(err: SendError) -> Error {
|
||||
match err {
|
||||
SendError::AuthenticationFailed(reply) => {
|
||||
Error::Rejected(format!("{} {}", reply.code(), reply.message()))
|
||||
}
|
||||
SendError::UnsupportedAuthMechanism => {
|
||||
Error::Rejected("the server offers no login method for these credentials".into())
|
||||
}
|
||||
other => failed(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(err: SendError) -> Error {
|
||||
match err {
|
||||
SendError::Io(err) => Error::Connect(err),
|
||||
SendError::Tls(err) => Error::Tls(err.to_string()),
|
||||
SendError::Timeout => Error::Timeout("talking to the server"),
|
||||
SendError::UnexpectedReply(reply) => {
|
||||
Error::Server(format!("{} {}", reply.code(), reply.message()))
|
||||
}
|
||||
other => Error::Protocol(other.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! The local index, the originals and the search index.
|
||||
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
mod accounts;
|
||||
mod aside;
|
||||
mod folders;
|
||||
mod html;
|
||||
mod listing;
|
||||
mod messages;
|
||||
mod schema;
|
||||
mod search;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use accounts::Cached;
|
||||
pub use folders::{Held, LocalFolder, Synced};
|
||||
pub use listing::{Filter, Scope, Summary};
|
||||
pub use messages::{Fetched, Key, Stored};
|
||||
|
||||
const DB: &str = "mail.db";
|
||||
const CONTENT: &str = "content";
|
||||
const BACKUPS: &str = "backups";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct AccountId(pub i64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MailboxId(pub i64);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct MessageId(pub i64);
|
||||
|
||||
/// Bytes on disk per kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Sizes {
|
||||
pub index: u64,
|
||||
pub search: u64,
|
||||
pub originals: u64,
|
||||
}
|
||||
|
||||
pub struct Store {
|
||||
conn: Connection,
|
||||
dir: PathBuf,
|
||||
/// Why writes are refused, when they are.
|
||||
read_only: Option<String>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(dir: &Path) -> Result<Self> {
|
||||
open(dir, schema::STEPS)
|
||||
}
|
||||
|
||||
pub fn read_only(&self) -> Option<&str> {
|
||||
self.read_only.as_deref()
|
||||
}
|
||||
|
||||
fn writable(&self) -> Result<()> {
|
||||
match &self.read_only {
|
||||
Some(reason) => Err(Error::ReadOnly(reason.clone())),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mailbox by name, made on first use.
|
||||
pub fn mailbox(&self, account: AccountId, name: &str) -> Result<MailboxId> {
|
||||
let name = mailbox_name(name);
|
||||
let find = "SELECT id FROM mailboxes WHERE account = ?1 AND name = ?2";
|
||||
if let Some(id) = self
|
||||
.conn
|
||||
.query_row(find, params![account.0, name], |row| row.get(0))
|
||||
.optional()?
|
||||
{
|
||||
return Ok(MailboxId(id));
|
||||
}
|
||||
self.writable()?;
|
||||
self.conn.execute(
|
||||
"INSERT INTO mailboxes (account, name) VALUES (?1, ?2)",
|
||||
params![account.0, name],
|
||||
)?;
|
||||
Ok(MailboxId(self.conn.last_insert_rowid()))
|
||||
}
|
||||
|
||||
/// The index counts the WAL and shared-memory files.
|
||||
pub fn sizes(&self) -> Result<Sizes> {
|
||||
let mut files = 0;
|
||||
for name in [DB.to_owned(), format!("{DB}-wal"), format!("{DB}-shm")] {
|
||||
files += match fs::metadata(self.dir.join(&name)) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => 0,
|
||||
Err(err) => return Err(disk("measuring the index", err)),
|
||||
};
|
||||
}
|
||||
let search: i64 = self.conn.query_row(
|
||||
"SELECT coalesce(sum(pgsize), 0) FROM dbstat
|
||||
WHERE name = 'search' OR name GLOB 'search_*'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let search = u64::try_from(search)
|
||||
.map_err(|_| Error::Store(format!("a negative size: {search}")))?;
|
||||
let index = files.checked_sub(search).ok_or_else(|| {
|
||||
Error::Store(format!("search pages {search} exceed the files {files}"))
|
||||
})?;
|
||||
Ok(Sizes {
|
||||
index,
|
||||
search,
|
||||
originals: folder_size(&self.dir.join(CONTENT))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn open(dir: &Path, steps: &[&str]) -> Result<Store> {
|
||||
let path = dir.join(DB);
|
||||
fs::create_dir_all(dir.join(CONTENT)).map_err(|err| disk("making the store", err))?;
|
||||
let mut conn = Connection::open(&path)?;
|
||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
|
||||
let version = usize::try_from(version)
|
||||
.map_err(|_| Error::Store(format!("schema version {version} is negative")))?;
|
||||
let target = steps.len();
|
||||
let refused = if version > target {
|
||||
Some(format!(
|
||||
"written by a newer ElyMail (schema v{version}, this one reads v{target})"
|
||||
))
|
||||
} else if version < target {
|
||||
upgrade(&mut conn, dir, version, steps).err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = refused {
|
||||
log::warn!("mail: store opened read-only: {reason}");
|
||||
drop(conn);
|
||||
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
|
||||
let conn = Connection::open_with_flags(&path, flags)?;
|
||||
return Ok(Store {
|
||||
conn,
|
||||
dir: dir.to_owned(),
|
||||
read_only: Some(reason),
|
||||
});
|
||||
}
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
aside::recover(&conn, dir)?;
|
||||
log::info!("mail: store opened at schema v{target}");
|
||||
Ok(Store {
|
||||
conn,
|
||||
dir: dir.to_owned(),
|
||||
read_only: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A backup comes first; a failure keeps the old data.
|
||||
fn upgrade(
|
||||
conn: &mut Connection,
|
||||
dir: &Path,
|
||||
version: usize,
|
||||
steps: &[&str],
|
||||
) -> std::result::Result<(), String> {
|
||||
if version > 0 {
|
||||
backup(conn, dir, version).map_err(|err| format!("no backup before upgrade: {err}"))?;
|
||||
}
|
||||
migrate(conn, version, steps).map_err(|err| {
|
||||
format!(
|
||||
"upgrade from schema v{version} to v{} failed: {err}",
|
||||
steps.len()
|
||||
)
|
||||
})?;
|
||||
log::info!("mail: store schema v{version} upgraded to v{}", steps.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate(conn: &mut Connection, version: usize, steps: &[&str]) -> Result<()> {
|
||||
// A rebuilt parent would cascade into its children.
|
||||
conn.pragma_update(None, "foreign_keys", false)?;
|
||||
let tx = conn.transaction()?;
|
||||
for step in &steps[version..] {
|
||||
tx.execute_batch(step)?;
|
||||
}
|
||||
if tx.prepare("PRAGMA foreign_key_check")?.exists([])? {
|
||||
return Err(Error::Store("rows point at missing rows".into()));
|
||||
}
|
||||
tx.pragma_update(None, "user_version", steps.len() as i64)?;
|
||||
Ok(tx.commit()?)
|
||||
}
|
||||
|
||||
fn backup(conn: &Connection, dir: &Path, version: usize) -> Result<()> {
|
||||
let folder = dir.join(BACKUPS);
|
||||
fs::create_dir_all(&folder).map_err(|err| disk("making the backup folder", err))?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| Error::Store(format!("the clock is before 1970: {err}")))?
|
||||
.as_secs();
|
||||
let target = folder.join(format!("mail-v{version}-{now}.db"));
|
||||
let target = target
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::Store("the backup path is not UTF-8".into()))?;
|
||||
conn.execute("VACUUM INTO ?1", [target])?;
|
||||
log::info!("mail: store schema v{version} backed up before upgrade");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two levels: `content/ab/abcd…`.
|
||||
fn folder_size(folder: &Path) -> Result<u64> {
|
||||
let mut total = 0;
|
||||
for shard in fs::read_dir(folder).map_err(|err| disk("reading the originals", err))? {
|
||||
let shard = shard.map_err(|err| disk("reading the originals", err))?;
|
||||
for file in fs::read_dir(shard.path()).map_err(|err| disk("reading the originals", err))? {
|
||||
let meta = file
|
||||
.and_then(|file| file.metadata())
|
||||
.map_err(|err| disk("reading the originals", err))?;
|
||||
total += meta.len();
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn disk(step: &str, err: io::Error) -> Error {
|
||||
Error::Store(format!("{step}: {err}"))
|
||||
}
|
||||
|
||||
/// RFC 3501 §5.1: INBOX in any case is INBOX.
|
||||
fn mailbox_name(name: &str) -> &str {
|
||||
match name.eq_ignore_ascii_case("INBOX") {
|
||||
true => "INBOX",
|
||||
false => name,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Accounts: added, listed, and removed with their cache.
|
||||
|
||||
use std::{fs, io::ErrorKind};
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::{AccountId, CONTENT, Store, aside::Aside, disk};
|
||||
use crate::{Account, Error, Result};
|
||||
|
||||
/// What removing an account deletes here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Cached {
|
||||
pub messages: u64,
|
||||
/// Originals no other account shares.
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Addresses differ in more than ASCII case.
|
||||
pub fn add_account(&self, account: &Account) -> Result<AccountId> {
|
||||
self.writable()?;
|
||||
let settings = serde_json::to_string(account)?;
|
||||
let added = self.conn.execute(
|
||||
"INSERT INTO accounts (address, settings) VALUES (?1, ?2)",
|
||||
params![account.address, settings],
|
||||
);
|
||||
match added {
|
||||
Ok(_) => Ok(AccountId(self.conn.last_insert_rowid())),
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
|
||||
{
|
||||
Err(Error::Taken(account.address.clone()))
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accounts(&self) -> Result<Vec<(AccountId, Account)>> {
|
||||
let mut query = self
|
||||
.conn
|
||||
.prepare("SELECT id, settings FROM accounts ORDER BY id")?;
|
||||
let rows = query.query_map([], |row| {
|
||||
Ok((AccountId(row.get(0)?), row.get::<_, String>(1)?))
|
||||
})?;
|
||||
rows.map(|row| {
|
||||
let (id, settings) = row?;
|
||||
Ok((id, serde_json::from_str(&settings)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn cached(&self, account: AccountId) -> Result<Cached> {
|
||||
let messages: i64 = self.conn.query_row(
|
||||
"SELECT count(*) FROM messages JOIN mailboxes ON messages.mailbox = mailboxes.id
|
||||
WHERE mailboxes.account = ?1",
|
||||
[account.0],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let mut bytes = 0;
|
||||
for hash in self.only_in(account)? {
|
||||
let path = self.dir.join(CONTENT).join(&hash[..2]).join(&hash);
|
||||
bytes += match fs::metadata(path) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => 0,
|
||||
Err(err) => return Err(disk("measuring an original", err)),
|
||||
};
|
||||
}
|
||||
let messages = u64::try_from(messages)
|
||||
.map_err(|_| Error::Store(format!("a negative count: {messages}")))?;
|
||||
Ok(Cached { messages, bytes })
|
||||
}
|
||||
|
||||
/// The account, its mailboxes, messages and unshared originals.
|
||||
pub fn remove_account(&mut self, account: AccountId) -> Result<()> {
|
||||
self.writable()?;
|
||||
let hashes = self.only_in(account)?;
|
||||
let tx = self.conn.transaction()?;
|
||||
let mut aside = Aside::new(&self.dir);
|
||||
let done = (|| -> Result<()> {
|
||||
let gone = tx.execute("DELETE FROM accounts WHERE id = ?1", [account.0])?;
|
||||
if gone != 1 {
|
||||
return Err(Error::Store(format!("no account {}", account.0)));
|
||||
}
|
||||
for hash in &hashes {
|
||||
aside.drop_unused(&tx, hash)?;
|
||||
}
|
||||
Ok(tx.commit()?)
|
||||
})();
|
||||
aside.settle(done)?;
|
||||
log::info!(
|
||||
"mail: account {} removed with {} originals",
|
||||
account.0,
|
||||
hashes.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn only_in(&self, account: AccountId) -> Result<Vec<String>> {
|
||||
let mut query = self.conn.prepare(
|
||||
"SELECT DISTINCT content FROM messages JOIN mailboxes ON messages.mailbox = mailboxes.id
|
||||
WHERE mailboxes.account = ?1 AND content IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM messages AS other
|
||||
JOIN mailboxes AS theirs ON other.mailbox = theirs.id
|
||||
WHERE other.content = messages.content AND theirs.account <> ?1)
|
||||
ORDER BY content",
|
||||
)?;
|
||||
let rows = query.query_map([account.0], |row| row.get(0))?;
|
||||
Ok(rows.collect::<rusqlite::Result<_>>()?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Originals set aside until their transaction commits.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{CONTENT, disk};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MARK: &str = ".gone";
|
||||
|
||||
fn original(dir: &Path, hash: &str) -> PathBuf {
|
||||
dir.join(CONTENT).join(&hash[..2]).join(hash)
|
||||
}
|
||||
|
||||
fn parked(dir: &Path, hash: &str) -> PathBuf {
|
||||
dir.join(CONTENT)
|
||||
.join(&hash[..2])
|
||||
.join(format!(".{hash}{MARK}"))
|
||||
}
|
||||
|
||||
/// Renamed, not deleted, so a rollback can put them back.
|
||||
pub(super) struct Aside {
|
||||
dir: PathBuf,
|
||||
moved: Vec<String>,
|
||||
}
|
||||
|
||||
impl Aside {
|
||||
pub fn new(dir: &Path) -> Self {
|
||||
Self {
|
||||
dir: dir.to_owned(),
|
||||
moved: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Once no row names the hash; runs before commit.
|
||||
pub fn drop_unused(&mut self, conn: &Connection, hash: &str) -> Result<()> {
|
||||
let used: bool = conn.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM messages WHERE content = ?1)",
|
||||
[hash],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if used {
|
||||
return Ok(());
|
||||
}
|
||||
match fs::rename(original(&self.dir, hash), parked(&self.dir, hash)) {
|
||||
Ok(()) => {
|
||||
self.moved.push(hash.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
log::warn!("mail: original {hash} was already gone");
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(disk("deleting an original", err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes on success; puts every file back on failure.
|
||||
pub fn settle<T>(self, done: Result<T>) -> Result<T> {
|
||||
match done {
|
||||
Ok(value) => {
|
||||
for hash in &self.moved {
|
||||
if let Err(err) = fs::remove_file(parked(&self.dir, hash)) {
|
||||
log::error!("mail: original {hash} stays set aside: {err}");
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(err) => {
|
||||
let mut stuck = Vec::new();
|
||||
for hash in self.moved.iter().rev() {
|
||||
if let Err(back) =
|
||||
fs::rename(parked(&self.dir, hash), original(&self.dir, hash))
|
||||
{
|
||||
stuck.push(format!("{hash}: {back}"));
|
||||
}
|
||||
}
|
||||
if stuck.is_empty() {
|
||||
Err(err)
|
||||
} else {
|
||||
Err(Error::Store(format!(
|
||||
"{err}; originals not put back (restored at next start): {}",
|
||||
stuck.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a crash: back if named, deleted if not.
|
||||
pub(super) fn recover(conn: &Connection, dir: &Path) -> Result<()> {
|
||||
let mut back = 0;
|
||||
let mut gone = 0;
|
||||
let shards =
|
||||
fs::read_dir(dir.join(CONTENT)).map_err(|err| disk("reading the originals", err))?;
|
||||
for shard in shards {
|
||||
let shard = shard.map_err(|err| disk("reading the originals", err))?;
|
||||
let files = fs::read_dir(shard.path()).map_err(|err| disk("reading the originals", err))?;
|
||||
for file in files {
|
||||
let name = file
|
||||
.map_err(|err| disk("reading the originals", err))?
|
||||
.file_name();
|
||||
let Some(hash) = name
|
||||
.to_str()
|
||||
.and_then(|name| name.strip_prefix('.'))
|
||||
.and_then(|name| name.strip_suffix(MARK))
|
||||
.filter(|hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let named: bool = conn.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM messages WHERE content = ?1)",
|
||||
[hash],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if named {
|
||||
fs::rename(parked(dir, hash), original(dir, hash))
|
||||
.map_err(|err| disk("restoring an original", err))?;
|
||||
back += 1;
|
||||
} else {
|
||||
fs::remove_file(parked(dir, hash))
|
||||
.map_err(|err| disk("deleting an original", err))?;
|
||||
gone += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if back + gone > 0 {
|
||||
log::warn!(
|
||||
"mail: {back} originals restored and {gone} deleted after an interrupted change"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! IMAP folders as mailboxes, and how far they are synced.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{Transaction, params};
|
||||
|
||||
use super::{AccountId, MailboxId, MessageId, Store, aside::Aside, mailbox_name};
|
||||
use crate::{Error, Result, Role};
|
||||
|
||||
/// A local IMAP message as sync compares it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Held {
|
||||
pub id: MessageId,
|
||||
/// The body is here, perhaps without attachments.
|
||||
pub body: bool,
|
||||
/// Every part is here.
|
||||
pub whole: bool,
|
||||
/// Sorted.
|
||||
pub flags: Vec<String>,
|
||||
}
|
||||
|
||||
/// A local mailbox.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LocalFolder {
|
||||
pub id: MailboxId,
|
||||
pub name: String,
|
||||
pub role: Option<Role>,
|
||||
/// Every message comes down whole.
|
||||
pub offline: bool,
|
||||
}
|
||||
|
||||
/// An account's sync, as the window shows it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Synced {
|
||||
/// Unix seconds of the last full sync.
|
||||
pub at: Option<i64>,
|
||||
pub messages: u64,
|
||||
/// Messages with only their header here.
|
||||
pub uncached: u64,
|
||||
/// Messages whose attachments wait for a request.
|
||||
pub partial: u64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Keeps the listed folders; the rest go with their mail.
|
||||
pub fn set_folders(
|
||||
&mut self,
|
||||
account: AccountId,
|
||||
folders: &[(&str, Option<Role>)],
|
||||
) -> Result<Vec<MailboxId>> {
|
||||
self.writable()?;
|
||||
let held: Vec<(i64, String)> = self
|
||||
.conn
|
||||
.prepare("SELECT id, name FROM mailboxes WHERE account = ?1")?
|
||||
.query_map([account.0], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
for (id, name) in held {
|
||||
if !folders
|
||||
.iter()
|
||||
.any(|(listed, _)| mailbox_name(listed) == name)
|
||||
{
|
||||
self.without_mail(MailboxId(id), None, |tx| {
|
||||
tx.execute("DELETE FROM mailboxes WHERE id = ?1", [id])
|
||||
})?;
|
||||
log::info!("mail: mailbox {id} is no longer listed; removed");
|
||||
}
|
||||
}
|
||||
folders
|
||||
.iter()
|
||||
.map(|(name, role)| {
|
||||
let id = self.mailbox(account, name)?;
|
||||
self.conn.execute(
|
||||
"UPDATE mailboxes SET role = ?1 WHERE id = ?2",
|
||||
params![role.map(Role::name), id.0],
|
||||
)?;
|
||||
Ok(id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The account's mailboxes in name order.
|
||||
pub fn folders(&self, account: AccountId) -> Result<Vec<LocalFolder>> {
|
||||
let mut query = self.conn.prepare(
|
||||
"SELECT id, name, role, offline FROM mailboxes WHERE account = ?1 ORDER BY name",
|
||||
)?;
|
||||
let rows = query.query_map([account.0], |row| {
|
||||
Ok((
|
||||
MailboxId(row.get(0)?),
|
||||
row.get(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get(3)?,
|
||||
))
|
||||
})?;
|
||||
rows.map(|row| {
|
||||
let (id, name, role, offline) = row?;
|
||||
let role = role
|
||||
.map(|role| {
|
||||
Role::from_name(&role)
|
||||
.ok_or_else(|| Error::Store(format!("an unknown folder role {role:?}")))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(LocalFolder {
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
offline,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the mailbox's mail comes down whole.
|
||||
pub fn offline(&self, mailbox: MailboxId) -> Result<bool> {
|
||||
Ok(self.conn.query_row(
|
||||
"SELECT offline FROM mailboxes WHERE id = ?1",
|
||||
[mailbox.0],
|
||||
|row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn set_offline(&self, mailbox: MailboxId, offline: bool) -> Result<()> {
|
||||
self.writable()?;
|
||||
let changed = self.conn.execute(
|
||||
"UPDATE mailboxes SET offline = ?1 WHERE id = ?2",
|
||||
params![offline, mailbox.0],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::Store(format!("no mailbox {}", mailbox.0)));
|
||||
}
|
||||
log::info!("mail: mailbox {} offline: {offline}", mailbox.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Held messages by UID; mail under another UIDVALIDITY goes.
|
||||
pub fn open_mailbox(
|
||||
&mut self,
|
||||
mailbox: MailboxId,
|
||||
uidvalidity: u32,
|
||||
) -> Result<HashMap<u32, Held>> {
|
||||
self.writable()?;
|
||||
let known: Option<u32> = self.conn.query_row(
|
||||
"SELECT uidvalidity FROM mailboxes WHERE id = ?1",
|
||||
[mailbox.0],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if known != Some(uidvalidity) {
|
||||
if known.is_some() {
|
||||
log::warn!("mail: mailbox {} has a new UIDVALIDITY; rebuilt", mailbox.0);
|
||||
}
|
||||
self.without_mail(mailbox, Some(uidvalidity), |tx| {
|
||||
tx.execute(
|
||||
"UPDATE mailboxes SET uidvalidity = ?1 WHERE id = ?2",
|
||||
params![uidvalidity, mailbox.0],
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut held: HashMap<u32, Held> = HashMap::new();
|
||||
let mut query = self.conn.prepare(
|
||||
"SELECT id, uid, content IS NOT NULL, content IS NOT NULL AND missing = '[]'
|
||||
FROM messages WHERE mailbox = ?1 AND uidvalidity = ?2",
|
||||
)?;
|
||||
let rows = query.query_map(params![mailbox.0, uidvalidity], |row| {
|
||||
let id = MessageId(row.get(0)?);
|
||||
Ok((row.get::<_, u32>(1)?, id, row.get(2)?, row.get(3)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (uid, id, body, whole) = row?;
|
||||
held.insert(
|
||||
uid,
|
||||
Held {
|
||||
id,
|
||||
body,
|
||||
whole,
|
||||
flags: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut query = self.conn.prepare(
|
||||
"SELECT messages.uid, flags.flag FROM flags JOIN messages ON flags.message = messages.id
|
||||
WHERE messages.mailbox = ?1 AND messages.uidvalidity = ?2 ORDER BY flags.flag",
|
||||
)?;
|
||||
let rows = query.query_map(params![mailbox.0, uidvalidity], |row| {
|
||||
Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (uid, flag) = row?;
|
||||
held.get_mut(&uid)
|
||||
.ok_or_else(|| Error::Store(format!("a flag for no message {uid}")))?
|
||||
.flags
|
||||
.push(flag);
|
||||
}
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
/// The UIDLs a POP3 mailbox holds.
|
||||
pub fn uidls(&self, mailbox: MailboxId) -> Result<HashSet<String>> {
|
||||
let mut query = self
|
||||
.conn
|
||||
.prepare("SELECT uidl FROM messages WHERE mailbox = ?1 AND uidl IS NOT NULL")?;
|
||||
let rows = query.query_map([mailbox.0], |row| row.get(0))?;
|
||||
Ok(rows.collect::<rusqlite::Result<_>>()?)
|
||||
}
|
||||
|
||||
pub fn set_synced(&self, account: AccountId, at: i64) -> Result<()> {
|
||||
self.writable()?;
|
||||
let changed = self.conn.execute(
|
||||
"UPDATE accounts SET synced = ?1 WHERE id = ?2",
|
||||
params![at, account.0],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(Error::Store(format!("no account {}", account.0)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn synced(&self, account: AccountId) -> Result<Synced> {
|
||||
let at = self.conn.query_row(
|
||||
"SELECT synced FROM accounts WHERE id = ?1",
|
||||
[account.0],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let (messages, cached, partial): (i64, i64, i64) = self.conn.query_row(
|
||||
"SELECT count(*), count(content), count(CASE WHEN missing <> '[]' THEN 1 END)
|
||||
FROM messages JOIN mailboxes ON messages.mailbox = mailboxes.id
|
||||
WHERE mailboxes.account = ?1",
|
||||
[account.0],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
let count =
|
||||
|n: i64| u64::try_from(n).map_err(|_| Error::Store(format!("a negative count: {n}")));
|
||||
Ok(Synced {
|
||||
at,
|
||||
messages: count(messages)?,
|
||||
uncached: count(messages - cached)?,
|
||||
partial: count(partial)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Deletes the mailbox's messages not under `keep`, with `change`.
|
||||
fn without_mail(
|
||||
&mut self,
|
||||
mailbox: MailboxId,
|
||||
keep: Option<u32>,
|
||||
change: impl FnOnce(&Transaction) -> rusqlite::Result<usize>,
|
||||
) -> Result<()> {
|
||||
let hashes: Vec<String> = self
|
||||
.conn
|
||||
.prepare(&format!(
|
||||
"SELECT DISTINCT content FROM messages WHERE {STALE} AND content IS NOT NULL"
|
||||
))?
|
||||
.query_map(params![mailbox.0, keep], |row| row.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
let tx = self.conn.transaction()?;
|
||||
let mut aside = Aside::new(&self.dir);
|
||||
let done = (|| -> Result<()> {
|
||||
tx.execute(
|
||||
&format!("DELETE FROM messages WHERE {STALE}"),
|
||||
params![mailbox.0, keep],
|
||||
)?;
|
||||
change(&tx)?;
|
||||
for hash in &hashes {
|
||||
aside.drop_unused(&tx, hash)?;
|
||||
}
|
||||
Ok(tx.commit()?)
|
||||
})();
|
||||
aside.settle(done)
|
||||
}
|
||||
}
|
||||
|
||||
const STALE: &str = "mailbox = ?1 AND (?2 IS NULL OR uidvalidity IS NOT ?2)";
|
||||
@@ -0,0 +1,289 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use html5ever::{
|
||||
Attribute, LocalName, Namespace, ParseOpts, QualName, local_name, parse_document,
|
||||
tendril::{StrTendril, TendrilSink},
|
||||
tree_builder::{ElemName, ElementFlags, NodeOrText, QuirksMode, TreeBuilderOpts, TreeSink},
|
||||
};
|
||||
|
||||
/// Deeper trees stall html5ever.
|
||||
const DEPTH: usize = 512;
|
||||
const CHUNK: usize = 4096;
|
||||
|
||||
/// Visible HTML text, and whether it stopped short.
|
||||
pub(super) fn text(html: &str) -> (String, bool) {
|
||||
let opts = ParseOpts {
|
||||
tree_builder: TreeBuilderOpts {
|
||||
// Mail never runs scripts.
|
||||
scripting_enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut parser = parse_document(Tree::default(), opts);
|
||||
let mut rest = html;
|
||||
let mut short = false;
|
||||
while !rest.is_empty() {
|
||||
if parser.tokenizer.sink.sink.deep.get() {
|
||||
short = true;
|
||||
break;
|
||||
}
|
||||
let mut cut = rest.len().min(CHUNK);
|
||||
while !rest.is_char_boundary(cut) {
|
||||
cut += 1;
|
||||
}
|
||||
let (head, tail) = rest.split_at(cut);
|
||||
parser.process(StrTendril::from_slice(head));
|
||||
rest = tail;
|
||||
}
|
||||
(parser.finish().text(), short)
|
||||
}
|
||||
|
||||
enum Data {
|
||||
Document,
|
||||
Element { name: QualName, point: bool },
|
||||
Text(String),
|
||||
Other,
|
||||
}
|
||||
|
||||
struct Node {
|
||||
parent: Option<usize>,
|
||||
children: Vec<usize>,
|
||||
data: Data,
|
||||
}
|
||||
|
||||
/// The tree html5ever's builder makes.
|
||||
struct Tree {
|
||||
nodes: RefCell<Vec<Node>>,
|
||||
/// An element went past `DEPTH`.
|
||||
deep: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Default for Tree {
|
||||
fn default() -> Self {
|
||||
let document = Node {
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
data: Data::Document,
|
||||
};
|
||||
Tree {
|
||||
nodes: RefCell::new(vec![document]),
|
||||
deep: Cell::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Name(QualName);
|
||||
|
||||
impl ElemName for Name {
|
||||
fn ns(&self) -> &Namespace {
|
||||
&self.0.ns
|
||||
}
|
||||
|
||||
fn local_name(&self) -> &LocalName {
|
||||
&self.0.local
|
||||
}
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
fn add(&self, data: Data) -> usize {
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
nodes.push(Node {
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
data,
|
||||
});
|
||||
nodes.len() - 1
|
||||
}
|
||||
|
||||
fn detach(&self, node: usize) {
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
if let Some(parent) = nodes[node].parent.take() {
|
||||
nodes[parent].children.retain(|&child| child != node);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a child at index `at`.
|
||||
fn insert(&self, parent: usize, at: usize, child: NodeOrText<usize>) {
|
||||
let node = match child {
|
||||
NodeOrText::AppendText(text) => {
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
let before = at.checked_sub(1).map(|i| nodes[parent].children[i]);
|
||||
if let Some(Data::Text(held)) = before.map(|i| &mut nodes[i].data) {
|
||||
held.push_str(&text);
|
||||
return;
|
||||
}
|
||||
drop(nodes);
|
||||
self.add(Data::Text(text.to_string()))
|
||||
}
|
||||
NodeOrText::AppendNode(node) => {
|
||||
self.detach(node);
|
||||
node
|
||||
}
|
||||
};
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
let at = at.min(nodes[parent].children.len());
|
||||
nodes[parent].children.insert(at, node);
|
||||
nodes[node].parent = Some(parent);
|
||||
if matches!(nodes[node].data, Data::Element { .. }) {
|
||||
let mut up = Some(parent);
|
||||
let mut depth = 0;
|
||||
while let Some(next) = up.filter(|_| depth <= DEPTH) {
|
||||
depth += 1;
|
||||
up = nodes[next].parent;
|
||||
}
|
||||
if depth > DEPTH {
|
||||
self.deep.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible text, read without recursion.
|
||||
fn text(&self) -> String {
|
||||
let nodes = self.nodes.borrow();
|
||||
let mut out = String::new();
|
||||
let mut stack = vec![0];
|
||||
while let Some(at) = stack.pop() {
|
||||
match &nodes[at].data {
|
||||
Data::Text(text) => out.push_str(text),
|
||||
Data::Element { name, .. } if hidden(&name.local) => out.push(' '),
|
||||
Data::Element { .. } | Data::Document => {
|
||||
// Tags separate words.
|
||||
out.push(' ');
|
||||
stack.extend(nodes[at].children.iter().rev());
|
||||
}
|
||||
Data::Other => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Not drawn, per the HTML rendering rules.
|
||||
fn hidden(name: &LocalName) -> bool {
|
||||
matches!(
|
||||
*name,
|
||||
local_name!("area")
|
||||
| local_name!("base")
|
||||
| local_name!("basefont")
|
||||
| local_name!("datalist")
|
||||
| local_name!("head")
|
||||
| local_name!("iframe")
|
||||
| local_name!("link")
|
||||
| local_name!("meta")
|
||||
| local_name!("noembed")
|
||||
| local_name!("noframes")
|
||||
| local_name!("param")
|
||||
| local_name!("rp")
|
||||
| local_name!("script")
|
||||
| local_name!("style")
|
||||
| local_name!("template")
|
||||
| local_name!("title")
|
||||
)
|
||||
}
|
||||
|
||||
impl TreeSink for Tree {
|
||||
type Handle = usize;
|
||||
type Output = Self;
|
||||
type ElemName<'a> = Name;
|
||||
|
||||
fn finish(self) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
fn parse_error(&self, _msg: Cow<'static, str>) {}
|
||||
|
||||
fn get_document(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn elem_name(&self, target: &usize) -> Name {
|
||||
match &self.nodes.borrow()[*target].data {
|
||||
Data::Element { name, .. } => Name(name.clone()),
|
||||
_ => unreachable!("html5ever asked a non-element for its name"),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_element(&self, name: QualName, _: Vec<Attribute>, flags: ElementFlags) -> usize {
|
||||
let point = flags.mathml_annotation_xml_integration_point;
|
||||
let node = self.add(Data::Element { name, point });
|
||||
if flags.template {
|
||||
// Template contents stay out of the tree.
|
||||
self.add(Data::Other);
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
fn create_comment(&self, _text: StrTendril) -> usize {
|
||||
self.add(Data::Other)
|
||||
}
|
||||
|
||||
fn create_pi(&self, _target: StrTendril, _data: StrTendril) -> usize {
|
||||
self.add(Data::Other)
|
||||
}
|
||||
|
||||
fn append(&self, parent: &usize, child: NodeOrText<usize>) {
|
||||
let end = self.nodes.borrow()[*parent].children.len();
|
||||
self.insert(*parent, end, child);
|
||||
}
|
||||
|
||||
fn append_based_on_parent_node(
|
||||
&self,
|
||||
element: &usize,
|
||||
prev_element: &usize,
|
||||
child: NodeOrText<usize>,
|
||||
) {
|
||||
if self.nodes.borrow()[*element].parent.is_some() {
|
||||
self.append_before_sibling(element, child);
|
||||
} else {
|
||||
self.append(prev_element, child);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_doctype_to_document(&self, _: StrTendril, _: StrTendril, _: StrTendril) {}
|
||||
|
||||
fn get_template_contents(&self, target: &usize) -> usize {
|
||||
target + 1
|
||||
}
|
||||
|
||||
fn same_node(&self, x: &usize, y: &usize) -> bool {
|
||||
x == y
|
||||
}
|
||||
|
||||
fn set_quirks_mode(&self, _mode: QuirksMode) {}
|
||||
|
||||
fn append_before_sibling(&self, sibling: &usize, child: NodeOrText<usize>) {
|
||||
let (parent, at) = {
|
||||
let nodes = self.nodes.borrow();
|
||||
let parent = nodes[*sibling]
|
||||
.parent
|
||||
.expect("html5ever names a sibling with a parent");
|
||||
let at = nodes[parent].children.iter().position(|c| c == sibling);
|
||||
(parent, at.expect("a sibling among its parent's children"))
|
||||
};
|
||||
self.insert(parent, at, child);
|
||||
}
|
||||
|
||||
fn add_attrs_if_missing(&self, _target: &usize, _attrs: Vec<Attribute>) {}
|
||||
|
||||
fn remove_from_parent(&self, target: &usize) {
|
||||
self.detach(*target);
|
||||
}
|
||||
|
||||
fn reparent_children(&self, node: &usize, new_parent: &usize) {
|
||||
let children = std::mem::take(&mut self.nodes.borrow_mut()[*node].children);
|
||||
for child in children {
|
||||
self.nodes.borrow_mut()[child].parent = None;
|
||||
self.append(new_parent, NodeOrText::AppendNode(child));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mathml_annotation_xml_integration_point(&self, handle: &usize) -> bool {
|
||||
matches!(
|
||||
self.nodes.borrow()[*handle].data,
|
||||
Data::Element { point: true, .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Message lists: one mailbox or every inbox, newest first.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::ToSql;
|
||||
|
||||
use super::{MailboxId, MessageId, Store};
|
||||
use crate::{Mailbox, Result};
|
||||
|
||||
/// Which messages a list holds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
Mailbox(MailboxId),
|
||||
/// Every account's inbox together.
|
||||
Inboxes,
|
||||
}
|
||||
|
||||
/// Each set field narrows the list.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Filter {
|
||||
pub unread: bool,
|
||||
pub flagged: bool,
|
||||
pub attachments: bool,
|
||||
}
|
||||
|
||||
/// A message as a list shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Summary {
|
||||
pub id: MessageId,
|
||||
pub from: Vec<Mailbox>,
|
||||
pub subject: Option<String>,
|
||||
pub date: Option<i64>,
|
||||
pub seen: bool,
|
||||
pub flagged: bool,
|
||||
/// None until the body is here.
|
||||
pub attachments: Option<u32>,
|
||||
}
|
||||
|
||||
// RFC 3501 §2.3.2: system flags match in any case.
|
||||
const SEEN: &str = "EXISTS (SELECT 1 FROM flags f WHERE f.message = m.id \
|
||||
AND f.flag = '\\Seen' COLLATE NOCASE)";
|
||||
const FLAGGED: &str = "EXISTS (SELECT 1 FROM flags f WHERE f.message = m.id \
|
||||
AND f.flag = '\\Flagged' COLLATE NOCASE)";
|
||||
|
||||
impl Store {
|
||||
/// Newest first; SQLite sorts undated last.
|
||||
pub fn list(&self, scope: Scope, filter: Filter) -> Result<Vec<Summary>> {
|
||||
let mut clauses = vec![match scope {
|
||||
Scope::Mailbox(_) => "m.mailbox = ?1".to_owned(),
|
||||
Scope::Inboxes => "b.role = 'inbox'".to_owned(),
|
||||
}];
|
||||
if filter.unread {
|
||||
clauses.push(format!("NOT {SEEN}"));
|
||||
}
|
||||
if filter.flagged {
|
||||
clauses.push(FLAGGED.to_owned());
|
||||
}
|
||||
if filter.attachments {
|
||||
clauses.push("m.attachments > 0".to_owned());
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.sender, m.subject, m.date, m.attachments, {SEEN}, {FLAGGED}
|
||||
FROM messages m JOIN mailboxes b ON b.id = m.mailbox
|
||||
WHERE {}
|
||||
ORDER BY m.date DESC, m.id DESC",
|
||||
clauses.join(" AND ")
|
||||
);
|
||||
let id;
|
||||
let values: Vec<&dyn ToSql> = match scope {
|
||||
Scope::Mailbox(mailbox) => {
|
||||
id = mailbox.0;
|
||||
vec![&id]
|
||||
}
|
||||
Scope::Inboxes => Vec::new(),
|
||||
};
|
||||
let mut query = self.conn.prepare(&sql)?;
|
||||
let rows = query.query_map(values.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
row.get(6)?,
|
||||
))
|
||||
})?;
|
||||
rows.map(|row| {
|
||||
let (id, from, subject, date, attachments, seen, flagged) = row?;
|
||||
Ok(Summary {
|
||||
id: MessageId(id),
|
||||
from: serde_json::from_str(&from)?,
|
||||
subject,
|
||||
date,
|
||||
seen,
|
||||
flagged,
|
||||
attachments,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Unread messages per mailbox; mailboxes with none are left out.
|
||||
pub fn unread(&self) -> Result<HashMap<MailboxId, u32>> {
|
||||
let mut query = self.conn.prepare(&format!(
|
||||
"SELECT m.mailbox, COUNT(*) FROM messages m WHERE NOT {SEEN} GROUP BY m.mailbox"
|
||||
))?;
|
||||
let rows = query.query_map([], |row| Ok((MailboxId(row.get(0)?), row.get(1)?)))?;
|
||||
Ok(rows.collect::<rusqlite::Result<_>>()?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::{fs, io::ErrorKind, path::Path};
|
||||
|
||||
use rusqlite::{OptionalExtension, Row, ToSql, Transaction, params};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{CONTENT, MailboxId, MessageId, Store, aside::Aside, disk, search};
|
||||
use crate::{Error, Mail, Mailbox, Result, read};
|
||||
|
||||
/// RFC 3501 §2.3.1.1: identity is mailbox, UIDVALIDITY and UID.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
Imap {
|
||||
uidvalidity: u32,
|
||||
uid: u32,
|
||||
},
|
||||
/// POP3 has one mailbox and UIDL names.
|
||||
Pop {
|
||||
uidl: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a sync brought down.
|
||||
pub enum Fetched<'a> {
|
||||
Headers(&'a [u8]),
|
||||
/// Left-out parts are empty and named by part number.
|
||||
Text {
|
||||
bytes: &'a [u8],
|
||||
missing: &'a [String],
|
||||
},
|
||||
Whole(&'a [u8]),
|
||||
}
|
||||
|
||||
/// A message as indexed; the original is read apart.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Stored {
|
||||
pub id: MessageId,
|
||||
pub mailbox: MailboxId,
|
||||
pub key: Key,
|
||||
pub flags: Vec<String>,
|
||||
pub message_id: Option<String>,
|
||||
pub in_reply_to: Vec<String>,
|
||||
pub references: Vec<String>,
|
||||
pub subject: Option<String>,
|
||||
pub from: Vec<Mailbox>,
|
||||
pub to: Vec<Mailbox>,
|
||||
pub cc: Vec<Mailbox>,
|
||||
pub date: Option<i64>,
|
||||
/// None until the body is here.
|
||||
pub attachments: Option<u32>,
|
||||
pub body: bool,
|
||||
/// Part numbers not downloaded; their content is empty.
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
const COLUMNS: &str = "id, mailbox, uidvalidity, uid, uidl, message_id, in_reply_to, refs, \
|
||||
subject, sender, recipients, copies, date, attachments, content, missing";
|
||||
|
||||
impl Store {
|
||||
pub fn find(&self, mailbox: MailboxId, key: &Key) -> Result<Option<MessageId>> {
|
||||
let found = match key {
|
||||
Key::Imap { uidvalidity, uid } => self.conn.query_row(
|
||||
"SELECT id FROM messages WHERE mailbox = ?1 AND uidvalidity = ?2 AND uid = ?3",
|
||||
params![mailbox.0, uidvalidity, uid],
|
||||
|row| row.get(0),
|
||||
),
|
||||
Key::Pop { uidl } => self.conn.query_row(
|
||||
"SELECT id FROM messages WHERE mailbox = ?1 AND uidl = ?2",
|
||||
params![mailbox.0, uidl],
|
||||
|row| row.get(0),
|
||||
),
|
||||
};
|
||||
Ok(found.optional()?.map(MessageId))
|
||||
}
|
||||
|
||||
/// One key, one message; headers again only set flags.
|
||||
pub fn put(
|
||||
&mut self,
|
||||
mailbox: MailboxId,
|
||||
key: &Key,
|
||||
flags: &[&str],
|
||||
fetched: Fetched,
|
||||
) -> Result<MessageId> {
|
||||
self.writable()?;
|
||||
let existing = self.find(mailbox, key)?;
|
||||
let (bytes, missing): (&[u8], &[String]) = match (fetched, existing) {
|
||||
(Fetched::Headers(_), Some(id)) => {
|
||||
self.set_flags(id, flags)?;
|
||||
return Ok(id);
|
||||
}
|
||||
(Fetched::Headers(bytes), None) => {
|
||||
let mail = read(bytes)?;
|
||||
return self.write(mailbox, key, flags, &mail, None, None);
|
||||
}
|
||||
(Fetched::Text { bytes, missing }, _) => (bytes, missing),
|
||||
(Fetched::Whole(bytes), _) => (bytes, &[][..]),
|
||||
};
|
||||
let mail = read(bytes)?;
|
||||
let (content, made) = self.keep(bytes)?;
|
||||
let body = Some((content.as_str(), missing));
|
||||
let written = self.write(mailbox, key, flags, &mail, body, existing);
|
||||
if written.is_err() && made {
|
||||
let mut aside = Aside::new(&self.dir);
|
||||
let gone = aside.drop_unused(&self.conn, &content);
|
||||
if let Err(gone) = aside.settle(gone) {
|
||||
log::error!("mail: an unused original stays after a failed write: {gone}");
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
fn write(
|
||||
&mut self,
|
||||
mailbox: MailboxId,
|
||||
key: &Key,
|
||||
flags: &[&str],
|
||||
mail: &Mail,
|
||||
body: Option<(&str, &[String])>,
|
||||
existing: Option<MessageId>,
|
||||
) -> Result<MessageId> {
|
||||
let content = body.map(|(content, _)| content);
|
||||
let missing = body.map_or(&[][..], |(_, missing)| missing);
|
||||
let (uidvalidity, uid, uidl) = match key {
|
||||
Key::Imap { uidvalidity, uid } => (Some(*uidvalidity), Some(*uid), None),
|
||||
Key::Pop { uidl } => (None, None, Some(uidl.as_str())),
|
||||
};
|
||||
let attachments = content.map(|_| mail.attachments.len() as u32);
|
||||
let json = [
|
||||
serde_json::to_string(&mail.in_reply_to)?,
|
||||
serde_json::to_string(&mail.references)?,
|
||||
serde_json::to_string(&mail.from)?,
|
||||
serde_json::to_string(&mail.to)?,
|
||||
serde_json::to_string(&mail.cc)?,
|
||||
serde_json::to_string(missing)?,
|
||||
];
|
||||
let mut values: Vec<&dyn ToSql> = vec![
|
||||
&mail.message_id,
|
||||
&json[0],
|
||||
&json[1],
|
||||
&mail.subject,
|
||||
&json[2],
|
||||
&json[3],
|
||||
&json[4],
|
||||
&mail.date,
|
||||
&attachments,
|
||||
&content,
|
||||
&json[5],
|
||||
];
|
||||
let tx = self.conn.transaction()?;
|
||||
let mut aside = Aside::new(&self.dir);
|
||||
let done = (|| -> Result<MessageId> {
|
||||
let mut old: Option<String> = None;
|
||||
let id = match &existing {
|
||||
Some(id) => {
|
||||
old = tx.query_row(
|
||||
"SELECT content FROM messages WHERE id = ?1",
|
||||
[id.0],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
values.push(&id.0);
|
||||
tx.execute(
|
||||
"UPDATE messages SET message_id = ?1, in_reply_to = ?2, refs = ?3,
|
||||
subject = ?4, sender = ?5, recipients = ?6, copies = ?7, date = ?8,
|
||||
attachments = ?9, content = ?10, missing = ?11 WHERE id = ?12",
|
||||
values.as_slice(),
|
||||
)?;
|
||||
*id
|
||||
}
|
||||
None => {
|
||||
values.extend::<[&dyn ToSql; 4]>([&mailbox.0, &uidvalidity, &uid, &uidl]);
|
||||
tx.execute(
|
||||
"INSERT INTO messages (message_id, in_reply_to, refs, subject, sender,
|
||||
recipients, copies, date, attachments, content, missing, mailbox,
|
||||
uidvalidity, uid, uidl) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
|
||||
?11, ?12, ?13, ?14, ?15)",
|
||||
values.as_slice(),
|
||||
)?;
|
||||
MessageId(tx.last_insert_rowid())
|
||||
}
|
||||
};
|
||||
replace_flags(&tx, id, flags)?;
|
||||
search::index(&tx, id, mail, content.is_some())?;
|
||||
if let Some(old) = old.filter(|old| Some(old.as_str()) != content) {
|
||||
aside.drop_unused(&tx, &old)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
})();
|
||||
aside.settle(done)
|
||||
}
|
||||
|
||||
pub fn set_flags(&mut self, id: MessageId, flags: &[&str]) -> Result<()> {
|
||||
self.writable()?;
|
||||
self.content(id)?;
|
||||
let tx = self.conn.transaction()?;
|
||||
replace_flags(&tx, id, flags)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(&self, id: MessageId) -> Result<Stored> {
|
||||
let mut stored = self.conn.query_row(
|
||||
&format!("SELECT {COLUMNS} FROM messages WHERE id = ?1"),
|
||||
[id.0],
|
||||
|found| Ok(row(found)),
|
||||
)??;
|
||||
let mut query = self
|
||||
.conn
|
||||
.prepare("SELECT flag FROM flags WHERE message = ?1 ORDER BY flag")?;
|
||||
stored.flags = query
|
||||
.query_map([id.0], |row| row.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
/// The original, checked against its hash; None for headers only.
|
||||
pub fn original(&self, id: MessageId) -> Result<Option<Vec<u8>>> {
|
||||
self.content(id)?
|
||||
.map(|hash| load_at(&self.dir, &hash))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: MessageId) -> Result<()> {
|
||||
self.writable()?;
|
||||
let content = self.content(id)?;
|
||||
let tx = self.conn.transaction()?;
|
||||
let mut aside = Aside::new(&self.dir);
|
||||
let done = (|| -> Result<()> {
|
||||
tx.execute("DELETE FROM messages WHERE id = ?1", [id.0])?;
|
||||
if let Some(hash) = content {
|
||||
aside.drop_unused(&tx, &hash)?;
|
||||
}
|
||||
Ok(tx.commit()?)
|
||||
})();
|
||||
aside.settle(done)
|
||||
}
|
||||
|
||||
fn content(&self, id: MessageId) -> Result<Option<String>> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT content FROM messages WHERE id = ?1",
|
||||
[id.0],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| Error::Store(format!("no message {}", id.0)))
|
||||
}
|
||||
|
||||
/// The hash, and whether the file is new.
|
||||
fn keep(&self, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
let hash = hex(&Sha256::digest(bytes));
|
||||
let path = self.dir.join(CONTENT).join(&hash[..2]).join(&hash);
|
||||
let made = match fs::read(&path) {
|
||||
Ok(held) if hex(&Sha256::digest(&held)) == hash => return Ok((hash, false)),
|
||||
Ok(_) => {
|
||||
log::warn!("mail: original {hash} was damaged; rewritten from the server copy");
|
||||
false
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => true,
|
||||
Err(err) => return Err(disk("checking an original", err)),
|
||||
};
|
||||
let shard = path.parent().expect("a shard folder");
|
||||
fs::create_dir_all(shard).map_err(|err| disk("storing an original", err))?;
|
||||
let staged = shard.join(format!(".{hash}.part"));
|
||||
if let Err(err) = fs::write(&staged, bytes).and_then(|()| fs::rename(&staged, &path)) {
|
||||
return Err(match fs::remove_file(&staged) {
|
||||
Ok(()) => disk("storing an original", err),
|
||||
Err(gone) if gone.kind() == ErrorKind::NotFound => disk("storing an original", err),
|
||||
Err(left) => Error::Store(format!(
|
||||
"storing an original: {err}; the staged copy stayed: {left}"
|
||||
)),
|
||||
});
|
||||
}
|
||||
Ok((hash, made))
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_flags(tx: &Transaction, id: MessageId, flags: &[&str]) -> Result<()> {
|
||||
tx.execute("DELETE FROM flags WHERE message = ?1", [id.0])?;
|
||||
for flag in flags {
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO flags (message, flag) VALUES (?1, ?2)",
|
||||
params![id.0, flag],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn row(row: &Row) -> Result<Stored> {
|
||||
let key = match row.get::<_, Option<String>>(4)? {
|
||||
Some(uidl) => Key::Pop { uidl },
|
||||
None => Key::Imap {
|
||||
uidvalidity: row.get(2)?,
|
||||
uid: row.get(3)?,
|
||||
},
|
||||
};
|
||||
let json = |at: usize| -> Result<String> { Ok(row.get(at)?) };
|
||||
Ok(Stored {
|
||||
id: MessageId(row.get(0)?),
|
||||
mailbox: MailboxId(row.get(1)?),
|
||||
key,
|
||||
flags: Vec::new(),
|
||||
message_id: row.get(5)?,
|
||||
in_reply_to: serde_json::from_str(&json(6)?)?,
|
||||
references: serde_json::from_str(&json(7)?)?,
|
||||
subject: row.get(8)?,
|
||||
from: serde_json::from_str(&json(9)?)?,
|
||||
to: serde_json::from_str(&json(10)?)?,
|
||||
cc: serde_json::from_str(&json(11)?)?,
|
||||
date: row.get(12)?,
|
||||
attachments: row.get(13)?,
|
||||
body: row.get::<_, Option<String>>(14)?.is_some(),
|
||||
missing: serde_json::from_str(&json(15)?)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn load_at(dir: &Path, hash: &str) -> Result<Vec<u8>> {
|
||||
let path = dir.join(CONTENT).join(&hash[..2]).join(hash);
|
||||
let bytes = fs::read(&path).map_err(|err| disk("reading an original", err))?;
|
||||
if hex(&Sha256::digest(&bytes)) != hash {
|
||||
return Err(Error::Store(format!("original {hash} is damaged")));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/// Step `n` upgrades version `n`.
|
||||
pub(super) const STEPS: &[&str] = &[V1, V2, V3, V4];
|
||||
|
||||
const V1: &str = "
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
address TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
settings TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE mailboxes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (account, name)
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
mailbox INTEGER NOT NULL REFERENCES mailboxes ON DELETE CASCADE,
|
||||
uidvalidity INTEGER,
|
||||
uid INTEGER,
|
||||
uidl TEXT,
|
||||
message_id TEXT,
|
||||
in_reply_to TEXT NOT NULL,
|
||||
refs TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
sender TEXT NOT NULL,
|
||||
recipients TEXT NOT NULL,
|
||||
copies TEXT NOT NULL,
|
||||
date INTEGER,
|
||||
attachments INTEGER,
|
||||
content TEXT,
|
||||
CHECK ((uid IS NULL) = (uidvalidity IS NULL) AND (uid IS NULL) <> (uidl IS NULL))
|
||||
);
|
||||
CREATE UNIQUE INDEX messages_imap ON messages (mailbox, uidvalidity, uid)
|
||||
WHERE uid IS NOT NULL;
|
||||
CREATE UNIQUE INDEX messages_pop ON messages (mailbox, uidl) WHERE uidl IS NOT NULL;
|
||||
CREATE INDEX messages_thread ON messages (message_id);
|
||||
CREATE INDEX messages_content ON messages (content);
|
||||
CREATE TABLE flags (
|
||||
message INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE,
|
||||
flag TEXT NOT NULL,
|
||||
PRIMARY KEY (message, flag)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX flags_by_flag ON flags (flag, message);
|
||||
CREATE VIRTUAL TABLE search USING fts5(
|
||||
subject, people, body, files,
|
||||
content = '', contentless_delete = 1, tokenize = 'trigram'
|
||||
);
|
||||
CREATE TRIGGER messages_gone AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM search WHERE rowid = old.id;
|
||||
END;
|
||||
";
|
||||
|
||||
/// IMAP sync (C12.6).
|
||||
const V2: &str = "
|
||||
ALTER TABLE accounts ADD COLUMN synced INTEGER;
|
||||
ALTER TABLE mailboxes ADD COLUMN role TEXT;
|
||||
ALTER TABLE mailboxes ADD COLUMN uidvalidity INTEGER;
|
||||
ALTER TABLE messages ADD COLUMN missing TEXT NOT NULL DEFAULT '[]';
|
||||
";
|
||||
|
||||
/// Folders kept whole (C12.8).
|
||||
const V3: &str = "
|
||||
ALTER TABLE mailboxes ADD COLUMN offline INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
|
||||
/// Ids never reused (C12.9); foreign keys are off here.
|
||||
const V4: &str = "
|
||||
CREATE TABLE accounts_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
address TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
settings TEXT NOT NULL,
|
||||
synced INTEGER
|
||||
);
|
||||
CREATE TABLE mailboxes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT,
|
||||
uidvalidity INTEGER,
|
||||
offline INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (account, name)
|
||||
);
|
||||
CREATE TABLE messages_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox INTEGER NOT NULL REFERENCES mailboxes ON DELETE CASCADE,
|
||||
uidvalidity INTEGER,
|
||||
uid INTEGER,
|
||||
uidl TEXT,
|
||||
message_id TEXT,
|
||||
in_reply_to TEXT NOT NULL,
|
||||
refs TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
sender TEXT NOT NULL,
|
||||
recipients TEXT NOT NULL,
|
||||
copies TEXT NOT NULL,
|
||||
date INTEGER,
|
||||
attachments INTEGER,
|
||||
content TEXT,
|
||||
missing TEXT NOT NULL DEFAULT '[]',
|
||||
CHECK ((uid IS NULL) = (uidvalidity IS NULL) AND (uid IS NULL) <> (uidl IS NULL))
|
||||
);
|
||||
INSERT INTO accounts_new (id, address, settings, synced)
|
||||
SELECT id, address, settings, synced FROM accounts;
|
||||
INSERT INTO mailboxes_new (id, account, name, role, uidvalidity, offline)
|
||||
SELECT id, account, name, role, uidvalidity, offline FROM mailboxes;
|
||||
INSERT INTO messages_new (id, mailbox, uidvalidity, uid, uidl, message_id, in_reply_to,
|
||||
refs, subject, sender, recipients, copies, date, attachments, content, missing)
|
||||
SELECT id, mailbox, uidvalidity, uid, uidl, message_id, in_reply_to,
|
||||
refs, subject, sender, recipients, copies, date, attachments, content, missing
|
||||
FROM messages;
|
||||
DROP TABLE messages;
|
||||
DROP TABLE mailboxes;
|
||||
DROP TABLE accounts;
|
||||
ALTER TABLE accounts_new RENAME TO accounts;
|
||||
ALTER TABLE mailboxes_new RENAME TO mailboxes;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
CREATE UNIQUE INDEX messages_imap ON messages (mailbox, uidvalidity, uid)
|
||||
WHERE uid IS NOT NULL;
|
||||
CREATE UNIQUE INDEX messages_pop ON messages (mailbox, uidl) WHERE uidl IS NOT NULL;
|
||||
CREATE INDEX messages_thread ON messages (message_id);
|
||||
CREATE INDEX messages_content ON messages (content);
|
||||
CREATE TRIGGER messages_gone AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM search WHERE rowid = old.id;
|
||||
END;
|
||||
";
|
||||
@@ -0,0 +1,134 @@
|
||||
use rusqlite::{Transaction, params};
|
||||
|
||||
use super::{MessageId, Store, html, messages::load_at};
|
||||
use crate::{Mail, Mailbox, Result, read};
|
||||
|
||||
/// Replaces the message's row in the full-text index.
|
||||
pub(super) fn index(tx: &Transaction, id: MessageId, mail: &Mail, whole: bool) -> Result<()> {
|
||||
let people = people([&mail.from, &mail.to, &mail.cc]);
|
||||
let (body, files) = if whole {
|
||||
contents(id, mail)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
put(tx, id, mail.subject.as_deref(), &people, &body, &files)
|
||||
}
|
||||
|
||||
fn put(
|
||||
tx: &Transaction,
|
||||
id: MessageId,
|
||||
subject: Option<&str>,
|
||||
people: &str,
|
||||
body: &str,
|
||||
files: &str,
|
||||
) -> Result<()> {
|
||||
tx.execute("DELETE FROM search WHERE rowid = ?1", [id.0])?;
|
||||
tx.execute(
|
||||
"INSERT INTO search (rowid, subject, people, body, files) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id.0, subject, people, body, files],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Rebuilt from the index rows and the originals.
|
||||
pub fn rebuild_search(&mut self) -> Result<usize> {
|
||||
self.writable()?;
|
||||
let ids: Vec<i64> = {
|
||||
let mut query = self.conn.prepare("SELECT id FROM messages")?;
|
||||
query
|
||||
.query_map([], |row| row.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let tx = self.conn.transaction()?;
|
||||
// A damaged index may not delete.
|
||||
let made: String = tx.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'search'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
tx.execute_batch(&format!("DROP TABLE search; {made};"))?;
|
||||
for &id in &ids {
|
||||
let (subject, from, to, cc, content): (
|
||||
Option<String>,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
) = tx.query_row(
|
||||
"SELECT subject, sender, recipients, copies, content FROM messages
|
||||
WHERE id = ?1",
|
||||
[id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
let lists: [Vec<Mailbox>; 3] = [
|
||||
serde_json::from_str(&from)?,
|
||||
serde_json::from_str(&to)?,
|
||||
serde_json::from_str(&cc)?,
|
||||
];
|
||||
let (body, files) = match content {
|
||||
Some(hash) => contents(MessageId(id), &read(&load_at(&self.dir, &hash)?)?),
|
||||
None => Default::default(),
|
||||
};
|
||||
let people = people([&lists[0], &lists[1], &lists[2]]);
|
||||
put(
|
||||
&tx,
|
||||
MessageId(id),
|
||||
subject.as_deref(),
|
||||
&people,
|
||||
&body,
|
||||
&files,
|
||||
)?;
|
||||
}
|
||||
let count = ids.len();
|
||||
tx.commit()?;
|
||||
log::info!("mail: search index rebuilt for {count} messages");
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn people(lists: [&[Mailbox]; 3]) -> String {
|
||||
let mut out = String::new();
|
||||
for person in lists.into_iter().flatten() {
|
||||
if let Some(name) = &person.name {
|
||||
out.push_str(name);
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(&person.email);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The body text and the attachment names.
|
||||
fn contents(id: MessageId, mail: &Mail) -> (String, String) {
|
||||
let body = match (&mail.text, &mail.html) {
|
||||
(Some(text), _) => text.clone(),
|
||||
(None, Some(html)) => {
|
||||
let (text, short) = html::text(html);
|
||||
if short {
|
||||
log::warn!(
|
||||
"mail: message {} nests HTML too deep; its later text is not indexed",
|
||||
id.0
|
||||
);
|
||||
}
|
||||
text
|
||||
}
|
||||
(None, None) => String::new(),
|
||||
};
|
||||
let files = mail
|
||||
.attachments
|
||||
.iter()
|
||||
.filter_map(|part| part.name.as_deref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(body, files)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::fs;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::folders::Synced;
|
||||
use super::{BACKUPS, DB, open, schema::STEPS};
|
||||
use crate::{Account, Auth, Error, Protocol, Role, Security, Server};
|
||||
|
||||
fn account(address: &str) -> Account {
|
||||
let server = Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 993,
|
||||
security: Security::Tls,
|
||||
};
|
||||
Account {
|
||||
address: address.into(),
|
||||
user: address.into(),
|
||||
auth: Auth::Password,
|
||||
protocol: Protocol::Imap,
|
||||
incoming: server.clone(),
|
||||
outgoing: server,
|
||||
}
|
||||
}
|
||||
|
||||
mod ids;
|
||||
mod listing;
|
||||
|
||||
const ADD: &str = "ALTER TABLE accounts ADD COLUMN nick TEXT";
|
||||
const BROKEN: &str = "ALTER TABLE nowhere ADD COLUMN nick TEXT";
|
||||
|
||||
fn version(path: &std::path::Path) -> i64 {
|
||||
Connection::open(path)
|
||||
.unwrap()
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn backups(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
match fs::read_dir(dir.join(BACKUPS)) {
|
||||
Ok(entries) => entries.map(|entry| entry.unwrap().path()).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_store_is_at_the_last_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = open(dir.path(), STEPS).unwrap();
|
||||
assert_eq!(store.read_only(), None);
|
||||
assert_eq!(version(&dir.path().join(DB)), STEPS.len() as i64);
|
||||
assert!(backups(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upgrade_backs_up_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
open(dir.path(), &[STEPS[0]])
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let store = open(dir.path(), &[STEPS[0], ADD]).unwrap();
|
||||
assert_eq!(store.read_only(), None);
|
||||
assert_eq!(version(&dir.path().join(DB)), 2);
|
||||
let [backup] = backups(dir.path()).try_into().unwrap();
|
||||
assert_eq!(version(&backup), 1);
|
||||
let accounts: i64 = Connection::open(&backup)
|
||||
.unwrap()
|
||||
.query_row("SELECT count(*) FROM accounts", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(accounts, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_upgrade_keeps_the_data_and_refuses_writes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
open(dir.path(), &[STEPS[0]])
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let store = open(dir.path(), &[STEPS[0], ADD, BROKEN]).unwrap();
|
||||
let reason = store.read_only().unwrap();
|
||||
assert!(reason.contains("v1 to v3"), "{reason}");
|
||||
assert_eq!(store.accounts().unwrap()[0].1.address, "ann@example.com");
|
||||
let err = store.add_account(&account("bo@example.com")).unwrap_err();
|
||||
assert!(matches!(err, Error::ReadOnly(_)), "{err}");
|
||||
drop(store);
|
||||
assert_eq!(version(&dir.path().join(DB)), 1);
|
||||
assert_eq!(backups(dir.path()).len(), 1);
|
||||
let partial = Connection::open(dir.path().join(DB))
|
||||
.unwrap()
|
||||
.prepare("SELECT nick FROM accounts")
|
||||
.map(drop);
|
||||
assert!(partial.is_err(), "the first step was not rolled back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_schema_is_read_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
drop(open(dir.path(), &[STEPS[0], ADD]).unwrap());
|
||||
let store = open(dir.path(), &[STEPS[0]]).unwrap();
|
||||
let reason = store.read_only().unwrap();
|
||||
assert!(reason.contains("newer"), "{reason}");
|
||||
assert!(store.accounts().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_backup_means_no_upgrade() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
drop(open(dir.path(), &[STEPS[0]]).unwrap());
|
||||
fs::write(dir.path().join(BACKUPS), b"a file in the way").unwrap();
|
||||
let store = open(dir.path(), &[STEPS[0], ADD]).unwrap();
|
||||
let reason = store.read_only().unwrap();
|
||||
assert!(reason.contains("no backup"), "{reason}");
|
||||
drop(store);
|
||||
assert_eq!(version(&dir.path().join(DB)), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v1_store_gains_sync_and_keeps_its_mail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = open(dir.path(), &[STEPS[0]]).unwrap();
|
||||
let ann = old.add_account(&account("ann@example.com")).unwrap();
|
||||
old.conn
|
||||
.execute_batch(&format!(
|
||||
"INSERT INTO mailboxes (account, name) VALUES ({}, 'INBOX');
|
||||
INSERT INTO messages (mailbox, uidvalidity, uid, in_reply_to, refs,
|
||||
sender, recipients, copies, content) VALUES
|
||||
(1, 7, 1, '[]', '[]', '[]', '[]', '[]', '{}'),
|
||||
(1, 6, 2, '[]', '[]', '[]', '[]', '[]', NULL);",
|
||||
ann.0,
|
||||
"a".repeat(64)
|
||||
))
|
||||
.unwrap();
|
||||
drop(old);
|
||||
let mut store = open(dir.path(), STEPS).unwrap();
|
||||
assert_eq!(store.read_only(), None);
|
||||
assert_eq!(version(&dir.path().join(DB)), STEPS.len() as i64);
|
||||
assert_eq!(backups(dir.path()).len(), 1);
|
||||
let synced = Synced {
|
||||
at: None,
|
||||
messages: 2,
|
||||
uncached: 1,
|
||||
partial: 0,
|
||||
};
|
||||
assert_eq!(store.synced(ann).unwrap(), synced);
|
||||
store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
let [inbox] = store.folders(ann).unwrap().try_into().unwrap();
|
||||
assert_eq!(
|
||||
(inbox.name.as_str(), inbox.role),
|
||||
("INBOX", Some(Role::Inbox))
|
||||
);
|
||||
assert!(!inbox.offline);
|
||||
let held = store.open_mailbox(inbox.id, 7).unwrap();
|
||||
assert_eq!(held.keys().copied().collect::<Vec<_>>(), [1]);
|
||||
assert!(held[&1].body);
|
||||
store.set_synced(ann, 9).unwrap();
|
||||
assert_eq!(store.synced(ann).unwrap().at, Some(9));
|
||||
assert_eq!(store.synced(ann).unwrap().messages, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upgrade_leaving_a_row_without_its_parent_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = open(dir.path(), &[STEPS[0]]).unwrap();
|
||||
old.conn
|
||||
.execute_batch(
|
||||
"PRAGMA foreign_keys = OFF;
|
||||
INSERT INTO mailboxes (account, name) VALUES (99, 'INBOX');",
|
||||
)
|
||||
.unwrap();
|
||||
drop(old);
|
||||
let store = open(dir.path(), STEPS).unwrap();
|
||||
let reason = store.read_only().unwrap();
|
||||
assert!(reason.contains("missing rows"), "{reason}");
|
||||
drop(store);
|
||||
assert_eq!(version(&dir.path().join(DB)), 1);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Ids are never reused, so a stale one names nothing.
|
||||
|
||||
use super::{STEPS, account, open};
|
||||
use crate::{Fetched, Key, Role, Store};
|
||||
|
||||
const MAIL: &[u8] = b"From: a@example.com\r\nSubject: hi\r\n\r\nhi\r\n";
|
||||
|
||||
fn imap(uidvalidity: u32) -> Key {
|
||||
Key::Imap {
|
||||
uidvalidity,
|
||||
uid: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_account_folder_or_message_leaves_its_id_unused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bob = store.add_account(&account("bob@example.com")).unwrap();
|
||||
store.remove_account(bob).unwrap();
|
||||
let cy = store.add_account(&account("cy@example.com")).unwrap();
|
||||
assert!(cy.0 > bob.0, "account {} came back", bob.0);
|
||||
let [_, old] = store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox)), ("Old", None)])
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
let [inbox, new] = store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox)), ("New", None)])
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert!(new.0 > old.0, "folder {} came back", old.0);
|
||||
store.open_mailbox(inbox, 1).unwrap();
|
||||
let first = store
|
||||
.put(inbox, &imap(1), &[], Fetched::Whole(MAIL))
|
||||
.unwrap();
|
||||
store.open_mailbox(inbox, 2).unwrap();
|
||||
let second = store
|
||||
.put(inbox, &imap(2), &[], Fetched::Whole(MAIL))
|
||||
.unwrap();
|
||||
assert!(second.0 > first.0, "message {} came back", first.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v3_store_keeps_its_ids_and_stops_reusing_them() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut old = open(dir.path(), &STEPS[..3]).unwrap();
|
||||
let ann = old.add_account(&account("ann@example.com")).unwrap();
|
||||
let [inbox] = old
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
old.open_mailbox(inbox, 1).unwrap();
|
||||
let first = old
|
||||
.put(inbox, &imap(1), &["\\Seen"], Fetched::Whole(MAIL))
|
||||
.unwrap();
|
||||
drop(old);
|
||||
let mut store = open(dir.path(), STEPS).unwrap();
|
||||
assert_eq!(store.read_only(), None);
|
||||
assert_eq!(store.accounts().unwrap()[0].0, ann);
|
||||
let held = store.open_mailbox(inbox, 1).unwrap();
|
||||
assert_eq!(held[&1].id, first);
|
||||
store.open_mailbox(inbox, 2).unwrap();
|
||||
let second = store
|
||||
.put(inbox, &imap(2), &[], Fetched::Whole(MAIL))
|
||||
.unwrap();
|
||||
assert!(second.0 > first.0, "message {} came back", first.0);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::account;
|
||||
use crate::{Fetched, Filter, Key, MailboxId, Role, Scope, Store};
|
||||
|
||||
fn mail(subject: &str, date: Option<&str>, attachment: bool) -> Vec<u8> {
|
||||
let mut head = format!("From: Ann <ann@example.com>\r\nSubject: {subject}\r\n");
|
||||
if let Some(date) = date {
|
||||
head.push_str(&format!("Date: {date}\r\n"));
|
||||
}
|
||||
if attachment {
|
||||
format!(
|
||||
"{head}Content-Type: multipart/mixed; boundary=b\r\n\r\n--b\r\n\
|
||||
Content-Type: text/plain\r\n\r\nhi\r\n--b\r\n\
|
||||
Content-Type: application/pdf\r\nContent-Disposition: attachment; filename=a.pdf\r\n\r\n\
|
||||
%PDF\r\n--b--\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
} else {
|
||||
format!("{head}Content-Type: text/plain\r\n\r\nhi\r\n").into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], bytes: &[u8]) {
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid,
|
||||
};
|
||||
store
|
||||
.put(mailbox, &key, flags, Fetched::Whole(bytes))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn subjects(store: &Store, scope: Scope, filter: Filter) -> Vec<String> {
|
||||
let listed = store.list(scope, filter).unwrap();
|
||||
listed.into_iter().map(|s| s.subject.unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Two accounts: inbox and archive, inbox only.
|
||||
fn two_accounts() -> (tempfile::TempDir, Store, [MailboxId; 3]) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bob = store.add_account(&account("bob@example.com")).unwrap();
|
||||
let [ann_inbox, archive] = store
|
||||
.set_folders(
|
||||
ann,
|
||||
&[
|
||||
("INBOX", Some(Role::Inbox)),
|
||||
("Archive", Some(Role::Archive)),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let [bob_inbox] = store
|
||||
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
(dir, store, [ann_inbox, archive, bob_inbox])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_inbox_lists_newest_first_and_undated_last() {
|
||||
let (_dir, mut store, [ann, archive, bob]) = two_accounts();
|
||||
put(
|
||||
&mut store,
|
||||
ann,
|
||||
1,
|
||||
&[],
|
||||
&mail("old", Some("Mon, 1 Jan 2024 10:00:00 +0000"), false),
|
||||
);
|
||||
put(
|
||||
&mut store,
|
||||
bob,
|
||||
1,
|
||||
&[],
|
||||
&mail("new", Some("Tue, 2 Jan 2024 10:00:00 +0000"), false),
|
||||
);
|
||||
put(&mut store, ann, 2, &[], &mail("undated", None, false));
|
||||
put(
|
||||
&mut store,
|
||||
archive,
|
||||
1,
|
||||
&[],
|
||||
&mail("filed", Some("Wed, 3 Jan 2024 10:00:00 +0000"), false),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
subjects(&store, Scope::Inboxes, Filter::default()),
|
||||
["new", "old", "undated"]
|
||||
);
|
||||
assert_eq!(
|
||||
subjects(&store, Scope::Mailbox(archive), Filter::default()),
|
||||
["filed"]
|
||||
);
|
||||
assert_eq!(
|
||||
subjects(&store, Scope::Mailbox(ann), Filter::default()),
|
||||
["old", "undated"]
|
||||
);
|
||||
|
||||
let first = &store.list(Scope::Inboxes, Filter::default()).unwrap()[0];
|
||||
assert_eq!(first.from[0].email, "ann@example.com");
|
||||
assert_eq!(first.date, Some(1_704_189_600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_narrow_together() {
|
||||
let (_dir, mut store, [ann, ..]) = two_accounts();
|
||||
let date = Some("Mon, 1 Jan 2024 10:00:00 +0000");
|
||||
put(&mut store, ann, 1, &["\\Seen"], &mail("read", date, false));
|
||||
put(
|
||||
&mut store,
|
||||
ann,
|
||||
2,
|
||||
&["\\SEEN", "\\flagged"],
|
||||
&mail("read flagged", date, true),
|
||||
);
|
||||
put(
|
||||
&mut store,
|
||||
ann,
|
||||
3,
|
||||
&["\\Flagged"],
|
||||
&mail("unread flagged", date, true),
|
||||
);
|
||||
put(&mut store, ann, 4, &[], &mail("unread", date, false));
|
||||
|
||||
let scope = Scope::Mailbox(ann);
|
||||
let unread = Filter {
|
||||
unread: true,
|
||||
..Filter::default()
|
||||
};
|
||||
let flagged = Filter {
|
||||
flagged: true,
|
||||
..Filter::default()
|
||||
};
|
||||
let attached = Filter {
|
||||
attachments: true,
|
||||
..Filter::default()
|
||||
};
|
||||
let all = Filter {
|
||||
unread: true,
|
||||
flagged: true,
|
||||
attachments: true,
|
||||
};
|
||||
assert_eq!(
|
||||
subjects(&store, scope, unread),
|
||||
["unread", "unread flagged"]
|
||||
);
|
||||
assert_eq!(
|
||||
subjects(&store, scope, flagged),
|
||||
["unread flagged", "read flagged"]
|
||||
);
|
||||
assert_eq!(
|
||||
subjects(&store, scope, attached),
|
||||
["unread flagged", "read flagged"]
|
||||
);
|
||||
assert_eq!(subjects(&store, scope, all), ["unread flagged"]);
|
||||
|
||||
let listed = store.list(scope, Filter::default()).unwrap();
|
||||
let read_flagged = listed
|
||||
.iter()
|
||||
.find(|s| s.subject.as_deref() == Some("read flagged"))
|
||||
.unwrap();
|
||||
assert!(read_flagged.seen && read_flagged.flagged);
|
||||
assert_eq!(read_flagged.attachments, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_counts_by_mailbox() {
|
||||
let (_dir, mut store, [ann, archive, bob]) = two_accounts();
|
||||
put(&mut store, ann, 1, &[], &mail("a", None, false));
|
||||
put(&mut store, ann, 2, &["\\seen"], &mail("b", None, false));
|
||||
put(&mut store, ann, 3, &[], &mail("c", None, false));
|
||||
put(&mut store, archive, 1, &["\\Seen"], &mail("d", None, false));
|
||||
put(&mut store, bob, 1, &[], &mail("e", None, false));
|
||||
|
||||
let counts = store.unread().unwrap();
|
||||
assert_eq!(counts.len(), 2);
|
||||
assert_eq!((counts[&ann], counts[&bob]), (2, 1));
|
||||
assert!(!counts.contains_key(&archive));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! IMAP and POP3 sync (§12.3).
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Account, AccountId, Error, Fetched, Got, Held, Imap, Key, MailboxId, Pop, Protocol, Result,
|
||||
Role, Secret, Store, TlsConnector, Vault, oauth,
|
||||
};
|
||||
|
||||
/// Bodies come down for mail this recent.
|
||||
pub const BODY_DAYS: i64 = 90;
|
||||
|
||||
/// UIDs per FETCH.
|
||||
const BATCH: usize = 100;
|
||||
|
||||
/// What one sync changed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
pub folders: usize,
|
||||
pub headers: usize,
|
||||
pub bodies: usize,
|
||||
pub removed: usize,
|
||||
/// POP3 messages deleted from the server.
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
/// The password, or an access token from the refresh token.
|
||||
pub async fn secret(account: &Account, vault: &Vault) -> Result<Secret> {
|
||||
let name = account.secret_name();
|
||||
let kept = vault
|
||||
.get(&name)?
|
||||
.ok_or_else(|| Error::Vault(format!("no saved secret for {name}")))?;
|
||||
let Some(provider) = account.auth.provider() else {
|
||||
return Ok(Secret::Password(kept));
|
||||
};
|
||||
let tokens = oauth::refresh(&provider?, &kept).await?;
|
||||
if let Some(next) = &tokens.refresh {
|
||||
vault.put(&name, next);
|
||||
}
|
||||
Ok(Secret::Token(tokens.access))
|
||||
}
|
||||
|
||||
/// One pass over every folder; `now` is Unix seconds.
|
||||
pub async fn imap(
|
||||
account: &Account,
|
||||
id: AccountId,
|
||||
secret: Secret,
|
||||
tls: &TlsConnector,
|
||||
store: &Mutex<Store>,
|
||||
now: i64,
|
||||
) -> Result<Report> {
|
||||
let mut imap = Imap::connect(&account.incoming, &account.login(secret), tls).await?;
|
||||
let folders: Vec<_> = imap
|
||||
.folders()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|folder| folder.selectable)
|
||||
.collect();
|
||||
let named: Vec<_> = folders
|
||||
.iter()
|
||||
.map(|folder| (folder.name.as_str(), folder.role))
|
||||
.collect();
|
||||
let boxes = lock(store)?.set_folders(id, &named)?;
|
||||
let day = since(now)?;
|
||||
let mut report = Report {
|
||||
folders: folders.len(),
|
||||
..Report::default()
|
||||
};
|
||||
for (folder, mailbox) in folders.iter().zip(boxes) {
|
||||
one(&mut imap, store, mailbox, &folder.name, &day, &mut report).await?;
|
||||
}
|
||||
imap.logout().await?;
|
||||
lock(store)?.set_synced(id, now)?;
|
||||
log::info!(
|
||||
"mail: account {} synced: {} folders, {} headers, {} bodies, {} removed",
|
||||
id.0,
|
||||
report.folders,
|
||||
report.headers,
|
||||
report.bodies,
|
||||
report.removed
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// New mail by UIDL; no folders, local copies stay.
|
||||
pub async fn pop(
|
||||
account: &Account,
|
||||
id: AccountId,
|
||||
secret: Secret,
|
||||
tls: &TlsConnector,
|
||||
store: &Mutex<Store>,
|
||||
now: i64,
|
||||
) -> Result<Report> {
|
||||
let Protocol::Pop { keep } = account.protocol else {
|
||||
return Err(Error::Invalid("a POP3 sync of an IMAP account".into()));
|
||||
};
|
||||
let mut pop = Pop::connect(&account.incoming, &account.login(secret), tls).await?;
|
||||
let inbox = match lock(store)?.set_folders(id, &[("INBOX", Some(Role::Inbox))])?[..] {
|
||||
[inbox] => inbox,
|
||||
_ => return Err(Error::Store("a POP3 account has one inbox".into())),
|
||||
};
|
||||
let mut held = lock(store)?.uidls(inbox)?;
|
||||
let mut report = Report {
|
||||
folders: 1,
|
||||
..Report::default()
|
||||
};
|
||||
for (number, uidl) in pop.uids().await? {
|
||||
if !held.contains(&uidl) {
|
||||
let bytes = pop.fetch(number).await?;
|
||||
let key = Key::Pop { uidl: uidl.clone() };
|
||||
lock(store)?.put(inbox, &key, &[], Fetched::Whole(&bytes))?;
|
||||
held.insert(uidl);
|
||||
report.bodies += 1;
|
||||
}
|
||||
if !keep {
|
||||
pop.delete(number).await?;
|
||||
report.deleted += 1;
|
||||
}
|
||||
}
|
||||
pop.quit().await?;
|
||||
lock(store)?.set_synced(id, now)?;
|
||||
log::info!(
|
||||
"mail: account {} synced: {} new, {} deleted on the server",
|
||||
id.0,
|
||||
report.bodies,
|
||||
report.deleted
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn one(
|
||||
imap: &mut Imap,
|
||||
store: &Mutex<Store>,
|
||||
mailbox: MailboxId,
|
||||
name: &str,
|
||||
day: &str,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
let opened = imap.examine(name).await?;
|
||||
let held = lock(store)?.open_mailbox(mailbox, opened.uidvalidity)?;
|
||||
if opened.exists == 0 {
|
||||
return remove(store, held.values(), report);
|
||||
}
|
||||
let server: HashMap<u32, Vec<String>> = imap.flags().await?.into_iter().collect();
|
||||
let gone = held
|
||||
.iter()
|
||||
.filter(|(uid, _)| !server.contains_key(uid))
|
||||
.map(|(_, held)| held);
|
||||
remove(store, gone, report)?;
|
||||
for (uid, held) in &held {
|
||||
match server.get(uid) {
|
||||
Some(flags) if *flags != held.flags => {
|
||||
lock(store)?.set_flags(held.id, &strs(flags))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = |uid| Key::Imap {
|
||||
uidvalidity: opened.uidvalidity,
|
||||
uid,
|
||||
};
|
||||
if lock(store)?.offline(mailbox)? {
|
||||
let mut whole: Vec<u32> = server
|
||||
.keys()
|
||||
.filter(|uid| !held.get(uid).is_some_and(|held| held.whole))
|
||||
.copied()
|
||||
.collect();
|
||||
whole.sort_unstable();
|
||||
for batch in whole.chunks(BATCH) {
|
||||
for got in imap.bodies(batch).await? {
|
||||
let fetched = Fetched::Whole(&got.bytes);
|
||||
put(store, mailbox, key(got.uid), &got, fetched)?;
|
||||
report.bodies += 1;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let recent: HashSet<u32> = imap.since(day).await?.into_iter().collect();
|
||||
let mut headers = Vec::new();
|
||||
let mut bodies = Vec::new();
|
||||
for uid in server.keys() {
|
||||
match (held.get(uid), recent.contains(uid)) {
|
||||
(None | Some(Held { body: false, .. }), true) => bodies.push(*uid),
|
||||
(None, false) => headers.push(*uid),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
headers.sort_unstable();
|
||||
bodies.sort_unstable();
|
||||
for batch in headers.chunks(BATCH) {
|
||||
for got in imap.headers(batch).await? {
|
||||
put(
|
||||
store,
|
||||
mailbox,
|
||||
key(got.uid),
|
||||
&got,
|
||||
Fetched::Headers(&got.bytes),
|
||||
)?;
|
||||
report.headers += 1;
|
||||
}
|
||||
}
|
||||
for batch in bodies.chunks(BATCH) {
|
||||
let (whole, partial): (Vec<_>, Vec<_>) = imap
|
||||
.structures(batch)
|
||||
.await?
|
||||
.into_iter()
|
||||
.partition(|(_, node)| node.complete());
|
||||
let whole: Vec<u32> = whole.into_iter().map(|(uid, _)| uid).collect();
|
||||
if !whole.is_empty() {
|
||||
for got in imap.bodies(&whole).await? {
|
||||
put(
|
||||
store,
|
||||
mailbox,
|
||||
key(got.uid),
|
||||
&got,
|
||||
Fetched::Whole(&got.bytes),
|
||||
)?;
|
||||
report.bodies += 1;
|
||||
}
|
||||
}
|
||||
// §12.3: attachments wait for a request.
|
||||
for (uid, node) in partial {
|
||||
let Some(got) = imap.text(uid, &node).await? else {
|
||||
continue;
|
||||
};
|
||||
let missing = node.missing();
|
||||
let text = Fetched::Text {
|
||||
bytes: &got.bytes,
|
||||
missing: &missing,
|
||||
};
|
||||
put(store, mailbox, key(uid), &got, text)?;
|
||||
report.bodies += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(
|
||||
store: &Mutex<Store>,
|
||||
mailbox: MailboxId,
|
||||
key: Key,
|
||||
got: &Got,
|
||||
fetched: Fetched,
|
||||
) -> Result<()> {
|
||||
lock(store)?.put(mailbox, &key, &strs(&got.flags), fetched)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove<'a>(
|
||||
store: &Mutex<Store>,
|
||||
gone: impl Iterator<Item = &'a Held>,
|
||||
report: &mut Report,
|
||||
) -> Result<()> {
|
||||
for held in gone {
|
||||
lock(store)?.remove(held.id)?;
|
||||
report.removed += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strs(flags: &[String]) -> Vec<&str> {
|
||||
flags.iter().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
/// `SINCE` takes a date in English: `19-Jul-2026`.
|
||||
fn since(now: i64) -> Result<String> {
|
||||
let start = jiff::Timestamp::from_second(now - BODY_DAYS * 86_400)
|
||||
.map_err(|err| Error::Store(format!("a sync time: {err}")))?;
|
||||
Ok(start
|
||||
.to_zoned(jiff::tz::TimeZone::UTC)
|
||||
.strftime("%-d-%b-%Y")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn lock(store: &Mutex<Store>) -> Result<MutexGuard<'_, Store>> {
|
||||
store
|
||||
.lock()
|
||||
.map_err(|_| Error::Store("a store task panicked".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_window_starts_ninety_days_back() {
|
||||
// 2026-10-19 12:00 UTC
|
||||
assert_eq!(super::since(1_792_411_200).unwrap(), "21-Jul-2026");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A secret store backend, or a test double.
|
||||
pub trait Backend: Send + Sync {
|
||||
fn get(&self, name: &str) -> std::result::Result<Option<String>, String>;
|
||||
fn set(&self, name: &str, secret: &str) -> std::result::Result<(), String>;
|
||||
fn delete(&self, name: &str) -> std::result::Result<(), String>;
|
||||
}
|
||||
|
||||
/// The system credential store (§18.2).
|
||||
pub struct Keyring;
|
||||
|
||||
const SERVICE: &str = "ElyMail";
|
||||
|
||||
impl Backend for Keyring {
|
||||
fn get(&self, name: &str) -> std::result::Result<Option<String>, String> {
|
||||
match entry(name)?.get_password() {
|
||||
Ok(secret) => Ok(Some(secret)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, name: &str, secret: &str) -> std::result::Result<(), String> {
|
||||
entry(name)?
|
||||
.set_password(secret)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn delete(&self, name: &str) -> std::result::Result<(), String> {
|
||||
match entry(name)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(name: &str) -> std::result::Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(SERVICE, name).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Where a kept secret lives.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Kept {
|
||||
System,
|
||||
/// The system store refused; gone when the app quits.
|
||||
Session(String),
|
||||
}
|
||||
|
||||
/// Passwords and refresh tokens: system store, else memory.
|
||||
pub struct Vault {
|
||||
backend: Box<dyn Backend>,
|
||||
session: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
pub fn system() -> Self {
|
||||
Self::new(Box::new(Keyring))
|
||||
}
|
||||
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
session: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores a secret; never on disk outside the system store.
|
||||
pub fn put(&self, name: &str, secret: &str) -> Kept {
|
||||
self.session().remove(name);
|
||||
match self.backend.set(name, secret) {
|
||||
Ok(()) => {
|
||||
log::info!("vault: {name} kept in the system store");
|
||||
Kept::System
|
||||
}
|
||||
Err(reason) => {
|
||||
log::warn!("vault: system store refused {name}, this session only: {reason}");
|
||||
self.session().insert(name.to_owned(), secret.to_owned());
|
||||
Kept::Session(reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The secret, None when never stored.
|
||||
pub fn get(&self, name: &str) -> Result<Option<String>> {
|
||||
if let Some(secret) = self.session().get(name) {
|
||||
return Ok(Some(secret.clone()));
|
||||
}
|
||||
self.backend
|
||||
.get(name)
|
||||
.map_err(|reason| Error::Vault(format!("reading {name}: {reason}")))
|
||||
}
|
||||
|
||||
/// A refused delete keeps the session copy too.
|
||||
pub fn remove(&self, name: &str) -> Result<()> {
|
||||
self.backend
|
||||
.delete(name)
|
||||
.map_err(|reason| Error::Vault(format!("deleting {name}: {reason}")))?;
|
||||
self.session().remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
|
||||
self.session.lock().expect("vault lock")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::io;
|
||||
|
||||
use imap_proto::{Response, Status};
|
||||
use nom::{Err as NomErr, Needed};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const CHUNK: usize = 16 * 1024;
|
||||
|
||||
/// IMAP responses off a byte stream; keeps the last BYE.
|
||||
pub(crate) struct Wire<S> {
|
||||
io: S,
|
||||
buf: Vec<u8>,
|
||||
/// Bytes the next parse needs before it can finish.
|
||||
needs: usize,
|
||||
bye: Option<String>,
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + AsyncWrite + Unpin> Wire<S> {
|
||||
pub fn new(io: S) -> Self {
|
||||
Self {
|
||||
io,
|
||||
buf: Vec::new(),
|
||||
needs: 0,
|
||||
bye: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the next response and its raw bytes to `read`.
|
||||
pub async fn next<R>(&mut self, mut read: impl FnMut(&Response, &[u8]) -> R) -> Result<R> {
|
||||
loop {
|
||||
if self.buf.len() >= self.needs && !self.buf.is_empty() {
|
||||
let parsed = match Response::parse(&self.buf) {
|
||||
Ok((rest, response)) => {
|
||||
let used = self.buf.len() - rest.len();
|
||||
let raw = &self.buf[..used];
|
||||
if let Response::Data {
|
||||
status: Status::Bye,
|
||||
..
|
||||
} = response
|
||||
{
|
||||
self.bye = Some(words(raw));
|
||||
}
|
||||
Some((used, read(&response, raw)))
|
||||
}
|
||||
Err(NomErr::Incomplete(Needed::Size(more))) => {
|
||||
self.needs = self.buf.len() + more.get();
|
||||
None
|
||||
}
|
||||
Err(NomErr::Incomplete(Needed::Unknown)) => {
|
||||
self.needs = self.buf.len() + 1;
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(Error::Protocol(
|
||||
"a reply from the server could not be read".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Some((used, value)) = parsed {
|
||||
self.buf.drain(..used);
|
||||
self.needs = 0;
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
self.buf.reserve(CHUNK);
|
||||
match self.io.read_buf(&mut self.buf).await {
|
||||
Ok(0) => return Err(self.closed(io::ErrorKind::UnexpectedEof.into())),
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(self.closed(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
let sent = async {
|
||||
self.io.write_all(bytes).await?;
|
||||
self.io.flush().await
|
||||
};
|
||||
sent.await.map_err(|err| self.closed(err))
|
||||
}
|
||||
|
||||
/// The stream for TLS; bytes already read would be plaintext.
|
||||
pub fn into_inner(self) -> Result<S> {
|
||||
if !self.buf.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"the server sent data before TLS began".into(),
|
||||
));
|
||||
}
|
||||
Ok(self.io)
|
||||
}
|
||||
|
||||
/// After BYE, a dropped line is the server's words.
|
||||
fn closed(&self, err: io::Error) -> Error {
|
||||
match &self.bye {
|
||||
Some(words) => Error::Server(words.clone()),
|
||||
None => Error::Connect(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The line after its tag, as the server wrote it.
|
||||
pub(crate) fn words(raw: &[u8]) -> String {
|
||||
let line = raw.strip_suffix(b"\r\n").unwrap_or(raw);
|
||||
let text = line.splitn(2, |&byte| byte == b' ').nth(1).unwrap_or(line);
|
||||
String::from_utf8_lossy(text).into_owned()
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{
|
||||
Account, Auth, Cached, Error, Fetched, Key, Protocol, Secret, Security, Server, Store,
|
||||
};
|
||||
use fake::{Certs, account, certs, expect, send, serve};
|
||||
|
||||
fn pop_oauth(address: &str) -> Account {
|
||||
Account {
|
||||
address: address.into(),
|
||||
user: "bo".into(),
|
||||
auth: Auth::Microsoft,
|
||||
protocol: Protocol::Pop { keep: true },
|
||||
incoming: Server {
|
||||
host: "pop.example.com".into(),
|
||||
port: 995,
|
||||
security: Security::Tls,
|
||||
},
|
||||
outgoing: Server {
|
||||
host: "smtp.example.com".into(),
|
||||
port: 587,
|
||||
security: Security::StartTls,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_come_back_as_added() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
let ann = account("ann@example.com");
|
||||
let bo = pop_oauth("bo@example.com");
|
||||
let first = store.add_account(&ann).unwrap();
|
||||
let second = store.add_account(&bo).unwrap();
|
||||
drop(store);
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
assert_eq!(store.accounts().unwrap(), [(first, ann), (second, bo)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_is_added_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
store.add_account(&account("ann@example.com")).unwrap();
|
||||
let err = store
|
||||
.add_account(&pop_oauth("ANN@Example.com"))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Taken(address) if address == "ANN@Example.com"),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(store.accounts().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_account_keeps_what_another_shares() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bo = store.add_account(&account("bo@example.com")).unwrap();
|
||||
let (ann_inbox, bo_inbox) = (
|
||||
store.mailbox(ann, "INBOX").unwrap(),
|
||||
store.mailbox(bo, "INBOX").unwrap(),
|
||||
);
|
||||
let shared = b"Subject: Both\r\n\r\nshared\r\n";
|
||||
let own = b"Subject: Ann only\r\n\r\nmine and mine alone\r\n";
|
||||
let key = |uid| Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid,
|
||||
};
|
||||
store
|
||||
.put(ann_inbox, &key(1), &[], Fetched::Whole(shared))
|
||||
.unwrap();
|
||||
store
|
||||
.put(ann_inbox, &key(2), &[], Fetched::Whole(own))
|
||||
.unwrap();
|
||||
let kept = store
|
||||
.put(bo_inbox, &key(1), &[], Fetched::Whole(shared))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.cached(ann).unwrap(),
|
||||
Cached {
|
||||
messages: 2,
|
||||
bytes: own.len() as u64
|
||||
}
|
||||
);
|
||||
store.remove_account(ann).unwrap();
|
||||
assert_eq!(store.accounts().unwrap().len(), 1);
|
||||
assert_eq!(store.original(kept).unwrap().unwrap(), shared);
|
||||
assert_eq!(store.sizes().unwrap().originals, shared.len() as u64);
|
||||
let err = store.remove_account(ann).unwrap_err();
|
||||
assert!(matches!(err, Error::Store(_)), "{err}");
|
||||
}
|
||||
|
||||
/// IMAP and SMTP scripts that log in and leave.
|
||||
async fn servers(
|
||||
certs: &Certs,
|
||||
imap_answer: &str,
|
||||
) -> (Account, Vec<tokio::task::JoinHandle<Result<(), String>>>) {
|
||||
let mut imap = vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send(imap_answer),
|
||||
];
|
||||
if imap_answer.contains(" OK ") {
|
||||
imap.extend([expect("{tag} LOGOUT"), send("* BYE\n{tag} OK bye\n")]);
|
||||
}
|
||||
let (incoming, imap) = serve(certs, Security::Tls, imap).await;
|
||||
let smtp = vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH PLAIN\n"),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
expect("QUIT"),
|
||||
send("221 bye\n"),
|
||||
];
|
||||
let (outgoing, smtp) = serve(certs, Security::Tls, smtp).await;
|
||||
let account = Account {
|
||||
incoming,
|
||||
outgoing,
|
||||
..account("ann@example.com")
|
||||
};
|
||||
(account, vec![imap, smtp])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_check_logs_in_to_both_servers() {
|
||||
let certs = certs();
|
||||
let (account, scripts) = servers(&certs, "{tag} OK in\n").await;
|
||||
let checked = account
|
||||
.check(Secret::Password("pw".into()), &certs.trusted)
|
||||
.await;
|
||||
assert!(checked.passed());
|
||||
for script in scripts {
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_server_answers_for_itself() {
|
||||
let certs = certs();
|
||||
let (account, scripts) =
|
||||
servers(&certs, "{tag} NO [AUTHENTICATIONFAILED] bad password\n").await;
|
||||
let checked = account
|
||||
.check(Secret::Password("pw".into()), &certs.trusted)
|
||||
.await;
|
||||
assert!(!checked.passed());
|
||||
match checked.incoming {
|
||||
Err(Error::Rejected(text)) => assert!(text.contains("bad password"), "{text}"),
|
||||
other => panic!("incoming: {:?}", other.err()),
|
||||
}
|
||||
checked.outgoing.unwrap();
|
||||
for script in scripts {
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_no_server_could_carry_is_refused() {
|
||||
account("ann@example.com").valid().unwrap();
|
||||
let broken = [
|
||||
Account {
|
||||
address: "ann".into(),
|
||||
..account("ann@example.com")
|
||||
},
|
||||
Account {
|
||||
user: String::new(),
|
||||
..account("ann@example.com")
|
||||
},
|
||||
Account {
|
||||
user: "ann\r\nRSET".into(),
|
||||
..account("ann@example.com")
|
||||
},
|
||||
Account {
|
||||
incoming: Server {
|
||||
host: "imap example.com".into(),
|
||||
port: 993,
|
||||
security: Security::Tls,
|
||||
},
|
||||
..account("ann@example.com")
|
||||
},
|
||||
Account {
|
||||
outgoing: Server {
|
||||
host: "smtp.example.com".into(),
|
||||
port: 0,
|
||||
security: Security::Tls,
|
||||
},
|
||||
..account("ann@example.com")
|
||||
},
|
||||
];
|
||||
for account in broken {
|
||||
let err = account.valid().unwrap_err();
|
||||
assert!(matches!(err, Error::Invalid(_)), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
mod fake;
|
||||
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use ely_mail_engine::{Fetched, Key, Store};
|
||||
use fake::account;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parked(dir: &Path, hash: &str) -> std::path::PathBuf {
|
||||
dir.join("content")
|
||||
.join(&hash[..2])
|
||||
.join(format!(".{hash}.gone"))
|
||||
}
|
||||
|
||||
fn key(uid: u32) -> Key {
|
||||
Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removal_that_fails_midway_keeps_every_original() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let inbox = store.mailbox(ann, "INBOX").unwrap();
|
||||
let one: &[u8] = b"Subject: One\r\n\r\nfirst\r\n";
|
||||
let two: &[u8] = b"Subject: Two\r\n\r\nsecond\r\n";
|
||||
let a = store.put(inbox, &key(1), &[], Fetched::Whole(one)).unwrap();
|
||||
let b = store.put(inbox, &key(2), &[], Fetched::Whole(two)).unwrap();
|
||||
let last = hash(one).max(hash(two));
|
||||
fs::create_dir(parked(dir.path(), &last)).unwrap();
|
||||
store.remove_account(ann).unwrap_err();
|
||||
assert_eq!(store.accounts().unwrap().len(), 1);
|
||||
assert_eq!(store.original(a).unwrap().unwrap(), one);
|
||||
assert_eq!(store.original(b).unwrap().unwrap(), two);
|
||||
fs::remove_dir(parked(dir.path(), &last)).unwrap();
|
||||
store.remove_account(ann).unwrap();
|
||||
assert_eq!(store.sizes().unwrap().originals, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_puts_back_what_a_crash_left_aside() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let inbox = store.mailbox(ann, "INBOX").unwrap();
|
||||
let kept: &[u8] = b"Subject: Kept\r\n\r\nstill named\r\n";
|
||||
let id = store
|
||||
.put(inbox, &key(1), &[], Fetched::Whole(kept))
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let named = hash(kept);
|
||||
let original = dir.path().join("content").join(&named[..2]).join(&named);
|
||||
fs::rename(&original, parked(dir.path(), &named)).unwrap();
|
||||
let orphan = hash(b"nobody names this");
|
||||
let shard = dir.path().join("content").join(&orphan[..2]);
|
||||
fs::create_dir_all(&shard).unwrap();
|
||||
fs::write(parked(dir.path(), &orphan), b"nobody names this").unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
assert_eq!(store.original(id).unwrap().unwrap(), kept);
|
||||
assert!(!parked(dir.path(), &named).exists());
|
||||
assert!(!parked(dir.path(), &orphan).exists());
|
||||
assert_eq!(store.sizes().unwrap().originals, kept.len() as u64);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! A scripted mail server over real TLS, for protocol tests.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod smtp;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ely_mail_engine::{Account, Auth, Login, Protocol, Secret, Security, Server, TlsConnector};
|
||||
use rcgen::{CertificateParams, KeyPair};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader},
|
||||
net::TcpListener,
|
||||
task::JoinHandle,
|
||||
};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ClientConfig, RootCertStore, ServerConfig,
|
||||
crypto::ring,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||
},
|
||||
};
|
||||
|
||||
pub enum Step {
|
||||
/// CRLF for `\n`; `{tag}` is the client's last tag.
|
||||
Send(String),
|
||||
/// One line; a leading `{tag} ` takes the client's tag.
|
||||
Expect(String),
|
||||
/// SMTP DATA lines before the lone dot.
|
||||
Data(String),
|
||||
StartTls,
|
||||
}
|
||||
|
||||
pub fn send(text: &str) -> Step {
|
||||
Step::Send(text.to_owned())
|
||||
}
|
||||
|
||||
pub fn expect(text: &str) -> Step {
|
||||
Step::Expect(text.to_owned())
|
||||
}
|
||||
|
||||
trait Io: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Io for T {}
|
||||
|
||||
pub struct Certs {
|
||||
acceptor: TlsAcceptor,
|
||||
/// Trusts only this test certificate.
|
||||
pub trusted: TlsConnector,
|
||||
}
|
||||
|
||||
pub fn certs() -> Certs {
|
||||
let key = KeyPair::generate().unwrap();
|
||||
let cert = CertificateParams::new(vec!["127.0.0.1".into()])
|
||||
.unwrap()
|
||||
.self_signed(&key)
|
||||
.unwrap();
|
||||
let der = CertificateDer::from(cert.der().to_vec());
|
||||
let provider = Arc::new(ring::default_provider());
|
||||
let server = ServerConfig::builder_with_provider(provider.clone())
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![der.clone()],
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())),
|
||||
)
|
||||
.unwrap();
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(der).unwrap();
|
||||
let client = ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Certs {
|
||||
acceptor: TlsAcceptor::from(Arc::new(server)),
|
||||
trusted: TlsConnector::from(Arc::new(client)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An IMAP account on the test address.
|
||||
pub fn account(address: &str) -> Account {
|
||||
let server = |port| Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
security: Security::Tls,
|
||||
};
|
||||
Account {
|
||||
address: address.into(),
|
||||
user: address.into(),
|
||||
auth: Auth::Password,
|
||||
protocol: Protocol::Imap,
|
||||
incoming: server(993),
|
||||
outgoing: server(465),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn login(secret: Secret) -> Login {
|
||||
Login {
|
||||
user: "ann@example.com".into(),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves one connection; the task fails on the first mismatch.
|
||||
pub async fn serve(
|
||||
certs: &Certs,
|
||||
security: Security,
|
||||
steps: Vec<Step>,
|
||||
) -> (Server, JoinHandle<Result<(), String>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let acceptor = certs.acceptor.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.map_err(|err| err.to_string())?;
|
||||
let io: Box<dyn Io> = match security {
|
||||
Security::Tls => Box::new(acceptor.accept(tcp).await.map_err(|e| e.to_string())?),
|
||||
Security::StartTls => Box::new(tcp),
|
||||
};
|
||||
run(io, &acceptor, steps).await
|
||||
});
|
||||
let server = Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
security,
|
||||
};
|
||||
(server, task)
|
||||
}
|
||||
|
||||
async fn run(io: Box<dyn Io>, acceptor: &TlsAcceptor, steps: Vec<Step>) -> Result<(), String> {
|
||||
let mut io = BufReader::new(io);
|
||||
let mut tag = String::new();
|
||||
for step in steps {
|
||||
match step {
|
||||
Step::Send(text) => {
|
||||
let text = text.replace("{tag}", &tag).replace('\n', "\r\n");
|
||||
io.write_all(text.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
io.flush().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Step::Expect(want) => {
|
||||
let got = line(&mut io).await?;
|
||||
let got = match want.strip_prefix("{tag} ") {
|
||||
Some(_) => {
|
||||
let (first, rest) = got.split_once(' ').unwrap_or((&got, ""));
|
||||
tag = first.to_owned();
|
||||
format!("{{tag}} {rest}")
|
||||
}
|
||||
None => got,
|
||||
};
|
||||
if got != want {
|
||||
return Err(format!("expected {want:?}, got {got:?}"));
|
||||
}
|
||||
}
|
||||
Step::Data(want) => {
|
||||
let mut lines = Vec::new();
|
||||
loop {
|
||||
let got = line(&mut io).await?;
|
||||
if got == "." {
|
||||
break;
|
||||
}
|
||||
lines.push(got);
|
||||
}
|
||||
let got = lines.join("\n");
|
||||
if got != want {
|
||||
return Err(format!("expected data {want:?}, got {got:?}"));
|
||||
}
|
||||
}
|
||||
Step::StartTls => {
|
||||
let tcp = io.into_inner();
|
||||
let tls = acceptor.accept(tcp).await.map_err(|e| e.to_string())?;
|
||||
io = BufReader::new(Box::new(tls));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn line(io: &mut BufReader<Box<dyn Io>>) -> Result<String, String> {
|
||||
let mut line = String::new();
|
||||
io.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
match line.strip_suffix("\r\n") {
|
||||
Some(line) => Ok(line.to_owned()),
|
||||
None => Err(format!("client sent {line:?} without CRLF")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Secret, Security, Smtp};
|
||||
|
||||
use super::{Step, certs, expect, login, send, serve};
|
||||
|
||||
pub async fn logged_in(
|
||||
ehlo: &str,
|
||||
steps: Vec<Step>,
|
||||
) -> (Smtp, tokio::task::JoinHandle<Result<(), String>>) {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send(ehlo),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (server, script) = serve(&certs, Security::Tls, script).await;
|
||||
let smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(smtp, script)
|
||||
}
|
||||
|
||||
/// MAIL, RCPT and DATA with the server agreeing.
|
||||
pub fn transaction(mail: &str, rcpt: &str, data: &str) -> Vec<Step> {
|
||||
vec![
|
||||
expect(mail),
|
||||
send("250 ok\n"),
|
||||
expect(rcpt),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data(data.to_owned()),
|
||||
send("250 queued\n"),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Error, Imap, Role, Secret, Security, system_tls};
|
||||
use fake::{certs, expect, login, send, serve};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const TOKEN_LINE: &str = "user=ann@example.com\x01auth=Bearer tok-1\x01\x01";
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_login_lists_and_fetches() {
|
||||
let certs = certs();
|
||||
let lines = "Subject: Hi\n\nHello\n";
|
||||
let body = lines.replace('\n', "\r\n");
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw 1\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} CAPABILITY"),
|
||||
send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST (\\HasNoChildren) \"/\" INBOX\n"),
|
||||
send("* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\n"),
|
||||
send("{tag} OK listed\n"),
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("* 1 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK [READ-ONLY] in\n"),
|
||||
expect("{tag} UID FETCH 42 (UID FLAGS BODY.PEEK[])"),
|
||||
send(&format!(
|
||||
"* 1 FETCH (UID 42 BODY[] {{{}}}\n{lines})\n{{tag}} OK fetched\n",
|
||||
body.len()
|
||||
)),
|
||||
expect("{tag} LOGOUT"),
|
||||
send("* BYE\n{tag} OK bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw 1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let folders = imap.folders().await.unwrap();
|
||||
let names: Vec<_> = folders.iter().map(|f| (f.name.as_str(), f.role)).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
("INBOX", Some(Role::Inbox)),
|
||||
("Sent Mail", Some(Role::Sent))
|
||||
]
|
||||
);
|
||||
let fetched = imap.fetch("INBOX", 42).await.unwrap().unwrap();
|
||||
assert_eq!(String::from_utf8(fetched).unwrap(), body);
|
||||
imap.logout().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn starttls_then_xoauth2() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} STARTTLS"),
|
||||
send("{tag} OK go\n"),
|
||||
fake::Step::StartTls,
|
||||
expect("{tag} AUTHENTICATE XOAUTH2"),
|
||||
send("+ \n"),
|
||||
expect(&STANDARD.encode(TOKEN_LINE)),
|
||||
send("{tag} OK welcome\n"),
|
||||
expect("{tag} LOGOUT"),
|
||||
send("* BYE\n{tag} OK bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
imap.logout().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_password_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"bad\""),
|
||||
send("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("bad".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("Invalid credentials")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_answers_the_error_challenge_empty() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} AUTHENTICATE XOAUTH2"),
|
||||
send("+ \n"),
|
||||
expect(&STANDARD.encode(TOKEN_LINE)),
|
||||
send(&format!("+ {}\n", STANDARD.encode(r#"{"status":"401"}"#))),
|
||||
expect(""),
|
||||
send("{tag} NO [AUTHENTICATIONFAILED] Invalid token\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("Invalid token")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_uid_is_none() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("* 1 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK in\n"),
|
||||
expect("{tag} UID FETCH 9 (UID FLAGS BODY.PEEK[])"),
|
||||
send("{tag} OK nothing\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imap.fetch("INBOX", 9).await.unwrap(), None);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_reply_is_a_protocol_error() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} CAPABILITY"),
|
||||
send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST (((\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = imap.folders().await.unwrap_err();
|
||||
assert!(matches!(err, Error::Protocol(_)), "{err}");
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_system_store_refuses_an_unknown_certificate() {
|
||||
let certs = certs();
|
||||
let (server, _script) = serve(&certs, Security::Tls, vec![send("* OK ready\n")]).await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&system_tls().unwrap(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(err, Error::Tls(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_greeting_bye_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![send("* BYE [UNAVAILABLE] Maintenance in progress\n")],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("Maintenance in progress")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Greeting and LOGIN "pw", then `steps`.
|
||||
async fn logged_in(steps: Vec<fake::Step>) -> (Imap, JoinHandle<Result<(), String>>) {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("{tag} OK done\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (server, script) = serve(&certs, Security::Tls, script).await;
|
||||
let imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(imap, script)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listed_names_lose_quote_escapes_and_open_as_listed() {
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} CAPABILITY"),
|
||||
send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST () \"/\" \"a\\\"b\"\n"),
|
||||
send("* LIST () \"/\" \"c\\\\d\"\n"),
|
||||
send("* LIST () \"/\" {3}\ne\\\"\n"),
|
||||
send("{tag} OK listed\n"),
|
||||
expect("{tag} EXAMINE \"a\\\"b\""),
|
||||
send("* 1 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK in\n"),
|
||||
expect("{tag} UID FETCH 9 (UID FLAGS BODY.PEEK[])"),
|
||||
send("{tag} OK nothing\n"),
|
||||
])
|
||||
.await;
|
||||
let names: Vec<_> = imap
|
||||
.folders()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| f.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["a\"b", "c\\d", "e\\\""]);
|
||||
assert_eq!(imap.fetch(&names[0], 9).await.unwrap(), None);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_list_keeps_the_server_words() {
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} CAPABILITY"),
|
||||
send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST () \"/\" INBOX\n"),
|
||||
send("{tag} NO [UNAVAILABLE] Mailbox listing temporarily unavailable\n"),
|
||||
])
|
||||
.await;
|
||||
let err = imap.folders().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("listing temporarily unavailable")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_fetch_is_not_a_missing_uid() {
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("* 1 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK in\n"),
|
||||
expect("{tag} UID FETCH 9 (UID FLAGS BODY.PEEK[])"),
|
||||
send("{tag} NO [UNAVAILABLE] Message store temporarily unavailable\n"),
|
||||
])
|
||||
.await;
|
||||
let err = imap.fetch("INBOX", 9).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("store temporarily unavailable")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bye_after_login_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("* BYE [UNAVAILABLE] Too many simultaneous connections\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("Too many simultaneous connections")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} CAPABILITY"),
|
||||
send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* BYE Shutting down\n"),
|
||||
])
|
||||
.await;
|
||||
let err = imap.folders().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("Shutting down")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_beyond_ascii_goes_as_a_literal() {
|
||||
let certs = certs();
|
||||
let password = "pässwört";
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect(&format!(
|
||||
"{{tag}} LOGIN \"ann@example.com\" {{{}}}",
|
||||
password.len()
|
||||
)),
|
||||
send("+ go on\n"),
|
||||
expect(password),
|
||||
send("{tag} OK done\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password(password.into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_before_tls_are_refused() {
|
||||
let certs = certs();
|
||||
let (server, _script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} STARTTLS"),
|
||||
send("{tag} OK go\n* OK injected\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Protocol(text) if text.contains("before TLS")),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use ely_mail_engine::{Draft, Error, Mail, Mailbox, Part, build, read};
|
||||
|
||||
/// The sample as a server sends it: CRLF lines.
|
||||
fn sample() -> Vec<u8> {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../tests/fixtures/mail/sample.eml"
|
||||
);
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
text.replace("\r\n", "\n")
|
||||
.replace('\n', "\r\n")
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn mailbox(name: Option<&str>, email: &str) -> Mailbox {
|
||||
Mailbox {
|
||||
name: name.map(str::to_owned),
|
||||
email: email.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_reads_whole() {
|
||||
let mail = read(&sample()).unwrap();
|
||||
assert_eq!(mail.subject.as_deref(), Some("Café report"));
|
||||
assert_eq!(mail.from, [mailbox(Some("张三"), "zhang@example.com")]);
|
||||
assert_eq!(
|
||||
mail.to,
|
||||
[
|
||||
mailbox(Some("Bo Smith"), "bo@example.com"),
|
||||
mailbox(None, "cy@example.org")
|
||||
]
|
||||
);
|
||||
assert_eq!(mail.cc, [mailbox(None, "dee@example.net")]);
|
||||
assert_eq!(mail.date, Some(1_792_391_400));
|
||||
assert_eq!(mail.message_id.as_deref(), Some("m1@example.com"));
|
||||
assert_eq!(mail.in_reply_to, ["m0@example.com"]);
|
||||
assert_eq!(mail.references, ["r1@example.com", "m0@example.com"]);
|
||||
assert_eq!(mail.text.as_deref(), Some("Café numbers attached."));
|
||||
assert_eq!(
|
||||
mail.html.as_deref(),
|
||||
Some(r#"<p>Café <img src="cid:logo@example.com"></p>"#)
|
||||
);
|
||||
assert_eq!(
|
||||
mail.inline,
|
||||
[Part {
|
||||
name: None,
|
||||
content_type: "image/png".into(),
|
||||
params: Vec::new(),
|
||||
content_id: Some("logo@example.com".into()),
|
||||
bytes: b"\x89PNG\r\n\x1a\n".to_vec(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
mail.attachments,
|
||||
[Part {
|
||||
name: Some("report.pdf".into()),
|
||||
content_type: "application/pdf".into(),
|
||||
params: Vec::new(),
|
||||
content_id: Some("att@example.com".into()),
|
||||
bytes: b"%PDF-1.4\n".to_vec(),
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
mail.headers
|
||||
.starts_with("Return-Path: <zhang@example.com>\r\n")
|
||||
);
|
||||
assert!(mail.headers.contains("X-Custom: kept as sent\r\n"));
|
||||
assert!(!mail.headers.contains("--mix"));
|
||||
}
|
||||
|
||||
fn reply_to(mail: &Mail) -> Draft {
|
||||
let every_byte: Vec<u8> = (0..=255).collect();
|
||||
let mut attachments = mail.attachments.clone();
|
||||
attachments.push(Part {
|
||||
name: Some("bytes.bin".into()),
|
||||
content_type: "application/octet-stream".into(),
|
||||
params: Vec::new(),
|
||||
content_id: None,
|
||||
bytes: every_byte,
|
||||
});
|
||||
Draft {
|
||||
from: mail.from[0].clone(),
|
||||
to: mail.to.clone(),
|
||||
cc: mail.cc.clone(),
|
||||
subject: format!("Re: {}", mail.subject.as_deref().unwrap()),
|
||||
text: mail.text.clone().unwrap(),
|
||||
html: mail.html.clone(),
|
||||
attachments,
|
||||
in_reply_to: mail.message_id.clone(),
|
||||
references: mail.references.clone(),
|
||||
date: 1_792_400_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_built_from_the_sample_reads_back_the_same() {
|
||||
let mail = read(&sample()).unwrap();
|
||||
let draft = reply_to(&mail);
|
||||
let built = build(&draft).unwrap();
|
||||
let back = read(&built.bytes).unwrap();
|
||||
assert_eq!(back.subject.as_deref(), Some("Re: Café report"));
|
||||
assert_eq!(back.from, std::slice::from_ref(&draft.from));
|
||||
assert_eq!(back.to, draft.to);
|
||||
assert_eq!(back.cc, draft.cc);
|
||||
assert_eq!(back.date, Some(draft.date));
|
||||
assert_eq!(back.text, Some(draft.text.clone()));
|
||||
assert_eq!(back.html, draft.html);
|
||||
assert_eq!(back.message_id.as_deref(), Some(built.message_id.as_str()));
|
||||
assert!(
|
||||
built.message_id.ends_with("@example.com"),
|
||||
"{}",
|
||||
built.message_id
|
||||
);
|
||||
assert_eq!(back.in_reply_to, ["m1@example.com"]);
|
||||
assert_eq!(back.references, ["r1@example.com", "m0@example.com"]);
|
||||
assert_eq!(back.attachments, draft.attachments);
|
||||
let bcc = |line: &str| line.to_ascii_lowercase().starts_with("bcc:");
|
||||
assert!(!back.headers.lines().any(bcc), "{}", back.headers);
|
||||
let again = build(&draft).unwrap();
|
||||
assert_ne!(again.message_id, built.message_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_are_only_what_the_sender_wrote() {
|
||||
let html = read(b"Content-Type: text/html\r\n\r\n<p>only html</p>").unwrap();
|
||||
assert_eq!(html.text, None);
|
||||
assert_eq!(html.html.as_deref(), Some("<p>only html</p>"));
|
||||
|
||||
let mut draft = reply_to(&read(&sample()).unwrap());
|
||||
draft.html = None;
|
||||
let plain = read(&build(&draft).unwrap().bytes).unwrap();
|
||||
assert_eq!(plain.html, None);
|
||||
assert_eq!(plain.text.as_deref(), Some("Café numbers attached."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_break_in_a_header_never_leaves() {
|
||||
let base = reply_to(&read(&sample()).unwrap());
|
||||
let mut subject = base.clone();
|
||||
subject.subject = "Hi\r\nBcc: hidden@example.com".into();
|
||||
let mut name = base.clone();
|
||||
name.to[0].name = Some("Bo\nBcc: hidden@example.com".into());
|
||||
let mut file = base.clone();
|
||||
file.attachments[0].name = Some("a.pdf\r\nX-Evil: 1".into());
|
||||
let mut email = base.clone();
|
||||
email.cc[0].email = "dee@example.net>\r\nBcc: <hidden@example.com".into();
|
||||
let mut nameless = base.clone();
|
||||
nameless.attachments[0].name = None;
|
||||
let mut cid = base.clone();
|
||||
cid.attachments[0].content_id = Some("a@b>\r\nX-Evil: 1".into());
|
||||
for draft in [subject, name, file, email, nameless, cid] {
|
||||
let err = build(&draft).err().unwrap();
|
||||
assert!(matches!(err, Error::Invalid(_)), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_no_message_are_an_error() {
|
||||
let err = read(b"").unwrap_err();
|
||||
assert!(matches!(err, Error::Mime(_)), "{err}");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use ely_mail_engine::{Error, read};
|
||||
|
||||
/// `levels` messages, each enclosing the next.
|
||||
fn enclosed(levels: usize) -> Vec<u8> {
|
||||
let mut mail = b"Subject: core\r\n\r\nhi\r\n".to_vec();
|
||||
for _ in 0..levels {
|
||||
let mut outer = b"Content-Type: message/rfc822\r\n\r\n".to_vec();
|
||||
outer.extend_from_slice(&mail);
|
||||
mail = outer;
|
||||
}
|
||||
mail
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosed_messages_count_toward_the_depth() {
|
||||
let err = read(&enclosed(128)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Mime(text) if text.contains("64")),
|
||||
"{err}"
|
||||
);
|
||||
let shallow = read(&enclosed(8)).unwrap();
|
||||
assert_eq!(shallow.attachments.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_deep_enclosure_is_refused_without_a_crash() {
|
||||
let err = read(&enclosed(8192)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Mime(text) if text.contains("64")),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_message_is_its_own_bytes() {
|
||||
let inner = "Subject: inner\r\nContent-Type: text/plain\r\n\r\nhello";
|
||||
let raw = format!(
|
||||
"Subject: outer\r\nContent-Type: multipart/mixed; boundary=b\r\n\r\n\
|
||||
--b\r\nContent-Type: text/plain\r\n\r\nbody\r\n\
|
||||
--b\r\nContent-Type: message/rfc822\r\n\r\n{inner}\r\n--b--\r\n"
|
||||
);
|
||||
let mail = read(raw.as_bytes()).unwrap();
|
||||
let [message] = mail.attachments.as_slice() else {
|
||||
panic!("{:?}", mail.attachments)
|
||||
};
|
||||
assert_eq!(message.bytes, inner.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multipart_with_a_content_id_is_a_cid_target() {
|
||||
let raw = "Content-Type: multipart/mixed; boundary=m\r\n\r\n\
|
||||
--m\r\nContent-Type: text/html\r\n\r\n<a href=\"cid:compound@example.com\">x</a>\r\n\
|
||||
--m\r\nContent-Type: multipart/alternative; boundary=a\r\n\
|
||||
Content-ID: <compound@example.com>\r\n\
|
||||
Content-Disposition: inline; filename=compound.mime\r\n\r\n\
|
||||
--a\r\nContent-Type: text/plain\r\n\r\ninner\r\n--a--\r\n--m--\r\n";
|
||||
let mail = read(raw.as_bytes()).unwrap();
|
||||
let [target] = mail.inline.as_slice() else {
|
||||
panic!("{:?}", mail.inline)
|
||||
};
|
||||
assert_eq!(target.content_id.as_deref(), Some("compound@example.com"));
|
||||
assert_eq!(target.content_type, "multipart/alternative");
|
||||
assert_eq!(target.name.as_deref(), Some("compound.mime"));
|
||||
assert!(
|
||||
target
|
||||
.bytes
|
||||
.starts_with(b"Content-Type: multipart/alternative")
|
||||
);
|
||||
assert!(target.bytes.ends_with(b"--a--"), "{:?}", target.bytes);
|
||||
assert!(mail.attachments.is_empty(), "{:?}", mail.attachments);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use ely_mail_engine::{Error, read};
|
||||
|
||||
/// Lines joined with CRLF, as servers send them.
|
||||
fn crlf(lines: &[&str]) -> Vec<u8> {
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// `levels` multipart/mixed containers around one text part.
|
||||
fn nested(levels: usize) -> Vec<u8> {
|
||||
let mut lines = Vec::new();
|
||||
for level in 0..levels {
|
||||
lines.push(format!("Content-Type: multipart/mixed; boundary=b{level}"));
|
||||
lines.push(String::new());
|
||||
lines.push(format!("--b{level}"));
|
||||
}
|
||||
lines.push("Content-Type: text/plain".into());
|
||||
lines.push(String::new());
|
||||
lines.push("deep".into());
|
||||
for level in (0..levels).rev() {
|
||||
lines.push(format!("--b{level}--"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_an_error_not_a_crash() {
|
||||
let err = read(&nested(2048)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Mime(text) if text.contains("deeper than 64")),
|
||||
"{err}"
|
||||
);
|
||||
let mail = read(&nested(63)).unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("deep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_charsets_decode() {
|
||||
let mail = read(&crlf(&[
|
||||
"Subject: =?GB2312?B?1tDOxA==?=",
|
||||
"Content-Type: text/plain; charset=gb2312",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"1tDOxA==",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.subject.as_deref(), Some("中文"));
|
||||
assert_eq!(mail.text.as_deref(), Some("中文"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_root_marked_attachment_is_still_the_body() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r; type=\"text/html\"",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"Content-Disposition: attachment; filename=body.html",
|
||||
"",
|
||||
"<p>root</p>",
|
||||
"--r",
|
||||
"Content-Type: image/png",
|
||||
"Content-ID: <img@example.com>",
|
||||
"",
|
||||
"png",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.html.as_deref(), Some("<p>root</p>"));
|
||||
assert!(mail.attachments.is_empty());
|
||||
let ids: Vec<_> = mail
|
||||
.inline
|
||||
.iter()
|
||||
.map(|p| p.content_id.as_deref())
|
||||
.collect();
|
||||
assert_eq!(ids, [Some("img@example.com")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_composite_related_resource_keeps_its_entity() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r; type=\"text/html\"",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"",
|
||||
"<p>root</p>",
|
||||
"--r",
|
||||
"Content-Type: multipart/alternative; boundary=a",
|
||||
"Content-ID: <entity@example.com>",
|
||||
"Content-Disposition: inline; filename=entity.mime",
|
||||
"",
|
||||
"--a",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"inner",
|
||||
"--a--",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.html.as_deref(), Some("<p>root</p>"));
|
||||
assert!(mail.attachments.is_empty());
|
||||
let [entity] = mail.inline.as_slice() else {
|
||||
panic!("{:?}", mail.inline)
|
||||
};
|
||||
assert_eq!(entity.content_id.as_deref(), Some("entity@example.com"));
|
||||
assert_eq!(entity.name.as_deref(), Some("entity.mime"));
|
||||
assert_eq!(entity.content_type, "multipart/alternative");
|
||||
let bytes = String::from_utf8(entity.bytes.clone()).unwrap();
|
||||
assert!(
|
||||
bytes.starts_with("Content-Type: multipart/alternative"),
|
||||
"{bytes}"
|
||||
);
|
||||
assert!(bytes.contains("inner"), "{bytes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_part_with_a_content_id_is_a_cid_target() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"Content-ID: <frame@example.com>",
|
||||
"Content-Disposition: inline",
|
||||
"",
|
||||
"<p>frame</p>",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
let [frame] = mail.inline.as_slice() else {
|
||||
panic!("{:?}", mail.inline)
|
||||
};
|
||||
assert_eq!(frame.content_id.as_deref(), Some("frame@example.com"));
|
||||
assert_eq!(frame.content_type, "text/html");
|
||||
assert_eq!(frame.params, [("charset".to_owned(), "utf-8".to_owned())]);
|
||||
assert_eq!(frame.bytes, b"<p>frame</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_disposition_is_an_attachment() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"Content-Disposition: x-file; filename=file.txt",
|
||||
"",
|
||||
"private attachment",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
let [file] = mail.attachments.as_slice() else {
|
||||
panic!("{:?}", mail.attachments)
|
||||
};
|
||||
assert_eq!(file.name.as_deref(), Some("file.txt"));
|
||||
assert_eq!(file.bytes, b"private attachment");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use ely_mail_engine::{Draft, Mail, Mailbox, build, read};
|
||||
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||
|
||||
/// Lines joined with CRLF, as servers send them.
|
||||
fn crlf(lines: &[&str]) -> Vec<u8> {
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// A new message carrying `mail`'s attachments.
|
||||
fn forward(mail: &Mail) -> Vec<u8> {
|
||||
let draft = Draft {
|
||||
from: Mailbox {
|
||||
name: None,
|
||||
email: "a@example.com".into(),
|
||||
},
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: "fwd".into(),
|
||||
text: "see attached".into(),
|
||||
html: None,
|
||||
attachments: mail.attachments.clone(),
|
||||
in_reply_to: None,
|
||||
references: Vec::new(),
|
||||
date: 0,
|
||||
};
|
||||
build(&draft).unwrap().bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_text_attachment_keeps_its_charset() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Content-Disposition: attachment; filename=cafe.txt",
|
||||
"",
|
||||
"Caf=E9",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
let sent = forward(&mail);
|
||||
let message = MessageParser::default().parse(&sent).unwrap();
|
||||
let file = message.attachment(0).unwrap();
|
||||
assert_eq!(file.text_contents(), Some("Café"));
|
||||
assert_eq!(read(&sent).unwrap().attachments, mail.attachments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_multipart_stays_a_multipart() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: multipart/alternative; boundary=a",
|
||||
"Content-Disposition: attachment; filename=alt.mime",
|
||||
"",
|
||||
"--a",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"alt plain",
|
||||
"--a",
|
||||
"Content-Type: text/html",
|
||||
"",
|
||||
"<p>alt html</p>",
|
||||
"--a--",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
let sent = forward(&mail);
|
||||
let message = MessageParser::default().parse(&sent).unwrap();
|
||||
let alt = message
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| part.content_type().and_then(|t| t.subtype()) == Some("alternative"))
|
||||
.unwrap();
|
||||
assert_eq!(alt.attachment_name(), Some("alt.mime"));
|
||||
assert_eq!(alt.content_transfer_encoding(), Some("7bit"));
|
||||
let PartType::Multipart(children) = &alt.body else {
|
||||
panic!("{:?}", alt.body)
|
||||
};
|
||||
let texts: Vec<_> = children
|
||||
.iter()
|
||||
.map(|&id| message.parts[id as usize].text_contents())
|
||||
.collect();
|
||||
assert_eq!(texts, [Some("alt plain"), Some("<p>alt html</p>")]);
|
||||
|
||||
let back = read(&sent).unwrap();
|
||||
assert_eq!(back.text.as_deref(), Some("see attached"));
|
||||
let [file] = back.attachments.as_slice() else {
|
||||
panic!("{:?}", back.attachments)
|
||||
};
|
||||
assert_eq!(file.content_type, "multipart/alternative");
|
||||
assert_eq!(file.params, mail.attachments[0].params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_message_stays_unencoded() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: message/rfc822",
|
||||
"Content-Disposition: attachment; filename=old.eml",
|
||||
"",
|
||||
"Subject: old",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
"您好",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
let sent = forward(&mail);
|
||||
let message = MessageParser::default().parse(&sent).unwrap();
|
||||
let old = message
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| matches!(part.body, PartType::Message(_)))
|
||||
.unwrap();
|
||||
assert_eq!(old.content_transfer_encoding(), Some("8bit"));
|
||||
let PartType::Message(inner) = &old.body else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(inner.subject(), Some("old"));
|
||||
assert_eq!(inner.body_text(0).as_deref(), Some("您好"));
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use ely_mail_engine::{Draft, Mailbox, Part, build, read};
|
||||
|
||||
/// Lines joined with CRLF, as servers send them.
|
||||
fn crlf(lines: &[&str]) -> Vec<u8> {
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
fn part(name: Option<&str>, kind: &str, id: Option<&str>, bytes: &[u8]) -> Part {
|
||||
Part {
|
||||
name: name.map(str::to_owned),
|
||||
content_type: kind.to_owned(),
|
||||
params: Vec::new(),
|
||||
content_id: id.map(str::to_owned),
|
||||
bytes: bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_attachments_keep_their_own_bytes() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Content-Disposition: attachment; filename=qp.txt",
|
||||
"",
|
||||
"Caf=E9",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Disposition: attachment; filename=raw.txt",
|
||||
"",
|
||||
"Caf\u{e9}",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"Content-Disposition: attachment; filename=b64.txt",
|
||||
"",
|
||||
"Q2Fm6Q==",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
let bytes: Vec<&[u8]> = mail
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|p| p.bytes.as_slice())
|
||||
.collect();
|
||||
assert_eq!(bytes, [b"Caf\xe9", "Caf\u{e9}".as_bytes(), b"Caf\xe9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_built_latin1_text_attachment_reads_back_as_built() {
|
||||
let latin1 = part(Some("cafe.txt"), "text/plain", None, b"Caf\xe9\r\n");
|
||||
let draft = Draft {
|
||||
from: Mailbox {
|
||||
name: None,
|
||||
email: "a@example.com".into(),
|
||||
},
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: "s".into(),
|
||||
text: "t".into(),
|
||||
html: None,
|
||||
attachments: vec![latin1.clone()],
|
||||
in_reply_to: None,
|
||||
references: Vec::new(),
|
||||
date: 0,
|
||||
};
|
||||
let back = read(&build(&draft).unwrap().bytes).unwrap();
|
||||
assert_eq!(back.attachments, [latin1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_root_named_by_start_can_come_after_its_image() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r; type=\"text/html\"; start=\"<root@example.com>\"",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: image/png",
|
||||
"Content-ID: <img@example.com>",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"iVBORw==",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"Content-ID: <root@example.com>",
|
||||
"",
|
||||
"<img src=\"cid:img@example.com\">",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mail.html.as_deref(),
|
||||
Some("<img src=\"cid:img@example.com\">")
|
||||
);
|
||||
assert_eq!(
|
||||
mail.inline,
|
||||
[part(None, "image/png", Some("img@example.com"), b"\x89PNG")]
|
||||
);
|
||||
assert!(mail.attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_image_marked_attachment_is_still_a_cid_target() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"",
|
||||
"<img src=\"cid:img@example.com\">",
|
||||
"--r",
|
||||
"Content-Type: image/png",
|
||||
"Content-ID: <img@example.com>",
|
||||
"Content-Disposition: attachment; filename=img.png",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"iVBORw==",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mail.inline,
|
||||
[part(
|
||||
Some("img.png"),
|
||||
"image/png",
|
||||
Some("img@example.com"),
|
||||
b"\x89PNG"
|
||||
)]
|
||||
);
|
||||
assert!(mail.attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_multipart_is_one_attachment_and_no_body() {
|
||||
let entity = [
|
||||
"Content-Type: multipart/alternative; boundary=in",
|
||||
"Content-Disposition: attachment; filename=alternatives.mime",
|
||||
"",
|
||||
"--in",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"private attachment",
|
||||
"--in--",
|
||||
];
|
||||
let mut lines = vec![
|
||||
"Content-Type: multipart/mixed; boundary=out",
|
||||
"",
|
||||
"--out",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--out",
|
||||
];
|
||||
lines.extend(entity);
|
||||
lines.extend(["--out--", ""]);
|
||||
let mail = read(&crlf(&lines)).unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
assert_eq!(mail.attachments.len(), 1);
|
||||
let attached = &mail.attachments[0];
|
||||
assert_eq!(attached.name.as_deref(), Some("alternatives.mime"));
|
||||
assert_eq!(attached.content_type, "multipart/alternative");
|
||||
assert_eq!(attached.bytes, crlf(&entity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternatives_of_one_type_are_chosen_not_joined() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/alternative; boundary=a",
|
||||
"",
|
||||
"--a",
|
||||
"Content-Type: text/plain; charset=us-ascii",
|
||||
"",
|
||||
"Cafe",
|
||||
"--a",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"",
|
||||
"Café",
|
||||
"--a",
|
||||
"Content-Type: text/calendar",
|
||||
"",
|
||||
"BEGIN:VCALENDAR",
|
||||
"--a--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("Café"));
|
||||
assert_eq!(
|
||||
mail.attachments,
|
||||
[part(None, "text/calendar", None, b"BEGIN:VCALENDAR")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_child_without_a_type_is_a_message() {
|
||||
let enclosed = "From: x@example.com\r\nSubject: inner\r\n\r\nhello";
|
||||
let mut lines = vec!["Content-Type: multipart/digest; boundary=d", "", "--d", ""];
|
||||
lines.push(enclosed);
|
||||
lines.extend(["--d--", ""]);
|
||||
let mail = read(&crlf(&lines)).unwrap();
|
||||
assert_eq!(mail.attachments.len(), 1);
|
||||
assert_eq!(mail.attachments[0].content_type, "message/rfc822");
|
||||
let inner = read(&mail.attachments[0].bytes).unwrap();
|
||||
assert_eq!(inner.subject.as_deref(), Some("inner"));
|
||||
}
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user