32 Commits
Author SHA1 Message Date
雷电芽衣 5843fb4b1f 0.1.9
release / macos (push) Canceled after 0s
release / windows (push) Canceled after 0s
release / linux (push) Canceled after 0s
2026-10-20 03:14:58 -04:00
雷电芽衣 ff317dd863 R9.5: merge PR #6, ElyTable completion keys and overlay clicks
Enter and Tab are resolved by the worker against the kernel's current
candidates, after the typing queued before them. The dropdown arrow's
mouse blocker is at least as tall as the button.
2026-10-20 03:14:24 -04:00
雷电芽衣 72bec20a60 R9.3, R9.4: AutoFilter without the header query (GitHub #4); paste on Windows
Patch 0053: under LOK, Calc takes the first row as header instead of
asking asynchronously; the worker closed the query and crashed.
Patch 0049 includes doc.hxx (gcc on Linux did not build it).
arboard read a missing HTML clipboard format as an error on Windows;
the clipboard checks the format first, and paste errors show the
whole chain. ElyTable tests compare CSV and selection text the
platform's way (P3-09).
2026-10-20 02:23:17 -04:00
雷电芽衣 ba358c7de6 C12.9: three-pane mailbox view, filters, virtualized list; ids never reused 2026-10-20 00:25:57 -04:00
Jetkwok c5cb29fa34 Guard completion callbacks and cancel occluded grid drags 2026-10-20 11:42:13 +08:00
Jetkwok cc0f5a9caf Fix ElyTable completion keys and overlay click-through 2026-10-20 11:18:13 +08:00
雷电芽衣 7f70ab6df7 R9.1: ElyShow master view reads each master's own part info (GitHub #2) 2026-10-19 23:05:29 -04:00
雷电芽衣 6c972163b1 Merge branch 'main' of https://github.com/ELY-Open-Source/ElyOffice 2026-10-19 22:55:13 -04:00
雷电芽衣 bddf765f77 Merge pull request #1 from OmarGonD/fix/readable-text-selection
[BUG REPORT] Keep selected document text readable
2026-10-19 22:54:48 -04:00
雷电芽衣 22d913d45e C12.8: sync now, timed sync, pause, offline folders 2026-10-19 22:29:27 -04:00
雷电芽衣 4d0d64ff54 C12.7: POP3 fetch by UIDL 2026-10-19 21:40:25 -04:00
OmarGonD e42f580510 Keep selected document text readable 2026-10-19 20:32:44 -05:00
雷电芽衣 d4c59706d8 Issue templates: [FEATURE REQUEST], [BUG REPORT] and [Other] 2026-10-19 21:21:27 -04:00
雷电芽衣 890bd80d4d C12.6: IMAP sync 2026-10-19 21:21:27 -04:00
雷电芽衣 5c5ebf5e93 PR template: titles start with [FEATURE REQUEST], [BUG REPORT] or [Other] 2026-10-19 21:18:20 -04:00
雷电芽衣 1c225f6812 C12.5: mail accounts, add and remove
ElyMail's window lists accounts and adds Gmail, Microsoft, iCloud
(app-specific password), IMAP and POP3 ones; a check logs in to both
servers and reports each. Accounts live in the store as JSON with a
case-insensitive unique address; secrets go to the keychain. Removal
names the cache it deletes, takes the secret first and puts it back
when local removal fails. Originals are set aside until commit and
restored after a crash. Requests are numbered so late results stay
with their own dialog. The account list is keyboard-navigable.
2026-10-19 20:17:19 -04:00
ZacharyZhang-NY 5068bc07ca Merge branch 'main' of https://github.com/ELY-Open-Source/ElyOffice
# Conflicts:
#	agents.md
#	scripts/fetch-references.sh
2026-10-19 19:53:11 -04:00
ZacharyZhang-NYandClaude 0e0415fdbe Site: the download count adds Gitea's history to GitHub's
Gitea holds the downloads made before the move, so the worker sums
both hosts' asset download counts (update manifests excluded). Gitea is
read with the same five-minute cache and a five-second limit; when it
is down or answers oddly, the count is GitHub's alone. Downloads stay
on GitHub only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:37:59 -04:00
ZacharyZhang-NYandClaude 271468d0e5 Relicense Ely Office's own code from AGPL-3.0 to MIT
LICENSE is the MIT text, copyright Yang Zhang; the workspace crates,
the docs and the site say MIT. Bundled engines keep their licences:
LibreOffice (MPL-2.0), Git (GPL-2.0) and MuPDF (AGPL-3.0), which ElyPDF
links, so the Linux packages name both, e.g. "MIT AND AGPL-3.0-or-later".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:31:09 -04:00
ZacharyZhang-NYandClaude 1d74bbc417 Updates and the component library come from GitHub
The update check reads the latest release from GitHub's API; its JSON
has the same tag_name and assets[].browser_download_url the reader
expects. Ely-GPUI-Components is fetched from ELY-Open-Source at the same
pinned commits.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:22:39 -04:00
ZacharyZhang-NYandClaude a06c9b299d Docs: the code lives on GitHub under ELY-Open-Source
CONTRIBUTING opens pull requests from a GitHub fork; the Linux
packages' homepage is the GitHub repository; agents.md records the move.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:22:12 -04:00
ZacharyZhang-NYandClaude 6b28c54e33 Site: releases and source on GitHub; the Mac build is notarized
The worker reads the newest release and download counts from GitHub's
API, with an optional GITHUB_TOKEN secret for the rate limit. Download
links go to GitHub's latest release, and the source link wears the
GitHub mark. The "not notarized" notice goes: macOS builds are signed
and notarized now.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:21:30 -04:00
ZacharyZhang-NYandClaude 41321fd05f CI: release from GitHub Actions to GitHub releases
The release workflow moves from .gitea to .github: macOS on the
macos-signing runner, Windows and Linux (WSL) on ely-windows. The
persistent clones fetch from GitHub. ci.sh creates or reuses the tag's
release through GitHub's API and replaces a rerun's assets of the same
name; the update manifests point at GitHub's download URLs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 19:19:04 -04:00
ZacharyZhang-NYandClaude bd68382921 C12.4: mail credentials and OAuth sign-in
Passwords and refresh tokens go only to the system keychain; a refused
keychain keeps them in memory until exit and says why. Gmail and
Microsoft sign in by authorization code with S256 PKCE on a loopback
redirect; refresh tokens are kept, and revoked grants ask to sign in
again. Client IDs come from the build (env or oauth.env). ureq trace
records never reach the log.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 18:59:56 -04:00
ZacharyZhang-NYandClaude Opus 5.5 7c5bf48589 C12.3: ElyMail local store
SQLite index, content-addressed originals, rebuildable FTS5 search,
schema versions with a backup before each upgrade and read-only on
failure, and size reporting. Three review rounds; the round 3 fixes
are checked by tests, not reviewed again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 18:14:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 fe90221c11 C12.2: read and write MIME by the tree
The reader walks the MIME tree with its container context: alternative
picks versions, related finds its root by start, attached multiparts
stay whole, digest children are messages. Nesting deeper than 64
levels, counting enclosed messages, is refused, and over-deep parse
results are taken apart iteratively so dropping them cannot overflow
the stack. Composite parts with a Content-ID are cid targets.

The writer keeps attachment parameters and Content-IDs, writes
multipart and message attachments unencoded, and makes its own
Message-ID. SMTP adds BODY=8BITMIME for 8-bit bodies and SMTPUTF8
when any header block at any depth is 8-bit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 17:35:05 -04:00
ZacharyZhang-NYandClaude Opus 5.5 9f47b94714 C12.1: ely-mail-engine connects over IMAP, POP3 and SMTP
TLS or STARTTLS with the system trust store; password and XOAUTH2
logins keep the server's words on refusal. IMAP and POP3 are written
in the engine (imap-proto parses IMAP replies): async-imap dropped
NO/BAD on LIST and FETCH and lost BYE text, async-pop kept dot
stuffing and logged passwords. SMTP runs its own transaction with
RFC 5321 mailbox checks, full dot transparency, SMTPUTF8 and RSET.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:53:53 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d7de5619a7 C12.0: ElyMail backend moves to Rust crates; iCloud by app password
async-imap, async-pop, mail-send, mail-parser and mail-builder replace
MailCore 2. The MailCore 2 and libetpan fetch, build and probe scripts
go. HTML bodies render as a sanitized block subset in GPUI; compose uses
the Markdown document component. OAuth client IDs come from oauth.env
(ignored) or CI secrets. The task list for C12 is rewritten in order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:02:34 -04:00
ZacharyZhang-NYandClaude 0d991e1c71 R8.5, R8.6: 0.1.8 released; #8 and #9 closed
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 02:06:20 -04:00
ZacharyZhang-NYandClaude 0dfb4bb720 CI: macOS job runs on the GUI-session signing runner
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-19 02:05:55 -04:00
ZacharyZhang-NY c8d7e6f3cc Merge remote-tracking branch 'origin/main' 2026-10-19 00:27:17 -04:00
ZacharyZhang-NY 05b205f629 Update README.md 2026-10-19 04:20:14 +00:00
175 changed files with 15930 additions and 344 deletions

No files matched your search

+63
View File
@@ -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
+5
View File
@@ -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
+11
View File
@@ -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
+35
View File
@@ -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
View File
@@ -18,4 +18,5 @@ node_modules/
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
/TyporaRef
/TyporaRef
/oauth.env
+5 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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" }
+11 -13
View File
@@ -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/`
+13 -8
View File
@@ -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。
+12
View File
@@ -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
+285
View File
@@ -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)])
}
+262
View File
@@ -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)
}
}
+204
View File
@@ -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,
}
}
+88
View File
@@ -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(),
)
}
}
+289
View File
@@ -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())],
),
}
}
+87
View File
@@ -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,
}
}
+236
View File
@@ -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);
}
}
+18 -1
View File
@@ -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);
}
+20
View File
@@ -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)]),
]
}
+121
View File
@@ -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()
}
}
+97
View File
@@ -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();
}
}
+126
View File
@@ -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,
}
}
+187
View File
@@ -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) => {}
},
);
}
}
+192
View File
@@ -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(),
},
)
}
+338
View File
@@ -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);
});
}
+488
View File
@@ -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);
}
+258
View File
@@ -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:?}"
);
}
+115
View File
@@ -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 });
}
+284
View File
@@ -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()
}
}
}
+1 -1
View File
@@ -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);
}
};
+5
View File
@@ -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(
+6 -3
View File
@@ -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()
+1
View File
@@ -394,6 +394,7 @@ fn a_delete_stays_with_its_workbook() {
}
mod analysis;
mod aids;
mod data;
mod menu;
mod names;
+126
View File
@@ -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"
);
}
+3 -5
View File
@@ -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")
})
}
+5 -3
View File
@@ -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));
+20 -6
View File
@@ -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.
+27
View File
@@ -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");
}
+2 -2
View File
@@ -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()"
});
+18
View File
@@ -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() {
+1 -1
View File
@@ -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::{
+13
View File
@@ -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 {
+3
View File
@@ -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;
+1 -1
View File
@@ -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);
+37 -17
View File
@@ -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());
}
}
+28 -8
View File
@@ -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(
+4 -1
View File
@@ -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());
+3 -1
View File
@@ -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());
+1
View File
@@ -4,6 +4,7 @@ mod borders;
mod clip;
mod commands;
mod fit;
mod formula;
mod input;
mod large;
mod show_borders;
+272
View File
@@ -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");
});
}
+6
View File
@@ -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]))
+87
View File
@@ -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 {
+2 -7
View File
@@ -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))
+43
View File
@@ -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())
}
+46
View File
@@ -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
+39
View File
@@ -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());
}
}
}
+126
View File
@@ -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",
}
}
+74
View File
@@ -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}"))
}
}
+458
View File
@@ -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");
}
}
+197
View File
@@ -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))
}
+290
View File
@@ -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());
}
+28
View File
@@ -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};
+275
View File
@@ -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"))
}
+267
View File
@@ -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"))
})
}
+85
View File
@@ -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)
}
+277
View File
@@ -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}");
}
}
+110
View File
@@ -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))
}
+213
View File
@@ -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,
}
}
+288
View File
@@ -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, &params).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()),
}
}
+235
View File
@@ -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<_>>()?)
}
}
+139
View File
@@ -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(())
}
+270
View File
@@ -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)";
+289
View File
@@ -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, .. }
)
}
}
+111
View File
@@ -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()
}
+126
View File
@@ -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;
";
+134
View File
@@ -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)
}
+179
View File
@@ -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));
}
+291
View File
@@ -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");
}
}
+108
View File
@@ -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")
}
}
+108
View File
@@ -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()
}
+200
View File
@@ -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}");
}
}
+73
View File
@@ -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);
}
+187
View File
@@ -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")),
}
}
+45
View File
@@ -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"),
]
}
+428
View File
@@ -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}"
);
}
+162
View File
@@ -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&eacute; <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}");
}
+71
View File
@@ -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);
}
+173
View File
@@ -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("您好"));
}
+213
View File
@@ -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