Rewrite
This commit is contained in:
parent
c3f2037668
commit
2893b22d03
71 changed files with 1506 additions and 14801 deletions
82
.github/workflows/ci.yml
vendored
82
.github/workflows/ci.yml
vendored
|
@ -1,82 +0,0 @@
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
name: workflow
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
workflow:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Setup
|
|
||||||
|
|
||||||
- name: checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: rust
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
|
|
||||||
# Retrieve the cache if there is one
|
|
||||||
|
|
||||||
- uses: actions/cache@v2
|
|
||||||
id: cache
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
~/.cargo/bin
|
|
||||||
target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
|
|
||||||
# Install shit
|
|
||||||
|
|
||||||
- name: mdbook
|
|
||||||
uses: peaceiris/actions-mdbook@v1
|
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
|
||||||
with:
|
|
||||||
mdbook-version: 'latest'
|
|
||||||
|
|
||||||
- name: install-cargo-deb
|
|
||||||
if: steps.cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
cargo install mdbook --version 0.4.7
|
|
||||||
cargo install cargo-deb --version 1.29.1
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
|
|
||||||
- name: run tests
|
|
||||||
run: |
|
|
||||||
cargo test --all
|
|
||||||
|
|
||||||
# Generate documentation
|
|
||||||
|
|
||||||
- name: build mdbook
|
|
||||||
run: |
|
|
||||||
mdbook build docs -d $(pwd)/public
|
|
||||||
|
|
||||||
- name: build api docs
|
|
||||||
run: |
|
|
||||||
cargo doc --workspace --no-deps --document-private-items
|
|
||||||
cp -r target/doc public/api
|
|
||||||
|
|
||||||
- name: deploy
|
|
||||||
uses: JamesIves/github-pages-deploy-action@4.0.0
|
|
||||||
with:
|
|
||||||
branch: gh-pages
|
|
||||||
folder: public
|
|
||||||
|
|
||||||
# Build debian package
|
|
||||||
|
|
||||||
- name: build-deb
|
|
||||||
run: |
|
|
||||||
cargo deb
|
|
||||||
- uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: panorama.deb
|
|
||||||
path: target/debian/panorama*.deb
|
|
||||||
|
|
||||||
# vim: set sw=2 et :
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -5,3 +5,4 @@
|
||||||
/public
|
/public
|
||||||
/hellosu
|
/hellosu
|
||||||
/hellosu.db*
|
/hellosu.db*
|
||||||
|
|
||||||
|
|
3677
Cargo.lock
generated
3677
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
64
Cargo.toml
64
Cargo.toml
|
@ -1,65 +1,5 @@
|
||||||
[package]
|
|
||||||
name = "panorama"
|
|
||||||
version = "0.0.1"
|
|
||||||
authors = ["Michael Zhang <mail@mzhang.io>"]
|
|
||||||
edition = "2018"
|
|
||||||
description = "A customizable personal information manager."
|
|
||||||
repository = "https://git.mzhang.io/michael/panorama"
|
|
||||||
readme = "README.md"
|
|
||||||
license = "GPL-3.0-or-later"
|
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"imap",
|
"panorama-gui",
|
||||||
"smtp",
|
"panorama-core",
|
||||||
"tui",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# tantivy = "0.14.0"
|
|
||||||
anyhow = "1.0.41"
|
|
||||||
async-trait = "0.1.50"
|
|
||||||
cfg-if = "1.0.0"
|
|
||||||
chrono = "0.4.19"
|
|
||||||
chrono-humanize = "0.2.1"
|
|
||||||
downcast-rs = "1.2.0"
|
|
||||||
fern = { version = "0.6.0", features = ["colored"] }
|
|
||||||
format-bytes = "0.2.2"
|
|
||||||
futures = "0.3.15"
|
|
||||||
gluon = "0.17.2"
|
|
||||||
hex = "0.4.3"
|
|
||||||
inotify = { version = "0.9.3", features = ["stream"] }
|
|
||||||
log = "0.4.14"
|
|
||||||
mailparse = "0.13.4"
|
|
||||||
notify-rust = { version = "4.5.2", default-features = false, features = ["z"] }
|
|
||||||
panorama-tui = { path = "tui" }
|
|
||||||
parking_lot = "0.11.1"
|
|
||||||
quoted_printable = "0.4.3"
|
|
||||||
serde = { version = "1.0.126", features = ["derive"] }
|
|
||||||
sha2 = "0.9.5"
|
|
||||||
shellexpand = "2.1.0"
|
|
||||||
sqlx = { version = "0.5.5", features = ["runtime-tokio-rustls", "sqlite"] }
|
|
||||||
structopt = "0.3.21"
|
|
||||||
tokio = { version = "1.7.1", features = ["full"] }
|
|
||||||
tokio-rustls = "0.22.0"
|
|
||||||
tokio-stream = { version = "0.1.6", features = ["sync"] }
|
|
||||||
tokio-util = { version = "0.6.7", features = ["full"] }
|
|
||||||
toml = { version = "0.5.8", features = ["preserve_order"] }
|
|
||||||
webpki-roots = "0.22.0"
|
|
||||||
xdg = "2.2.0"
|
|
||||||
indexmap = "1.6.2"
|
|
||||||
|
|
||||||
[dependencies.panorama-imap]
|
|
||||||
path = "imap"
|
|
||||||
version = "0"
|
|
||||||
features = ["rfc2177-idle"]
|
|
||||||
|
|
||||||
[dependencies.panorama-smtp]
|
|
||||||
path = "smtp"
|
|
||||||
version = "0"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
clippy = []
|
|
||||||
|
|
||||||
[package.metadata.deb]
|
|
||||||
depends = "$auto"
|
|
||||||
|
|
14
Justfile
14
Justfile
|
@ -1,14 +0,0 @@
|
||||||
doc:
|
|
||||||
cargo doc --document-private-items
|
|
||||||
|
|
||||||
doc-open:
|
|
||||||
cargo doc --document-private-items --open
|
|
||||||
|
|
||||||
watch:
|
|
||||||
cargo watch -x 'clippy --all --all-features'
|
|
||||||
|
|
||||||
run:
|
|
||||||
cargo run -- --log-file output.log
|
|
||||||
|
|
||||||
tail:
|
|
||||||
tail -f output.log
|
|
47
README.md
47
README.md
|
@ -1,47 +0,0 @@
|
||||||
panorama
|
|
||||||
========
|
|
||||||
|
|
||||||
[![](https://tokei.rs/b1/github/iptq/panorama?category=code)](https://github.com/XAMPPRocky/tokei)
|
|
||||||
|
|
||||||
Panorama is a terminal Personal Information Manager (PIM).
|
|
||||||
|
|
||||||
Status: **not done yet**
|
|
||||||
|
|
||||||
Read documentation at [pim.mzhang.io][1]
|
|
||||||
|
|
||||||
Join chat on Matrix at [#panorama:mozilla.org][3]
|
|
||||||
|
|
||||||
Goals:
|
|
||||||
|
|
||||||
- **Never have to actually close the application.** All errors should be
|
|
||||||
handled gracefully in a way that can be recovered or restarted without
|
|
||||||
needing to close the entire application.
|
|
||||||
- **Handles email, calendar, and address books using open standards.** IMAP for
|
|
||||||
email retrieval, SMTP for email sending, CalDAV for calendars, and CardDAV
|
|
||||||
for address books. Work should be saved locally prior to uploading to make
|
|
||||||
sure nothing is ever lost as a result of network failure.
|
|
||||||
- **Hot-reload on-disk config.** Configuration should be able to be reloaded so
|
|
||||||
that the user can keep the application open. Errors in config should be
|
|
||||||
reported to the user while the application is still running off the old
|
|
||||||
version.
|
|
||||||
- **Scriptable.** Built-in scripting language should allow for customization of
|
|
||||||
common functionality, including keybinds and colors.
|
|
||||||
|
|
||||||
Stretch goals:
|
|
||||||
- Full-text email/message search
|
|
||||||
- Unified "feed" that any app can submit to.
|
|
||||||
- Submit notifications to gotify-shaped notification servers.
|
|
||||||
- JMAP implementation.
|
|
||||||
- RSS aggregator.
|
|
||||||
- IRC client??
|
|
||||||
|
|
||||||
Credits
|
|
||||||
-------
|
|
||||||
|
|
||||||
IMAP library modified from [djc/tokio-imap][2], MIT licensed.
|
|
||||||
|
|
||||||
License: GPLv3 or later
|
|
||||||
|
|
||||||
[1]: https://pim.mzhang.io
|
|
||||||
[2]: https://github.com/djc/tokio-imap
|
|
||||||
[3]: https://matrix.to/#/!NSaHPfsflbEkjCZViX:mozilla.org?via=mozilla.org
|
|
177
deny.toml
177
deny.toml
|
@ -1,177 +0,0 @@
|
||||||
# This template contains all of the possible sections and their default values
|
|
||||||
|
|
||||||
# Note that all fields that take a lint level have these possible values:
|
|
||||||
# * deny - An error will be produced and the check will fail
|
|
||||||
# * warn - A warning will be produced, but the check will not fail
|
|
||||||
# * allow - No warning or error will be produced, though in some cases a note
|
|
||||||
# will be
|
|
||||||
|
|
||||||
# The values provided in this template are the default values that will be used
|
|
||||||
# when any section or field is not specified in your own configuration
|
|
||||||
|
|
||||||
# If 1 or more target triples (and optionally, target_features) are specified,
|
|
||||||
# only the specified targets will be checked when running `cargo deny check`.
|
|
||||||
# This means, if a particular package is only ever used as a target specific
|
|
||||||
# dependency, such as, for example, the `nix` crate only being used via the
|
|
||||||
# `target_family = "unix"` configuration, that only having windows targets in
|
|
||||||
# this list would mean the nix crate, as well as any of its exclusive
|
|
||||||
# dependencies not shared by any other crates, would be ignored, as the target
|
|
||||||
# list here is effectively saying which targets you are building for.
|
|
||||||
targets = [
|
|
||||||
{ triple = "x86_64-unknown-linux-gnu" },
|
|
||||||
]
|
|
||||||
|
|
||||||
# This section is considered when running `cargo deny check advisories`
|
|
||||||
# More documentation for the advisories section can be found here:
|
|
||||||
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
|
|
||||||
[advisories]
|
|
||||||
# The path where the advisory database is cloned/fetched into
|
|
||||||
db-path = "~/.cargo/advisory-db"
|
|
||||||
# The url(s) of the advisory databases to use
|
|
||||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
|
||||||
# The lint level for security vulnerabilities
|
|
||||||
vulnerability = "deny"
|
|
||||||
# The lint level for unmaintained crates
|
|
||||||
unmaintained = "warn"
|
|
||||||
# The lint level for crates that have been yanked from their source registry
|
|
||||||
yanked = "warn"
|
|
||||||
# The lint level for crates with security notices. Note that as of
|
|
||||||
# 2019-12-17 there are no security notice advisories in
|
|
||||||
# https://github.com/rustsec/advisory-db
|
|
||||||
notice = "warn"
|
|
||||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
|
||||||
# output a note when they are encountered.
|
|
||||||
ignore = [
|
|
||||||
#"RUSTSEC-0000-0000",
|
|
||||||
]
|
|
||||||
# Threshold for security vulnerabilities, any vulnerability with a CVSS score
|
|
||||||
# lower than the range specified will be ignored. Note that ignored advisories
|
|
||||||
# will still output a note when they are encountered.
|
|
||||||
# * None - CVSS Score 0.0
|
|
||||||
# * Low - CVSS Score 0.1 - 3.9
|
|
||||||
# * Medium - CVSS Score 4.0 - 6.9
|
|
||||||
# * High - CVSS Score 7.0 - 8.9
|
|
||||||
# * Critical - CVSS Score 9.0 - 10.0
|
|
||||||
#severity-threshold =
|
|
||||||
|
|
||||||
# This section is considered when running `cargo deny check licenses`
|
|
||||||
# More documentation for the licenses section can be found here:
|
|
||||||
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
|
|
||||||
[licenses]
|
|
||||||
unlicensed = "deny"
|
|
||||||
allow = [
|
|
||||||
#"MIT",
|
|
||||||
#"Apache-2.0",
|
|
||||||
#"Apache-2.0 WITH LLVM-exception",
|
|
||||||
]
|
|
||||||
deny = [ ]
|
|
||||||
copyleft = "allow"
|
|
||||||
# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses
|
|
||||||
# * both - The license will be approved if it is both OSI-approved *AND* FSF
|
|
||||||
# * either - The license will be approved if it is either OSI-approved *OR* FSF
|
|
||||||
# * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF
|
|
||||||
# * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved
|
|
||||||
# * neither - This predicate is ignored and the default lint level is used
|
|
||||||
allow-osi-fsf-free = "either"
|
|
||||||
# Lint level used when no other predicates are matched
|
|
||||||
# 1. License isn't in the allow or deny lists
|
|
||||||
# 2. License isn't copyleft
|
|
||||||
# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither"
|
|
||||||
default = "deny"
|
|
||||||
# The confidence threshold for detecting a license from license text.
|
|
||||||
# The higher the value, the more closely the license text must be to the
|
|
||||||
# canonical license text of a valid SPDX license file.
|
|
||||||
# [possible values: any between 0.0 and 1.0].
|
|
||||||
confidence-threshold = 0.8
|
|
||||||
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
|
|
||||||
# aren't accepted for every possible crate as with the normal allow list
|
|
||||||
exceptions = [
|
|
||||||
# Each entry is the crate and version constraint, and its specific allow
|
|
||||||
# list
|
|
||||||
#{ allow = ["Zlib"], name = "adler32", version = "*" },
|
|
||||||
]
|
|
||||||
|
|
||||||
# Some crates don't have (easily) machine readable licensing information,
|
|
||||||
# adding a clarification entry for it allows you to manually specify the
|
|
||||||
# licensing information
|
|
||||||
[[licenses.clarify]]
|
|
||||||
name = "ring"
|
|
||||||
version = "*"
|
|
||||||
expression = "MIT AND ISC AND OpenSSL"
|
|
||||||
license-files = [
|
|
||||||
{ path = "LICENSE", hash = 0xbd0eed23 }
|
|
||||||
]
|
|
||||||
|
|
||||||
# The SPDX expression for the license requirements of the crate
|
|
||||||
# One or more files in the crate's source used as the "source of truth" for
|
|
||||||
# the license expression. If the contents match, the clarification will be used
|
|
||||||
# when running the license check, otherwise the clarification will be ignored
|
|
||||||
# and the crate will be checked normally, which may produce warnings or errors
|
|
||||||
# depending on the rest of your configuration
|
|
||||||
|
|
||||||
[licenses.private]
|
|
||||||
# If true, ignores workspace crates that aren't published, or are only
|
|
||||||
# published to private registries
|
|
||||||
ignore = false
|
|
||||||
# One or more private registries that you might publish crates to, if a crate
|
|
||||||
# is only published to private registries, and ignore is true, the crate will
|
|
||||||
# not have its license(s) checked
|
|
||||||
registries = [
|
|
||||||
#"https://sekretz.com/registry
|
|
||||||
]
|
|
||||||
|
|
||||||
# This section is considered when running `cargo deny check bans`.
|
|
||||||
# More documentation about the 'bans' section can be found here:
|
|
||||||
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
|
|
||||||
[bans]
|
|
||||||
# Lint level for when multiple versions of the same crate are detected
|
|
||||||
multiple-versions = "warn"
|
|
||||||
# Lint level for when a crate version requirement is `*`
|
|
||||||
wildcards = "allow"
|
|
||||||
# The graph highlighting used when creating dotgraphs for crates
|
|
||||||
# with multiple versions
|
|
||||||
# * lowest-version - The path to the lowest versioned duplicate is highlighted
|
|
||||||
# * simplest-path - The path to the version with the fewest edges is highlighted
|
|
||||||
# * all - Both lowest-version and simplest-path are used
|
|
||||||
highlight = "all"
|
|
||||||
# List of crates that are allowed. Use with care!
|
|
||||||
allow = [
|
|
||||||
#{ name = "ansi_term", version = "=0.11.0" },
|
|
||||||
]
|
|
||||||
# List of crates to deny
|
|
||||||
deny = [
|
|
||||||
# Each entry the name of a crate and a version range. If version is
|
|
||||||
# not specified, all versions will be matched.
|
|
||||||
#{ name = "ansi_term", version = "=0.11.0" },
|
|
||||||
#
|
|
||||||
# Wrapper crates can optionally be specified to allow the crate when it
|
|
||||||
# is a direct dependency of the otherwise banned crate
|
|
||||||
#{ name = "ansi_term", version = "=0.11.0", wrappers = [] },
|
|
||||||
]
|
|
||||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
|
||||||
skip = [
|
|
||||||
#{ name = "ansi_term", version = "=0.11.0" },
|
|
||||||
]
|
|
||||||
# Similarly to `skip` allows you to skip certain crates during duplicate
|
|
||||||
# detection. Unlike skip, it also includes the entire tree of transitive
|
|
||||||
# dependencies starting at the specified crate, up to a certain depth, which is
|
|
||||||
# by default infinite
|
|
||||||
skip-tree = [
|
|
||||||
#{ name = "ansi_term", version = "=0.11.0", depth = 20 },
|
|
||||||
]
|
|
||||||
|
|
||||||
# This section is considered when running `cargo deny check sources`.
|
|
||||||
# More documentation about the 'sources' section can be found here:
|
|
||||||
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
|
|
||||||
[sources]
|
|
||||||
# Lint level for what to happen when a crate from a crate registry that is not
|
|
||||||
# in the allow list is encountered
|
|
||||||
unknown-registry = "warn"
|
|
||||||
# Lint level for what to happen when a crate from a git repository that is not
|
|
||||||
# in the allow list is encountered
|
|
||||||
unknown-git = "warn"
|
|
||||||
# List of URLs for allowed crate registries. Defaults to the crates.io index
|
|
||||||
# if not specified. If it is specified but empty, no registries are allowed.
|
|
||||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
|
||||||
# List of URLs for allowed Git repositories
|
|
||||||
allow-git = []
|
|
1
docs/.gitignore
vendored
1
docs/.gitignore
vendored
|
@ -1 +0,0 @@
|
||||||
book
|
|
|
@ -1,6 +0,0 @@
|
||||||
[book]
|
|
||||||
authors = ["Michael Zhang"]
|
|
||||||
language = "en"
|
|
||||||
multilingual = false
|
|
||||||
src = "src"
|
|
||||||
title = "Panorama"
|
|
|
@ -1,5 +0,0 @@
|
||||||
# Summary
|
|
||||||
|
|
||||||
- [Intro](./intro.md)
|
|
||||||
- [Config](./config.md)
|
|
||||||
- [Code Structure](./code.md)
|
|
|
@ -1,26 +0,0 @@
|
||||||
# Code Structure
|
|
||||||
|
|
||||||
The entire application is running on several async threads in a tokio runtime:
|
|
||||||
|
|
||||||
- [Mail](#mail)
|
|
||||||
- [UI](#ui)
|
|
||||||
- [VM](#vm)
|
|
||||||
|
|
||||||
Each of these communicates with the others via pairs of unbounded async
|
|
||||||
channels.
|
|
||||||
|
|
||||||
## Mail
|
|
||||||
|
|
||||||
The mail thread is in charge of communicating with mail servers. It keeps a
|
|
||||||
single connection alive to each server even if the UI thread has multiple mail
|
|
||||||
views open.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
The UI thread manages everything user-facing. It runs a terminal UI using the
|
|
||||||
tui crate. There's a tiny windowing system built in that allows for tiling
|
|
||||||
windows, split horizontally or vertically.
|
|
||||||
|
|
||||||
## VM
|
|
||||||
|
|
||||||
The VM runs the scripting language that can be used inside the application.
|
|
|
@ -1,30 +0,0 @@
|
||||||
# Config
|
|
||||||
|
|
||||||
Configuration is done by editing `$XDG_CONFIG_HOME/panorama/panorama.toml`.
|
|
||||||
This is usually found somewhere like `$HOME/.config/panorama/panorama.toml` It
|
|
||||||
follows the [TOML][1] file format, and the data structures are defined in code
|
|
||||||
at `src/config.rs`.
|
|
||||||
|
|
||||||
Example configuration:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
version = "0.1"
|
|
||||||
|
|
||||||
mail_dir = "~/.local/share/panorama/mail"
|
|
||||||
db_path = "~/.local/share/panorama/panorama.db"
|
|
||||||
|
|
||||||
[[mail]]
|
|
||||||
imap.server = "mail.example.com"
|
|
||||||
imap.port = 143
|
|
||||||
imap.tls = "starttls"
|
|
||||||
imap.auth = "plain"
|
|
||||||
imap.username = "foo"
|
|
||||||
imap.password = "bar"
|
|
||||||
```
|
|
||||||
|
|
||||||
As one of the primary goals of panorama, the application should automatically
|
|
||||||
detect changes made to this file after it has started, and automatically
|
|
||||||
re-establish the connections required. As a result, there's no UI for editing
|
|
||||||
the configuration within the application itself.
|
|
||||||
|
|
||||||
[1]: https://toml.io/en/
|
|
|
@ -1,26 +0,0 @@
|
||||||
# Intro
|
|
||||||
|
|
||||||
Panorama is a personal information manager.
|
|
||||||
|
|
||||||
- [rustdoc autogenerated API docs][1]
|
|
||||||
- [Github][3] ( [issues][4] )
|
|
||||||
- [Matrix chat #panorama:mozilla.org][5]
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
Since panorama is still in active development, install from git master with
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo install panorama --git https://github.com/iptq/panorama
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, create a config file. The format along with examples can be found in
|
|
||||||
[this page of the manual][2].
|
|
||||||
|
|
||||||
That's it! Run `panorama` forever.
|
|
||||||
|
|
||||||
[1]: https://pim.mzhang.io/api/panorama/
|
|
||||||
[2]: ./config.md
|
|
||||||
[3]: https://github.com/iptq/panorama
|
|
||||||
[4]: https://github.com/iptq/panorama/issues
|
|
||||||
[5]: https://matrix.to/#/!NSaHPfsflbEkjCZViX:mozilla.org?via=mozilla.org
|
|
1
imap/.gitignore
vendored
1
imap/.gitignore
vendored
|
@ -1 +0,0 @@
|
||||||
out.rs
|
|
|
@ -1,2 +0,0 @@
|
||||||
src/builders
|
|
||||||
src/oldparser
|
|
|
@ -1,36 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "panorama-imap"
|
|
||||||
version = "0.0.1"
|
|
||||||
authors = ["Dirkjan Ochtman <dirkjan@ochtman.nl>", "Michael Zhang <mail@mzhang.io>"]
|
|
||||||
description = "IMAP protocol parser and data structures"
|
|
||||||
keywords = ["imap", "email"]
|
|
||||||
categories = ["email", "network-programming", "parser-implementations"]
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
edition = "2018"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1.0.38"
|
|
||||||
async-trait = "0.1.42"
|
|
||||||
bytes = { version = "1.0.1" }
|
|
||||||
chrono = "0.4.19"
|
|
||||||
derive_builder = "0.9.0"
|
|
||||||
futures = "0.3.12"
|
|
||||||
imap-proto = "0.14.3"
|
|
||||||
log = "0.4.14"
|
|
||||||
parking_lot = "0.11.1"
|
|
||||||
# pest = { path = "../../pest/pest" }
|
|
||||||
# pest_derive = { path = "../../pest/derive" }
|
|
||||||
pest = { git = "https://github.com/iptq/pest", rev = "6a4d3a3d10e42a3ee605ca979d0fcdac97a83a99" }
|
|
||||||
pest_derive = { git = "https://github.com/iptq/pest", rev = "6a4d3a3d10e42a3ee605ca979d0fcdac97a83a99" }
|
|
||||||
quoted_printable = "0.4.2"
|
|
||||||
tokio = { version = "1.1.1", features = ["full"] }
|
|
||||||
tokio-rustls = "0.22.0"
|
|
||||||
tokio-util = { version = "0.6.3" }
|
|
||||||
webpki-roots = "0.21.0"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
assert_matches = "1.3"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["rfc2177-idle"]
|
|
||||||
rfc2177-idle = []
|
|
|
@ -1,38 +0,0 @@
|
||||||
IMAP
|
|
||||||
===
|
|
||||||
|
|
||||||
here's the list of RFCs planning to be supported and the status of the
|
|
||||||
implementation of their commands:
|
|
||||||
|
|
||||||
- RFC3501 (IMAP4rev1)
|
|
||||||
- any state:
|
|
||||||
- CAPABILITY: works
|
|
||||||
- NOOP: not yet implemented
|
|
||||||
- LOGOUT: not yet implemented
|
|
||||||
- not authenticated state:
|
|
||||||
- STARTTLS: works
|
|
||||||
- AUTHENTICATE: not yet implemented
|
|
||||||
- LOGIN: plain only
|
|
||||||
- authenticated state:
|
|
||||||
- SELECT: incomplete args
|
|
||||||
- EXAMINE: not yet implemented
|
|
||||||
- CREATE: not yet implemented
|
|
||||||
- DELETE: not yet implemented
|
|
||||||
- RENAME: not yet implemented
|
|
||||||
- SUBSCRIBE: not yet implemented
|
|
||||||
- UNSUBSCRIBE: not yet implemented
|
|
||||||
- LIST: not yet implemented
|
|
||||||
- LSUB: not yet implemented
|
|
||||||
- STATUS: not yet implemented
|
|
||||||
- APPEND: not yet implemented
|
|
||||||
- selected state:
|
|
||||||
- CHECK: not yet implemented
|
|
||||||
- CLOSE: not yet implemented
|
|
||||||
- EXPUNGE: not yet implemented
|
|
||||||
- SEARCH: incomplete args
|
|
||||||
- FETCH: incomplete args
|
|
||||||
- STORE: not yet implemented
|
|
||||||
- COPY: not yet implemented
|
|
||||||
- UID: incomplete args
|
|
||||||
- RFC2177 (IMAP4 IDLE)
|
|
||||||
- IDLE: works?
|
|
4
imap/fuzz/.gitignore
vendored
4
imap/fuzz/.gitignore
vendored
|
@ -1,4 +0,0 @@
|
||||||
|
|
||||||
target
|
|
||||||
corpus
|
|
||||||
artifacts
|
|
|
@ -1,21 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "imap-proto-fuzz"
|
|
||||||
version = "0.0.1"
|
|
||||||
authors = ["Automatically generated"]
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
cargo-fuzz = true
|
|
||||||
|
|
||||||
[dependencies.imap-proto]
|
|
||||||
path = ".."
|
|
||||||
[dependencies.libfuzzer-sys]
|
|
||||||
git = "https://github.com/rust-fuzz/libfuzzer-sys.git"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "utf8_parse_response"
|
|
||||||
path = "fuzz_targets/utf8_parse_response.rs"
|
|
||||||
|
|
||||||
# Prevent this from interfering with workspaces
|
|
||||||
[workspace]
|
|
||||||
members = ["."]
|
|
|
@ -1,8 +0,0 @@
|
||||||
#![no_main]
|
|
||||||
#[macro_use] extern crate libfuzzer_sys;
|
|
||||||
extern crate imap_proto;
|
|
||||||
|
|
||||||
// UTF-8
|
|
||||||
fuzz_target!(|data: &[u8]| {
|
|
||||||
let _ = imap_proto::Response::from_bytes(data);
|
|
||||||
});
|
|
|
@ -1,396 +0,0 @@
|
||||||
use std::borrow::Cow;
|
|
||||||
use std::marker::PhantomData;
|
|
||||||
use std::ops::{RangeFrom, RangeInclusive};
|
|
||||||
use std::str;
|
|
||||||
|
|
||||||
use crate::types::{AttrMacro, Attribute, State};
|
|
||||||
|
|
||||||
pub struct CommandBuilder {}
|
|
||||||
|
|
||||||
impl CommandBuilder {
|
|
||||||
pub fn check() -> Command {
|
|
||||||
let args = b"CHECK".to_vec();
|
|
||||||
Command {
|
|
||||||
args,
|
|
||||||
next_state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close() -> Command {
|
|
||||||
let args = b"CLOSE".to_vec();
|
|
||||||
Command {
|
|
||||||
args,
|
|
||||||
next_state: Some(State::Authenticated),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn starttls() -> Command {
|
|
||||||
let args = b"STARTTLS".to_vec();
|
|
||||||
Command {
|
|
||||||
args,
|
|
||||||
next_state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn examine(mailbox: &str) -> SelectCommand<select::NoParams> {
|
|
||||||
let args = format!("EXAMINE \"{}\"", quoted_string(mailbox).unwrap()).into_bytes();
|
|
||||||
SelectCommand {
|
|
||||||
args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch() -> FetchCommand<fetch::Empty> {
|
|
||||||
FetchCommand {
|
|
||||||
args: b"FETCH ".to_vec(),
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(reference: &str, glob: &str) -> Command {
|
|
||||||
let args = format!(
|
|
||||||
"LIST \"{}\" \"{}\"",
|
|
||||||
quoted_string(reference).unwrap(),
|
|
||||||
quoted_string(glob).unwrap()
|
|
||||||
)
|
|
||||||
.into_bytes();
|
|
||||||
Command {
|
|
||||||
args,
|
|
||||||
next_state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login(user_name: &str, password: &str) -> Command {
|
|
||||||
let args = format!(
|
|
||||||
"LOGIN \"{}\" \"{}\"",
|
|
||||||
quoted_string(user_name).unwrap(),
|
|
||||||
quoted_string(password).unwrap()
|
|
||||||
)
|
|
||||||
.into_bytes();
|
|
||||||
Command {
|
|
||||||
args,
|
|
||||||
next_state: Some(State::Authenticated),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select(mailbox: &str) -> SelectCommand<select::NoParams> {
|
|
||||||
let args = format!("SELECT \"{}\"", quoted_string(mailbox).unwrap()).into_bytes();
|
|
||||||
SelectCommand {
|
|
||||||
args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uid_fetch() -> FetchCommand<fetch::Empty> {
|
|
||||||
FetchCommand {
|
|
||||||
args: b"UID FETCH ".to_vec(),
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Command {
|
|
||||||
pub args: Vec<u8>,
|
|
||||||
pub next_state: Option<State>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SelectCommand<T> {
|
|
||||||
args: Vec<u8>,
|
|
||||||
state: PhantomData<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SelectCommand<select::NoParams> {
|
|
||||||
// RFC 4551 CONDSTORE parameter (based on RFC 4466 `select-param`)
|
|
||||||
pub fn cond_store(mut self) -> SelectCommand<select::Params> {
|
|
||||||
self.args.extend(b" (CONDSTORE");
|
|
||||||
SelectCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SelectCommand<select::NoParams>> for Command {
|
|
||||||
fn from(cmd: SelectCommand<select::NoParams>) -> Command {
|
|
||||||
Command {
|
|
||||||
args: cmd.args,
|
|
||||||
next_state: Some(State::Selected),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<SelectCommand<select::Params>> for Command {
|
|
||||||
fn from(mut cmd: SelectCommand<select::Params>) -> Command {
|
|
||||||
cmd.args.push(b')');
|
|
||||||
Command {
|
|
||||||
args: cmd.args,
|
|
||||||
next_state: Some(State::Selected),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod select {
|
|
||||||
pub struct NoParams;
|
|
||||||
pub struct Params;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod fetch {
|
|
||||||
pub struct Empty;
|
|
||||||
pub struct Messages;
|
|
||||||
pub struct Attributes;
|
|
||||||
pub struct Modifiers;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FetchCommand<T> {
|
|
||||||
args: Vec<u8>,
|
|
||||||
state: PhantomData<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FetchCommand<fetch::Empty> {
|
|
||||||
pub fn num(mut self, num: u32) -> FetchCommand<fetch::Messages> {
|
|
||||||
sequence_num(&mut self.args, num);
|
|
||||||
FetchCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn range(mut self, range: RangeInclusive<u32>) -> FetchCommand<fetch::Messages> {
|
|
||||||
sequence_range(&mut self.args, range);
|
|
||||||
FetchCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn range_from(mut self, range: RangeFrom<u32>) -> FetchCommand<fetch::Messages> {
|
|
||||||
range_from(&mut self.args, range);
|
|
||||||
FetchCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FetchCommand<fetch::Messages> {
|
|
||||||
pub fn num(mut self, num: u32) -> FetchCommand<fetch::Messages> {
|
|
||||||
self.args.extend(b",");
|
|
||||||
sequence_num(&mut self.args, num);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn range(mut self, range: RangeInclusive<u32>) -> FetchCommand<fetch::Messages> {
|
|
||||||
self.args.extend(b",");
|
|
||||||
sequence_range(&mut self.args, range);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn range_from(mut self, range: RangeFrom<u32>) -> FetchCommand<fetch::Messages> {
|
|
||||||
self.args.extend(b",");
|
|
||||||
range_from(&mut self.args, range);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attr_macro(mut self, named: AttrMacro) -> FetchCommand<fetch::Modifiers> {
|
|
||||||
self.args.push(b' ');
|
|
||||||
self.args.extend(
|
|
||||||
match named {
|
|
||||||
AttrMacro::All => "ALL",
|
|
||||||
AttrMacro::Fast => "FAST",
|
|
||||||
AttrMacro::Full => "FULL",
|
|
||||||
}
|
|
||||||
.as_bytes(),
|
|
||||||
);
|
|
||||||
FetchCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn attr(mut self, attr: Attribute) -> FetchCommand<fetch::Attributes> {
|
|
||||||
self.args.extend(b" (");
|
|
||||||
push_attr(&mut self.args, attr);
|
|
||||||
FetchCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sequence_num(cmd: &mut Vec<u8>, num: u32) {
|
|
||||||
cmd.extend(num.to_string().as_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sequence_range(cmd: &mut Vec<u8>, range: RangeInclusive<u32>) {
|
|
||||||
cmd.extend(range.start().to_string().as_bytes());
|
|
||||||
cmd.push(b':');
|
|
||||||
cmd.extend(range.end().to_string().as_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn range_from(cmd: &mut Vec<u8>, range: RangeFrom<u32>) {
|
|
||||||
cmd.extend(range.start.to_string().as_bytes());
|
|
||||||
cmd.extend(b":*");
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FetchCommand<fetch::Attributes> {
|
|
||||||
pub fn attr(mut self, attr: Attribute) -> FetchCommand<fetch::Attributes> {
|
|
||||||
self.args.push(b' ');
|
|
||||||
push_attr(&mut self.args, attr);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn changed_since(mut self, seq: u64) -> FetchCommand<fetch::Modifiers> {
|
|
||||||
self.args.push(b')');
|
|
||||||
changed_since(&mut self.args, seq);
|
|
||||||
FetchCommand {
|
|
||||||
args: self.args,
|
|
||||||
state: PhantomData::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_attr(cmd: &mut Vec<u8>, attr: Attribute) {
|
|
||||||
cmd.extend(
|
|
||||||
match attr {
|
|
||||||
Attribute::Body => "BODY",
|
|
||||||
Attribute::Envelope => "ENVELOPE",
|
|
||||||
Attribute::Flags => "FLAGS",
|
|
||||||
Attribute::InternalDate => "INTERNALDATE",
|
|
||||||
Attribute::ModSeq => "MODSEQ",
|
|
||||||
Attribute::Rfc822 => "RFC822",
|
|
||||||
Attribute::Rfc822Size => "RFC822.SIZE",
|
|
||||||
Attribute::Rfc822Text => "RFC822.TEXT",
|
|
||||||
Attribute::Uid => "UID",
|
|
||||||
}
|
|
||||||
.as_bytes(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<FetchCommand<fetch::Attributes>> for Command {
|
|
||||||
fn from(mut cmd: FetchCommand<fetch::Attributes>) -> Command {
|
|
||||||
cmd.args.push(b')');
|
|
||||||
Command {
|
|
||||||
args: cmd.args,
|
|
||||||
next_state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<FetchCommand<fetch::Modifiers>> for Command {
|
|
||||||
fn from(cmd: FetchCommand<fetch::Modifiers>) -> Command {
|
|
||||||
Command {
|
|
||||||
args: cmd.args,
|
|
||||||
next_state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FetchCommand<fetch::Modifiers> {
|
|
||||||
pub fn changed_since(mut self, seq: u64) -> FetchCommand<fetch::Modifiers> {
|
|
||||||
changed_since(&mut self.args, seq);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn changed_since(cmd: &mut Vec<u8>, seq: u64) {
|
|
||||||
cmd.extend(b" (CHANGEDSINCE ");
|
|
||||||
cmd.extend(seq.to_string().as_bytes());
|
|
||||||
cmd.push(b')');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an escaped string if necessary for use as a "quoted" string per
|
|
||||||
/// the IMAPv4 RFC. Return value does not include surrounding quote characters.
|
|
||||||
/// Will return Err if the argument contains illegal characters.
|
|
||||||
///
|
|
||||||
/// Relevant definitions from RFC 3501 formal syntax:
|
|
||||||
///
|
|
||||||
/// string = quoted / literal [literal elided here]
|
|
||||||
/// quoted = DQUOTE *QUOTED-CHAR DQUOTE
|
|
||||||
/// QUOTED-CHAR = <any TEXT-CHAR except quoted-specials> / "\" quoted-specials
|
|
||||||
/// quoted-specials = DQUOTE / "\"
|
|
||||||
/// TEXT-CHAR = <any CHAR except CR and LF>
|
|
||||||
fn quoted_string(s: &str) -> Result<Cow<str>, &'static str> {
|
|
||||||
let bytes = s.as_bytes();
|
|
||||||
let (mut start, mut new) = (0, Vec::<u8>::new());
|
|
||||||
for (i, b) in bytes.iter().enumerate() {
|
|
||||||
match *b {
|
|
||||||
b'\r' | b'\n' => {
|
|
||||||
return Err("CR and LF not allowed in quoted strings");
|
|
||||||
}
|
|
||||||
b'\\' | b'"' => {
|
|
||||||
if start < i {
|
|
||||||
new.extend(&bytes[start..i]);
|
|
||||||
}
|
|
||||||
new.push(b'\\');
|
|
||||||
new.push(*b);
|
|
||||||
start = i + 1;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if start == 0 {
|
|
||||||
Ok(Cow::Borrowed(s))
|
|
||||||
} else {
|
|
||||||
if start < bytes.len() {
|
|
||||||
new.extend(&bytes[start..]);
|
|
||||||
}
|
|
||||||
// Since the argument is a str, it must contain valid UTF-8. Since
|
|
||||||
// this function's transformation preserves the UTF-8 validity,
|
|
||||||
// unwrapping here should be okay.
|
|
||||||
Ok(Cow::Owned(String::from_utf8(new).unwrap()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{quoted_string, Attribute, Command, CommandBuilder};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn login() {
|
|
||||||
assert_eq!(
|
|
||||||
CommandBuilder::login("djc", "s3cr3t").args,
|
|
||||||
b"LOGIN \"djc\" \"s3cr3t\""
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
CommandBuilder::login("djc", "domain\\password").args,
|
|
||||||
b"LOGIN \"djc\" \"domain\\\\password\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn select() {
|
|
||||||
let cmd = Command::from(CommandBuilder::select("INBOX"));
|
|
||||||
assert_eq!(&cmd.args, br#"SELECT "INBOX""#);
|
|
||||||
let cmd = Command::from(CommandBuilder::examine("INBOX").cond_store());
|
|
||||||
assert_eq!(&cmd.args, br#"EXAMINE "INBOX" (CONDSTORE)"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch() {
|
|
||||||
let cmd: Command = CommandBuilder::fetch()
|
|
||||||
.range_from(1..)
|
|
||||||
.attr(Attribute::Uid)
|
|
||||||
.attr(Attribute::ModSeq)
|
|
||||||
.changed_since(13)
|
|
||||||
.into();
|
|
||||||
assert_eq!(cmd.args, &b"FETCH 1:* (UID MODSEQ) (CHANGEDSINCE 13)"[..]);
|
|
||||||
|
|
||||||
let cmd: Command = CommandBuilder::fetch()
|
|
||||||
.num(1)
|
|
||||||
.num(2)
|
|
||||||
.attr(Attribute::Uid)
|
|
||||||
.attr(Attribute::ModSeq)
|
|
||||||
.into();
|
|
||||||
assert_eq!(cmd.args, &b"FETCH 1,2 (UID MODSEQ)"[..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_quoted_string() {
|
|
||||||
assert_eq!(quoted_string("a").unwrap(), "a");
|
|
||||||
assert_eq!(quoted_string("").unwrap(), "");
|
|
||||||
assert_eq!(quoted_string("a\"b\\c").unwrap(), "a\\\"b\\\\c");
|
|
||||||
assert_eq!(quoted_string("\"foo\\").unwrap(), "\\\"foo\\\\");
|
|
||||||
assert!(quoted_string("\n").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
pub mod command;
|
|
|
@ -1,59 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::command::Command;
|
|
||||||
use crate::response::{Response, ResponseDone, Status};
|
|
||||||
|
|
||||||
use super::{ClientAuthenticated, ClientUnauthenticated};
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Auth {
|
|
||||||
/// Performs authentication, consuming the client
|
|
||||||
// TODO: return the unauthed client if failed?
|
|
||||||
async fn perform_auth(self, client: ClientUnauthenticated) -> Result<ClientAuthenticated>;
|
|
||||||
|
|
||||||
/// Converts the wrappers around the client once the authentication has happened. Should only
|
|
||||||
/// be called by the `perform_auth` function.
|
|
||||||
fn convert_client(client: ClientUnauthenticated) -> ClientAuthenticated {
|
|
||||||
match client {
|
|
||||||
ClientUnauthenticated::Encrypted(e) => ClientAuthenticated::Encrypted(e),
|
|
||||||
ClientUnauthenticated::Unencrypted(e) => ClientAuthenticated::Unencrypted(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Plain {
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Auth for Plain {
|
|
||||||
async fn perform_auth(self, mut client: ClientUnauthenticated) -> Result<ClientAuthenticated> {
|
|
||||||
let command = Command::Login {
|
|
||||||
username: self.username,
|
|
||||||
password: self.password,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = client.execute(command).await?;
|
|
||||||
let done = result.done().await?;
|
|
||||||
|
|
||||||
assert!(done.is_some());
|
|
||||||
let done = done.unwrap();
|
|
||||||
|
|
||||||
if done.status != Status::Ok {
|
|
||||||
bail!("unable to login: {:?}", done);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if !matches!(
|
|
||||||
// result,
|
|
||||||
// Response::Done(ResponseDone {
|
|
||||||
// status: Status::Ok,
|
|
||||||
// ..
|
|
||||||
// })
|
|
||||||
// ) {
|
|
||||||
// bail!("unable to login: {:?}", result);
|
|
||||||
// }
|
|
||||||
|
|
||||||
Ok(<Self as Auth>::convert_client(client))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,316 +0,0 @@
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::task::{Context, Poll};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures::{
|
|
||||||
future::{self, FutureExt, TryFutureExt},
|
|
||||||
stream::{Stream, StreamExt},
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
io::{
|
|
||||||
self, AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, ReadHalf, WriteHalf,
|
|
||||||
},
|
|
||||||
sync::{
|
|
||||||
mpsc,
|
|
||||||
oneshot::{self, error::TryRecvError},
|
|
||||||
},
|
|
||||||
task::JoinHandle,
|
|
||||||
};
|
|
||||||
use tokio_rustls::{
|
|
||||||
client::TlsStream, rustls::ClientConfig as RustlsConfig, webpki::DNSNameRef, TlsConnector,
|
|
||||||
};
|
|
||||||
use tokio_util::codec::FramedRead;
|
|
||||||
|
|
||||||
use crate::codec::ImapCodec;
|
|
||||||
use crate::command::Command;
|
|
||||||
use crate::parser::{parse_capability, parse_response};
|
|
||||||
use crate::response::{Response, ResponseDone};
|
|
||||||
|
|
||||||
use super::ClientConfig;
|
|
||||||
|
|
||||||
pub const TAG_PREFIX: &str = "ptag";
|
|
||||||
type Command2 = (String, Command, mpsc::UnboundedSender<Response>);
|
|
||||||
|
|
||||||
pub struct Client<C> {
|
|
||||||
ctr: usize,
|
|
||||||
config: ClientConfig,
|
|
||||||
// conn: WriteHalf<C>,
|
|
||||||
pub(crate) write_tx: mpsc::UnboundedSender<String>,
|
|
||||||
cmd_tx: mpsc::UnboundedSender<Command2>,
|
|
||||||
greeting_rx: Option<oneshot::Receiver<()>>,
|
|
||||||
writer_exit_tx: oneshot::Sender<()>,
|
|
||||||
writer_handle: JoinHandle<Result<WriteHalf<C>>>,
|
|
||||||
listener_exit_tx: oneshot::Sender<()>,
|
|
||||||
listener_handle: JoinHandle<Result<ReadHalf<C>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<C> Client<C>
|
|
||||||
where
|
|
||||||
C: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(conn: C, config: ClientConfig) -> Self {
|
|
||||||
let (read_half, mut write_half) = io::split(conn);
|
|
||||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
|
||||||
let (greeting_tx, greeting_rx) = oneshot::channel();
|
|
||||||
|
|
||||||
let (writer_exit_tx, exit_rx) = oneshot::channel();
|
|
||||||
let (write_tx, mut write_rx) = mpsc::unbounded_channel::<String>();
|
|
||||||
let writer_handle = tokio::spawn(write(write_half, write_rx, exit_rx).map_err(|err| {
|
|
||||||
error!("Help, the writer loop died: {}", err);
|
|
||||||
err
|
|
||||||
}));
|
|
||||||
|
|
||||||
let (exit_tx, exit_rx) = oneshot::channel();
|
|
||||||
let listener_handle = tokio::spawn(
|
|
||||||
listen(read_half, cmd_rx, write_tx.clone(), greeting_tx, exit_rx).map_err(|err| {
|
|
||||||
error!("Help, the listener loop died: {:?} {}", err, err);
|
|
||||||
err
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
Client {
|
|
||||||
ctr: 0,
|
|
||||||
// conn: write_half,
|
|
||||||
config,
|
|
||||||
cmd_tx,
|
|
||||||
write_tx,
|
|
||||||
greeting_rx: Some(greeting_rx),
|
|
||||||
writer_exit_tx,
|
|
||||||
listener_exit_tx: exit_tx,
|
|
||||||
writer_handle,
|
|
||||||
listener_handle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wait_for_greeting(&mut self) -> Result<()> {
|
|
||||||
if let Some(greeting_rx) = self.greeting_rx.take() {
|
|
||||||
greeting_rx.await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute(&mut self, cmd: Command) -> Result<ResponseStream> {
|
|
||||||
let id = self.ctr;
|
|
||||||
self.ctr += 1;
|
|
||||||
|
|
||||||
let tag = format!("{}{}", TAG_PREFIX, id);
|
|
||||||
// let cmd_str = format!("{} {}\r\n", tag, cmd);
|
|
||||||
// self.write_tx.send(cmd_str);
|
|
||||||
// self.conn.write_all(cmd_str.as_bytes()).await?;
|
|
||||||
// self.conn.flush().await?;
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
self.cmd_tx.send((tag, cmd, tx))?;
|
|
||||||
|
|
||||||
let stream = ResponseStream { inner: rx };
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn has_capability(&mut self, cap: impl AsRef<str>) -> Result<bool> {
|
|
||||||
// TODO: cache capabilities if needed?
|
|
||||||
let cap = cap.as_ref();
|
|
||||||
let cap = parse_capability(cap)?;
|
|
||||||
|
|
||||||
let resp = self.execute(Command::Capability).await?;
|
|
||||||
let (_, data) = resp.wait().await?;
|
|
||||||
|
|
||||||
for resp in data {
|
|
||||||
if let Response::Capabilities(caps) = resp {
|
|
||||||
return Ok(caps.contains(&cap));
|
|
||||||
}
|
|
||||||
// debug!("cap: {:?}", resp);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upgrade(mut self) -> Result<Client<TlsStream<C>>> {
|
|
||||||
// TODO: make sure STARTTLS is in the capability list
|
|
||||||
if !self.has_capability("STARTTLS").await? {
|
|
||||||
bail!("server doesn't support this capability");
|
|
||||||
}
|
|
||||||
|
|
||||||
// first, send the STARTTLS command
|
|
||||||
let mut resp = self.execute(Command::Starttls).await?;
|
|
||||||
let resp = resp.next().await.unwrap();
|
|
||||||
debug!("server response to starttls: {:?}", resp);
|
|
||||||
|
|
||||||
debug!("sending exit for upgrade");
|
|
||||||
// TODO: check that the channel is still open?
|
|
||||||
self.listener_exit_tx.send(()).unwrap();
|
|
||||||
self.writer_exit_tx.send(()).unwrap();
|
|
||||||
let (reader, writer) = future::join(self.listener_handle, self.writer_handle).await;
|
|
||||||
let reader = reader??;
|
|
||||||
let writer = writer??;
|
|
||||||
// let reader = self.listener_handle.await??;
|
|
||||||
// let writer = self.conn;
|
|
||||||
|
|
||||||
let conn = reader.unsplit(writer);
|
|
||||||
let server_name = &self.config.hostname;
|
|
||||||
|
|
||||||
let mut tls_config = RustlsConfig::new();
|
|
||||||
tls_config
|
|
||||||
.root_store
|
|
||||||
.add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS);
|
|
||||||
let tls_config = TlsConnector::from(Arc::new(tls_config));
|
|
||||||
let dnsname = DNSNameRef::try_from_ascii_str(server_name).unwrap();
|
|
||||||
let stream = tls_config.connect(dnsname, conn).await?;
|
|
||||||
debug!("upgraded, stream is using TLS now");
|
|
||||||
|
|
||||||
Ok(Client::new(stream, self.config))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ResponseStream {
|
|
||||||
pub(crate) inner: mpsc::UnboundedReceiver<Response>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResponseStream {
|
|
||||||
/// Retrieves just the DONE item in the stream, discarding the rest
|
|
||||||
pub async fn done(mut self) -> Result<Option<ResponseDone>> {
|
|
||||||
while let Some(resp) = self.inner.recv().await {
|
|
||||||
if let Response::Done(done) = resp {
|
|
||||||
return Ok(Some(done));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits for the entire stream to finish, returning the DONE status and the stream
|
|
||||||
pub async fn wait(mut self) -> Result<(Option<ResponseDone>, Vec<Response>)> {
|
|
||||||
let mut done = None;
|
|
||||||
let mut vec = Vec::new();
|
|
||||||
while let Some(resp) = self.inner.recv().await {
|
|
||||||
if let Response::Done(d) = resp {
|
|
||||||
done = Some(d);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
vec.push(resp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((done, vec))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream for ResponseStream {
|
|
||||||
type Item = Response;
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
|
|
||||||
self.inner.poll_recv(cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unreachable_code)]
|
|
||||||
async fn write<C>(
|
|
||||||
mut conn: WriteHalf<C>,
|
|
||||||
mut write_rx: mpsc::UnboundedReceiver<String>,
|
|
||||||
exit_rx: oneshot::Receiver<()>,
|
|
||||||
) -> Result<WriteHalf<C>>
|
|
||||||
where
|
|
||||||
C: AsyncWrite + Unpin,
|
|
||||||
{
|
|
||||||
let mut exit_rx = exit_rx.map_err(|_| ()).shared();
|
|
||||||
loop {
|
|
||||||
let write_fut = write_rx.recv().fuse();
|
|
||||||
pin_mut!(write_fut);
|
|
||||||
|
|
||||||
select! {
|
|
||||||
_ = exit_rx => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
line = write_fut => {
|
|
||||||
if let Some(line) = line {
|
|
||||||
conn.write_all(line.as_bytes()).await?;
|
|
||||||
conn.flush().await?;
|
|
||||||
trace!("C>>>S: {:?}", line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(unreachable_code)]
|
|
||||||
async fn listen<C>(
|
|
||||||
conn: ReadHalf<C>,
|
|
||||||
mut cmd_rx: mpsc::UnboundedReceiver<Command2>,
|
|
||||||
mut write_tx: mpsc::UnboundedSender<String>,
|
|
||||||
greeting_tx: oneshot::Sender<()>,
|
|
||||||
exit_rx: oneshot::Receiver<()>,
|
|
||||||
) -> Result<ReadHalf<C>>
|
|
||||||
where
|
|
||||||
C: AsyncRead + Unpin,
|
|
||||||
{
|
|
||||||
let codec = ImapCodec::default();
|
|
||||||
let mut framed = FramedRead::new(conn, codec);
|
|
||||||
let mut greeting_tx = Some(greeting_tx);
|
|
||||||
let mut curr_cmd: Option<Command2> = None;
|
|
||||||
let mut exit_rx = exit_rx.map_err(|_| ()).shared();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// let mut next_line = String::new();
|
|
||||||
// let read_fut = reader.read_line(&mut next_line).fuse();
|
|
||||||
let read_fut = framed.next().fuse();
|
|
||||||
pin_mut!(read_fut);
|
|
||||||
|
|
||||||
// only listen for a new command if there isn't one already
|
|
||||||
let mut cmd_fut = if let Some(_) = curr_cmd {
|
|
||||||
// if there is one, just make a future that never resolves so it'll always pick the
|
|
||||||
// other options in the select.
|
|
||||||
future::pending().boxed().fuse()
|
|
||||||
} else {
|
|
||||||
cmd_rx.recv().boxed().fuse()
|
|
||||||
};
|
|
||||||
|
|
||||||
select! {
|
|
||||||
_ = exit_rx => {
|
|
||||||
debug!("exiting the loop");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// read a command from the command list
|
|
||||||
cmd = cmd_fut => {
|
|
||||||
if curr_cmd.is_none() {
|
|
||||||
if let Some((ref tag, ref cmd, _)) = cmd {
|
|
||||||
let cmd_str = format!("{} {}\r\n", tag, cmd);
|
|
||||||
write_tx.send(cmd_str);
|
|
||||||
}
|
|
||||||
curr_cmd = cmd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// got a response from the server connection
|
|
||||||
resp = read_fut => {
|
|
||||||
let resp = match resp {
|
|
||||||
Some(Ok(v)) => v,
|
|
||||||
a => { error!("failed: {:?}", a); bail!("fuck"); },
|
|
||||||
};
|
|
||||||
trace!("S>>>C: {:?}", resp);
|
|
||||||
|
|
||||||
// if this is the very first response, then it's a greeting
|
|
||||||
if let Some(greeting_tx) = greeting_tx.take() {
|
|
||||||
greeting_tx.send(()).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Response::Done(_) = resp {
|
|
||||||
// since this is the DONE message, clear curr_cmd so another one can be sent
|
|
||||||
if let Some((_, _, cmd_tx)) = curr_cmd.take() {
|
|
||||||
let res = cmd_tx.send(resp);
|
|
||||||
// debug!("res0: {:?}", res);
|
|
||||||
}
|
|
||||||
} else if let Some((tag, cmd, cmd_tx)) = curr_cmd.as_mut() {
|
|
||||||
// we got a response from the server for this command, so send it over the
|
|
||||||
// channel
|
|
||||||
// debug!("sending {:?} to tag {}", resp, tag);
|
|
||||||
let res = cmd_tx.send(resp);
|
|
||||||
// debug!("res1: {:?}", res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let conn = framed.into_inner();
|
|
||||||
Ok(conn)
|
|
||||||
}
|
|
|
@ -1,333 +0,0 @@
|
||||||
//! IMAP Client
|
|
||||||
//! ===
|
|
||||||
//!
|
|
||||||
//! The IMAP client in this module is implemented as a state machine in the type system: methods
|
|
||||||
//! that are not supported in a particular state (ex. fetch in an unauthenticated state) cannot be
|
|
||||||
//! expressed in the type system entirely.
|
|
||||||
//!
|
|
||||||
//! Because there's many client types for the different types of clients, you'll want to start
|
|
||||||
//! here:
|
|
||||||
//!
|
|
||||||
//! - [`ClientBuilder`][self::ClientBuilder] : Constructs the config for the IMAP client
|
|
||||||
//!
|
|
||||||
//! If you choose not to use the high-level type-safe features of `ClientBuilder`, then you can
|
|
||||||
//! also choose to access the lower level [`Client`][self::inner::Client] directly.
|
|
||||||
//!
|
|
||||||
//! Example
|
|
||||||
//! ---
|
|
||||||
//!
|
|
||||||
//! The following example connects to `mywebsite.com:143` using STARTTLS.
|
|
||||||
//!
|
|
||||||
//! ```no_run
|
|
||||||
//! # use anyhow::Result;
|
|
||||||
//! # use panorama_imap::client::ClientConfigBuilder;
|
|
||||||
//! # async fn test() -> Result<()> {
|
|
||||||
//! let config = ClientConfigBuilder::default()
|
|
||||||
//! .hostname("mywebsite.com".to_owned())
|
|
||||||
//! .port(143)
|
|
||||||
//! .tls(false)
|
|
||||||
//! .build().unwrap();
|
|
||||||
//! let insecure = config.open().await?;
|
|
||||||
//! let unauth = insecure.upgrade().await?;
|
|
||||||
//! # Ok(())
|
|
||||||
//! # }
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
mod inner;
|
|
||||||
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::task::{Context, Poll};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures::{
|
|
||||||
future::{self, FutureExt},
|
|
||||||
stream::{Stream, StreamExt},
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
net::TcpStream,
|
|
||||||
sync::{mpsc, oneshot},
|
|
||||||
task::JoinHandle,
|
|
||||||
};
|
|
||||||
use tokio_rustls::{
|
|
||||||
client::TlsStream, rustls::ClientConfig as RustlsConfig, webpki::DNSNameRef, TlsConnector,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::command::{Command, FetchItems, SearchCriteria};
|
|
||||||
use crate::response::{
|
|
||||||
AttributeValue, Envelope, MailboxData, MailboxFlag, Response, ResponseCode, ResponseData,
|
|
||||||
ResponseDone, Status,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub use self::inner::{Client, ResponseStream};
|
|
||||||
|
|
||||||
/// Struct used to start building the config for a client.
|
|
||||||
///
|
|
||||||
/// Call [`.build`][1] to _build_ the config, then run [`.open`][2] to actually start opening
|
|
||||||
/// the connection to the server.
|
|
||||||
///
|
|
||||||
/// [1]: self::ClientConfigBuilder::build
|
|
||||||
/// [2]: self::ClientConfig::open
|
|
||||||
pub type ClientBuilder = ClientConfigBuilder;
|
|
||||||
|
|
||||||
/// An IMAP client that hasn't been connected yet.
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
|
||||||
pub struct ClientConfig {
|
|
||||||
/// The hostname of the IMAP server. If using TLS, must be an address
|
|
||||||
hostname: String,
|
|
||||||
|
|
||||||
/// The port of the IMAP server.
|
|
||||||
port: u16,
|
|
||||||
|
|
||||||
/// Whether or not the client is using an encrypted stream.
|
|
||||||
///
|
|
||||||
/// To upgrade the connection later, use the upgrade method.
|
|
||||||
tls: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientConfig {
|
|
||||||
pub async fn open(self) -> Result<ClientUnauthenticated> {
|
|
||||||
let hostname = self.hostname.as_ref();
|
|
||||||
let port = self.port;
|
|
||||||
let conn = TcpStream::connect((hostname, port)).await?;
|
|
||||||
|
|
||||||
if self.tls {
|
|
||||||
let mut tls_config = RustlsConfig::new();
|
|
||||||
tls_config
|
|
||||||
.root_store
|
|
||||||
.add_server_trust_anchors(&webpki_roots::TLS_SERVER_ROOTS);
|
|
||||||
let tls_config = TlsConnector::from(Arc::new(tls_config));
|
|
||||||
let dnsname = DNSNameRef::try_from_ascii_str(hostname).unwrap();
|
|
||||||
let conn = tls_config.connect(dnsname, conn).await?;
|
|
||||||
|
|
||||||
let mut inner = Client::new(conn, self);
|
|
||||||
inner.wait_for_greeting().await?;
|
|
||||||
return Ok(ClientUnauthenticated::Encrypted(inner));
|
|
||||||
} else {
|
|
||||||
let mut inner = Client::new(conn, self);
|
|
||||||
inner.wait_for_greeting().await?;
|
|
||||||
return Ok(ClientUnauthenticated::Unencrypted(inner));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum ClientUnauthenticated {
|
|
||||||
Encrypted(Client<TlsStream<TcpStream>>),
|
|
||||||
Unencrypted(Client<TcpStream>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientUnauthenticated {
|
|
||||||
pub async fn upgrade(self) -> Result<ClientUnauthenticated> {
|
|
||||||
match self {
|
|
||||||
// this is a no-op, we don't need to upgrade
|
|
||||||
ClientUnauthenticated::Encrypted(_) => Ok(self),
|
|
||||||
ClientUnauthenticated::Unencrypted(e) => {
|
|
||||||
Ok(ClientUnauthenticated::Encrypted(e.upgrade().await?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exposing low-level execute
|
|
||||||
async fn execute(&mut self, cmd: Command) -> Result<ResponseStream> {
|
|
||||||
match self {
|
|
||||||
ClientUnauthenticated::Encrypted(e) => e.execute(cmd).await,
|
|
||||||
ClientUnauthenticated::Unencrypted(e) => e.execute(cmd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if the server that the client is talking to has support for the given capability.
|
|
||||||
pub async fn has_capability(&mut self, cap: impl AsRef<str>) -> Result<bool> {
|
|
||||||
match self {
|
|
||||||
ClientUnauthenticated::Encrypted(e) => e.has_capability(cap).await,
|
|
||||||
ClientUnauthenticated::Unencrypted(e) => e.has_capability(cap).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum ClientAuthenticated {
|
|
||||||
Encrypted(Client<TlsStream<TcpStream>>),
|
|
||||||
Unencrypted(Client<TcpStream>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientAuthenticated {
|
|
||||||
/// Exposing low-level execute
|
|
||||||
async fn execute(&mut self, cmd: Command) -> Result<ResponseStream> {
|
|
||||||
match self {
|
|
||||||
ClientAuthenticated::Encrypted(e) => e.execute(cmd).await,
|
|
||||||
ClientAuthenticated::Unencrypted(e) => e.execute(cmd).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sender(&self) -> mpsc::UnboundedSender<String> {
|
|
||||||
match self {
|
|
||||||
ClientAuthenticated::Encrypted(e) => e.write_tx.clone(),
|
|
||||||
ClientAuthenticated::Unencrypted(e) => e.write_tx.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if the server that the client is talking to has support for the given capability.
|
|
||||||
pub async fn has_capability(&mut self, cap: impl AsRef<str>) -> Result<bool> {
|
|
||||||
match self {
|
|
||||||
ClientAuthenticated::Encrypted(e) => e.has_capability(cap).await,
|
|
||||||
ClientAuthenticated::Unencrypted(e) => e.has_capability(cap).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the LIST command
|
|
||||||
pub async fn list(&mut self) -> Result<Vec<String>> {
|
|
||||||
let cmd = Command::List {
|
|
||||||
reference: "".to_owned(),
|
|
||||||
mailbox: "*".to_owned(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = self.execute(cmd).await?;
|
|
||||||
let (_, data) = res.wait().await?;
|
|
||||||
|
|
||||||
let mut folders = Vec::new();
|
|
||||||
for resp in data {
|
|
||||||
if let Response::MailboxData(MailboxData::List { name, .. }) = resp {
|
|
||||||
folders.push(name.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(folders)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the SELECT command
|
|
||||||
pub async fn select(&mut self, mailbox: impl AsRef<str>) -> Result<SelectResponse> {
|
|
||||||
let cmd = Command::Select {
|
|
||||||
mailbox: mailbox.as_ref().to_owned(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let stream = self.execute(cmd).await?;
|
|
||||||
let (_, data) = stream.wait().await?;
|
|
||||||
|
|
||||||
let mut select = SelectResponse::default();
|
|
||||||
for resp in data {
|
|
||||||
match resp {
|
|
||||||
Response::MailboxData(MailboxData::Flags(flags)) => select.flags = flags,
|
|
||||||
Response::MailboxData(MailboxData::Exists(exists)) => select.exists = Some(exists),
|
|
||||||
Response::MailboxData(MailboxData::Recent(recent)) => select.recent = Some(recent),
|
|
||||||
Response::Data(ResponseData {
|
|
||||||
status: Status::Ok,
|
|
||||||
code: Some(code),
|
|
||||||
..
|
|
||||||
}) => match code {
|
|
||||||
ResponseCode::Unseen(value) => select.unseen = Some(value),
|
|
||||||
ResponseCode::UidNext(value) => select.uid_next = Some(value),
|
|
||||||
ResponseCode::UidValidity(value) => select.uid_validity = Some(value),
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(select)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the SEARCH command
|
|
||||||
pub async fn uid_search(&mut self) -> Result<Vec<u32>> {
|
|
||||||
let cmd = Command::UidSearch {
|
|
||||||
criteria: SearchCriteria::All,
|
|
||||||
};
|
|
||||||
let stream = self.execute(cmd).await?;
|
|
||||||
let (_, data) = stream.wait().await?;
|
|
||||||
for resp in data {
|
|
||||||
if let Response::MailboxData(MailboxData::Search(uids)) = resp {
|
|
||||||
return Ok(uids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bail!("could not find the SEARCH response")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the FETCH command
|
|
||||||
pub async fn fetch(
|
|
||||||
&mut self,
|
|
||||||
uids: &[u32],
|
|
||||||
items: FetchItems,
|
|
||||||
) -> Result<impl Stream<Item = (u32, Vec<AttributeValue>)>> {
|
|
||||||
let cmd = Command::Fetch {
|
|
||||||
uids: uids.to_vec(),
|
|
||||||
items,
|
|
||||||
};
|
|
||||||
debug!("fetch: {}", cmd);
|
|
||||||
let stream = self.execute(cmd).await?;
|
|
||||||
// let (done, data) = stream.wait().await?;
|
|
||||||
Ok(stream.filter_map(|resp| match resp {
|
|
||||||
Response::Fetch(n, attrs) => future::ready(Some((n, attrs))).boxed(),
|
|
||||||
Response::Done(_) => future::ready(None).boxed(),
|
|
||||||
_ => future::pending().boxed(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the UID FETCH command
|
|
||||||
pub async fn uid_fetch(
|
|
||||||
&mut self,
|
|
||||||
uids: &[u32],
|
|
||||||
items: FetchItems,
|
|
||||||
) -> Result<impl Stream<Item = (u32, Vec<AttributeValue>)>> {
|
|
||||||
let cmd = Command::UidFetch {
|
|
||||||
uids: uids.to_vec(),
|
|
||||||
items,
|
|
||||||
};
|
|
||||||
debug!("uid fetch: {}", cmd);
|
|
||||||
let stream = self.execute(cmd).await?;
|
|
||||||
// let (done, data) = stream.wait().await?;
|
|
||||||
Ok(stream.filter_map(|resp| match resp {
|
|
||||||
Response::Fetch(n, attrs) => future::ready(Some((n, attrs))).boxed(),
|
|
||||||
Response::Done(_) => future::ready(None).boxed(),
|
|
||||||
_ => future::pending().boxed(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the IDLE command
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rfc2177-idle")))]
|
|
||||||
pub async fn idle(&mut self) -> Result<IdleToken> {
|
|
||||||
let cmd = Command::Idle;
|
|
||||||
let stream = self.execute(cmd).await?;
|
|
||||||
let sender = self.sender();
|
|
||||||
Ok(IdleToken { stream, sender })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct SelectResponse {
|
|
||||||
pub flags: Vec<MailboxFlag>,
|
|
||||||
pub exists: Option<u32>,
|
|
||||||
pub recent: Option<u32>,
|
|
||||||
pub uid_next: Option<u32>,
|
|
||||||
pub uid_validity: Option<u32>,
|
|
||||||
pub unseen: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A token that represents an idling connection.
|
|
||||||
///
|
|
||||||
/// Dropping this token indicates that the idling should be completed, and the DONE command will be
|
|
||||||
/// sent to the server as a result.
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rfc2177-idle")))]
|
|
||||||
pub struct IdleToken {
|
|
||||||
pub stream: ResponseStream,
|
|
||||||
sender: mpsc::UnboundedSender<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rfc2177-idle")))]
|
|
||||||
impl Drop for IdleToken {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// TODO: should ignore this?
|
|
||||||
self.sender.send(format!("DONE\r\n")).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rfc2177-idle")))]
|
|
||||||
impl Stream for IdleToken {
|
|
||||||
type Item = Response;
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
|
|
||||||
let stream = Pin::new(&mut self.stream);
|
|
||||||
Stream::poll_next(stream, cx)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
pub struct Client {
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
use bytes::{Buf, BytesMut};
|
|
||||||
use tokio_util::codec::Decoder;
|
|
||||||
|
|
||||||
use crate::parser::parse_streamed_response;
|
|
||||||
use crate::response::Response;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ImapCodec;
|
|
||||||
|
|
||||||
impl Decoder for ImapCodec {
|
|
||||||
type Item = Response;
|
|
||||||
type Error = anyhow::Error;
|
|
||||||
|
|
||||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
|
||||||
let s = std::str::from_utf8(src)?;
|
|
||||||
// trace!("codec parsing {:?}", s);
|
|
||||||
match parse_streamed_response(s) {
|
|
||||||
Ok((resp, len)) => {
|
|
||||||
src.advance(len);
|
|
||||||
return Ok(Some(resp));
|
|
||||||
}
|
|
||||||
// TODO: distinguish between incomplete data and a parse error
|
|
||||||
Err(e) => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,134 +0,0 @@
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Commands, without the tag part.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub enum Command {
|
|
||||||
Capability,
|
|
||||||
Starttls,
|
|
||||||
Login {
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
},
|
|
||||||
Select {
|
|
||||||
mailbox: String,
|
|
||||||
},
|
|
||||||
List {
|
|
||||||
reference: String,
|
|
||||||
mailbox: String,
|
|
||||||
},
|
|
||||||
Search {
|
|
||||||
criteria: SearchCriteria,
|
|
||||||
},
|
|
||||||
Fetch {
|
|
||||||
// TODO: do sequence-set
|
|
||||||
uids: Vec<u32>,
|
|
||||||
items: FetchItems,
|
|
||||||
},
|
|
||||||
UidSearch {
|
|
||||||
criteria: SearchCriteria,
|
|
||||||
},
|
|
||||||
UidFetch {
|
|
||||||
// TODO: do sequence-set
|
|
||||||
uids: Vec<u32>,
|
|
||||||
items: FetchItems,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rfc2177-idle")))]
|
|
||||||
Idle,
|
|
||||||
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
#[cfg_attr(docsrs, doc(cfg(feature = "rfc2177-idle")))]
|
|
||||||
Done,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for Command {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
use Command::*;
|
|
||||||
match self {
|
|
||||||
Login { .. } => write!(f, "LOGIN"),
|
|
||||||
_ => <Self as fmt::Display>::fmt(self, f),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Command {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
use Command::*;
|
|
||||||
match self {
|
|
||||||
Capability => write!(f, "CAPABILITY"),
|
|
||||||
Starttls => write!(f, "STARTTLS"),
|
|
||||||
Login { username, password } => write!(f, "LOGIN {:?} {:?}", username, password),
|
|
||||||
Select { mailbox } => write!(f, "SELECT {}", mailbox),
|
|
||||||
Search { criteria } => write!(f, "SEARCH {}", criteria),
|
|
||||||
UidSearch { criteria } => write!(f, "UID SEARCH {}", criteria),
|
|
||||||
List { reference, mailbox } => write!(f, "LIST {:?} {:?}", reference, mailbox),
|
|
||||||
Fetch { uids, items } => write!(
|
|
||||||
f,
|
|
||||||
"FETCH {} {}",
|
|
||||||
uids.iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
items
|
|
||||||
),
|
|
||||||
UidFetch { uids, items } => write!(
|
|
||||||
f,
|
|
||||||
"UID FETCH {} {}",
|
|
||||||
uids.iter()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
items
|
|
||||||
),
|
|
||||||
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
Idle => write!(f, "IDLE"),
|
|
||||||
#[cfg(feature = "rfc2177-idle")]
|
|
||||||
Done => write!(f, "DONE"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum SearchCriteria {
|
|
||||||
All,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for SearchCriteria {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
use SearchCriteria::*;
|
|
||||||
match self {
|
|
||||||
All => write!(f, "ALL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum FetchItems {
|
|
||||||
All,
|
|
||||||
Fast,
|
|
||||||
Full,
|
|
||||||
BodyPeek,
|
|
||||||
Items(Vec<FetchAttr>),
|
|
||||||
|
|
||||||
/// item set that panorama uses, TODO: remove when FetchItems has a builder
|
|
||||||
PanoramaAll,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum FetchAttr {}
|
|
||||||
|
|
||||||
impl fmt::Display for FetchItems {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
use FetchItems::*;
|
|
||||||
match self {
|
|
||||||
All => write!(f, "ALL"),
|
|
||||||
Fast => write!(f, "FAST"),
|
|
||||||
Full => write!(f, "FULL"),
|
|
||||||
BodyPeek => write!(f, "(BODY.PEEK[])"),
|
|
||||||
PanoramaAll => write!(f, "(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY.PEEK[])"),
|
|
||||||
Items(attrs) => write!(f, ""),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
//! Panorama/IMAP
|
|
||||||
//! ===
|
|
||||||
//!
|
|
||||||
//! This is a library that implements parts of the IMAP protocol according to RFC 3501 and several
|
|
||||||
//! extensions. Although its primary purpose is to be used in panorama, it should be usable for
|
|
||||||
//! general-purpose IMAP client development. See the [client][crate::client] module for more
|
|
||||||
//! information on how to get started with a client quickly.
|
|
||||||
//!
|
|
||||||
//! RFCs:
|
|
||||||
//!
|
|
||||||
//! - RFC3501 (IMAP4) : work-in-progress
|
|
||||||
//! - RFC2177 (IDLE) : implemented
|
|
||||||
//! - RFC5256 (SORT / THREAD) : planned
|
|
||||||
|
|
||||||
#[macro_use]
|
|
||||||
extern crate anyhow;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate async_trait;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate derive_builder;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate futures;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate log;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate pest_derive;
|
|
||||||
|
|
||||||
pub mod client2;
|
|
||||||
pub mod client;
|
|
||||||
pub mod codec;
|
|
||||||
pub mod command;
|
|
||||||
pub mod parser;
|
|
||||||
pub mod response;
|
|
|
@ -1,91 +0,0 @@
|
||||||
use pest::{iterators::QueueableToken, ParseResult as PestResult, ParserState};
|
|
||||||
|
|
||||||
use super::Rule;
|
|
||||||
|
|
||||||
type PSR<'a> = Box<ParserState<'a, Rule>>;
|
|
||||||
|
|
||||||
/// This is a hack around the literal syntax to allow us to parse characters statefully.
|
|
||||||
pub(crate) fn literal_internal(state: PSR) -> PestResult<PSR> {
|
|
||||||
use pest::Atomicity;
|
|
||||||
|
|
||||||
// yoinked from the generated code
|
|
||||||
#[inline]
|
|
||||||
#[allow(non_snake_case, unused_variables)]
|
|
||||||
pub fn digit(state: PSR) -> PestResult<PSR> {
|
|
||||||
state.match_range('\u{30}'..'\u{39}')
|
|
||||||
}
|
|
||||||
#[inline]
|
|
||||||
#[allow(non_snake_case, unused_variables)]
|
|
||||||
pub fn number(state: PSR) -> PestResult<PSR> {
|
|
||||||
state.rule(Rule::number, |state| {
|
|
||||||
state.sequence(|state| digit(state).and_then(|state| state.repeat(digit)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[inline]
|
|
||||||
#[allow(non_snake_case, unused_variables)]
|
|
||||||
pub fn char8(state: PSR) -> PestResult<PSR> {
|
|
||||||
state.rule(Rule::char8, |state| {
|
|
||||||
state.atomic(Atomicity::Atomic, |state| {
|
|
||||||
state.match_range('\u{1}'..'\u{ff}')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[inline]
|
|
||||||
#[allow(non_snake_case, unused_variables)]
|
|
||||||
pub fn crlf(state: PSR) -> PestResult<PSR> {
|
|
||||||
state.sequence(|state| state.match_string("\r")?.match_string("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: PSR = state.match_string("{").and_then(number)?;
|
|
||||||
let num_chars = {
|
|
||||||
let queue = state.queue();
|
|
||||||
let (start_idx, end_pos) = queue
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find_map(|p| match p {
|
|
||||||
QueueableToken::End {
|
|
||||||
start_token_index: start,
|
|
||||||
rule: Rule::number,
|
|
||||||
input_pos: pos,
|
|
||||||
} => Some((*start, *pos)),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let start_pos = match queue[start_idx] {
|
|
||||||
QueueableToken::Start { input_pos: pos, .. } => pos,
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let inp = state.position().get_str();
|
|
||||||
let seg = &inp[start_pos..end_pos];
|
|
||||||
match seg.parse::<usize>() {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
error!(
|
|
||||||
"failed to parse int from {}..{} {:?}: {}",
|
|
||||||
start_pos, end_pos, seg, e
|
|
||||||
);
|
|
||||||
return Err(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.match_string("}")
|
|
||||||
.and_then(crlf)?
|
|
||||||
.rule(Rule::literal_str, |state| {
|
|
||||||
state.atomic(Atomicity::Atomic, |state| {
|
|
||||||
let mut state = Ok(state);
|
|
||||||
for _ in 0..num_chars {
|
|
||||||
state = state.and_then(char8);
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn noop(state: PSR) -> PestResult<PSR> {
|
|
||||||
// TODO: probably should be unreachable?
|
|
||||||
Ok(state)
|
|
||||||
}
|
|
|
@ -1,603 +0,0 @@
|
||||||
//! Module that implements parsers for all of the IMAP types.
|
|
||||||
|
|
||||||
mod literal;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
use std::fmt::Debug;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use chrono::{DateTime, FixedOffset, TimeZone};
|
|
||||||
use pest::{error::Error, iterators::Pair, Parser};
|
|
||||||
|
|
||||||
use crate::response::*;
|
|
||||||
|
|
||||||
use self::literal::literal_internal;
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[grammar = "parser/rfc3501.pest"]
|
|
||||||
|
|
||||||
struct Rfc3501;
|
|
||||||
|
|
||||||
pub type ParseResult<T, E = Error<Rule>> = Result<T, E>;
|
|
||||||
|
|
||||||
macro_rules! parse_fail {
|
|
||||||
($($tt:tt)*) => {
|
|
||||||
{ error!($($tt)*); panic!(); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_capability(s: impl AsRef<str>) -> ParseResult<Capability> {
|
|
||||||
let mut pairs = Rfc3501::parse(Rule::capability, s.as_ref())?;
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
Ok(build_capability(pair))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_streamed_response(s: impl AsRef<str>) -> ParseResult<(Response, usize)> {
|
|
||||||
let s = s.as_ref();
|
|
||||||
let mut pairs = match Rfc3501::parse(Rule::streamed_response, s) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
// error!("stream failed with len {}: {}", len ,e);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pair = unwrap1(pairs.next().unwrap());
|
|
||||||
let span = pair.as_span();
|
|
||||||
let range = span.end() - span.start();
|
|
||||||
let response = build_response(pair);
|
|
||||||
Ok((response, range))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_response(s: impl AsRef<str>) -> ParseResult<Response> {
|
|
||||||
let mut pairs = Rfc3501::parse(Rule::response, s.as_ref())?;
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
Ok(build_response(pair))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_response(pair: Pair<Rule>) -> Response {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::response));
|
|
||||||
let pair = unwrap1(pair);
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::response_done => build_response_done(pair),
|
|
||||||
Rule::response_data => build_response_data(pair),
|
|
||||||
Rule::continue_req => build_continue_req(pair),
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_response_done(pair: Pair<Rule>) -> Response {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::response_done));
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::response_tagged => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let tag = pair.as_str().to_owned();
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let (status, code, information) = build_resp_cond_state(pair);
|
|
||||||
Response::Done(ResponseDone {
|
|
||||||
tag,
|
|
||||||
status,
|
|
||||||
code,
|
|
||||||
information,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_response_data(pair: Pair<Rule>) -> Response {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::response_data));
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::resp_cond_state => {
|
|
||||||
let (status, code, information) = build_resp_cond_state(pair);
|
|
||||||
Response::Data(ResponseData {
|
|
||||||
status,
|
|
||||||
code,
|
|
||||||
information,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Rule::mailbox_data => Response::MailboxData(build_mailbox_data(pair)),
|
|
||||||
Rule::capability_data => Response::Capabilities(build_capabilities(pair)),
|
|
||||||
Rule::message_data => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let seq: u32 = build_number(pair);
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::message_data_expunge => Response::Expunge(seq),
|
|
||||||
Rule::message_data_fetch => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let msg_att = pairs.next().unwrap();
|
|
||||||
let attrs = msg_att.into_inner().map(build_msg_att).collect();
|
|
||||||
Response::Fetch(seq, attrs)
|
|
||||||
}
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_continue_req(pair: Pair<Rule>) -> Response {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::continue_req));
|
|
||||||
let (code, s) = build_resp_text(unwrap1(pair));
|
|
||||||
Response::Continue {
|
|
||||||
code,
|
|
||||||
information: Some(s),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_resp_text(pair: Pair<Rule>) -> (Option<ResponseCode>, String) {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::resp_text));
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let mut pair = pairs.next().unwrap();
|
|
||||||
let mut resp_code = None;
|
|
||||||
if let Rule::resp_text_code = pair.as_rule() {
|
|
||||||
resp_code = build_resp_text_code(pair);
|
|
||||||
pair = pairs.next().unwrap();
|
|
||||||
}
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::text));
|
|
||||||
let s = pair.as_str().to_owned();
|
|
||||||
(resp_code, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_msg_att(pair: Pair<Rule>) -> AttributeValue {
|
|
||||||
if !matches!(pair.as_rule(), Rule::msg_att_dyn_or_stat) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::msg_att_dynamic => AttributeValue::Flags(pair.into_inner().map(build_flag).collect()),
|
|
||||||
Rule::msg_att_static => build_msg_att_static(pair),
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_msg_att_static(pair: Pair<Rule>) -> AttributeValue {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::msg_att_static));
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::msg_att_static_internaldate => {
|
|
||||||
AttributeValue::InternalDate(build_date_time(unwrap1(pair)))
|
|
||||||
}
|
|
||||||
Rule::msg_att_static_rfc822_size => AttributeValue::Rfc822Size(build_number(unwrap1(pair))),
|
|
||||||
Rule::msg_att_static_envelope => AttributeValue::Envelope(build_envelope(unwrap1(pair))),
|
|
||||||
// TODO: do this
|
|
||||||
Rule::msg_att_static_body_structure => AttributeValue::BodySection(BodySection {
|
|
||||||
section: None,
|
|
||||||
index: None,
|
|
||||||
data: None,
|
|
||||||
}),
|
|
||||||
Rule::msg_att_static_body_section => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let section = None;
|
|
||||||
pairs.next();
|
|
||||||
let index = match pairs.peek().unwrap().as_rule() {
|
|
||||||
Rule::number => Some(build_number(unwrap1(pairs.next().unwrap()))),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let data = build_nstring(pairs.next().unwrap());
|
|
||||||
AttributeValue::BodySection(BodySection {
|
|
||||||
section,
|
|
||||||
index,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Rule::msg_att_static_uid => AttributeValue::Uid(build_number(unwrap1(unwrap1(pair)))),
|
|
||||||
_ => parse_fail!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_section(pair: Pair<Rule>) -> () {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::section));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_envelope(pair: Pair<Rule>) -> Envelope {
|
|
||||||
// TODO: do this
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let date = build_nstring(unwrap1(pairs.next().unwrap()));
|
|
||||||
let subject = build_nstring(unwrap1(pairs.next().unwrap()));
|
|
||||||
|
|
||||||
let address1 = |r: Rule, pair: Pair<Rule>| -> Option<Vec<Address>> {
|
|
||||||
assert!(matches!(pair.as_rule(), r));
|
|
||||||
let pair = unwrap1(pair);
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::nil => None,
|
|
||||||
Rule::env_address1 => Some(pair.into_inner().map(build_address).collect()),
|
|
||||||
_ => unreachable!("{:?}", pair),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let from = address1(Rule::env_from, pairs.next().unwrap());
|
|
||||||
let sender = address1(Rule::env_sender, pairs.next().unwrap());
|
|
||||||
let reply_to = address1(Rule::env_reply_to, pairs.next().unwrap());
|
|
||||||
let to = address1(Rule::env_to, pairs.next().unwrap());
|
|
||||||
let cc = address1(Rule::env_cc, pairs.next().unwrap());
|
|
||||||
let bcc = address1(Rule::env_bcc, pairs.next().unwrap());
|
|
||||||
let in_reply_to = build_nstring(unwrap1(pairs.next().unwrap()));
|
|
||||||
let message_id = build_nstring(unwrap1(pairs.next().unwrap()));
|
|
||||||
|
|
||||||
Envelope {
|
|
||||||
date,
|
|
||||||
subject,
|
|
||||||
from,
|
|
||||||
sender,
|
|
||||||
reply_to,
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
bcc,
|
|
||||||
in_reply_to,
|
|
||||||
message_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_resp_cond_state(pair: Pair<Rule>) -> (Status, Option<ResponseCode>, Option<String>) {
|
|
||||||
if !matches!(pair.as_rule(), Rule::resp_cond_state) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let status = build_status(pair);
|
|
||||||
let mut code = None;
|
|
||||||
let mut information = None;
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let pairs = pair.into_inner();
|
|
||||||
for pair in pairs {
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::resp_text_code => code = build_resp_text_code(pair),
|
|
||||||
Rule::text => information = Some(pair.as_str().to_owned()),
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(status, code, information)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_resp_text_code(pair: Pair<Rule>) -> Option<ResponseCode> {
|
|
||||||
if !matches!(pair.as_rule(), Rule::resp_text_code) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next()?;
|
|
||||||
Some(match pair.as_rule() {
|
|
||||||
Rule::capability_data => ResponseCode::Capabilities(build_capabilities(pair)),
|
|
||||||
Rule::resp_text_code_readwrite => ResponseCode::ReadWrite,
|
|
||||||
Rule::resp_text_code_uidvalidity => ResponseCode::UidValidity(build_number(unwrap1(pair))),
|
|
||||||
Rule::resp_text_code_uidnext => ResponseCode::UidNext(build_number(unwrap1(pair))),
|
|
||||||
Rule::resp_text_code_unseen => ResponseCode::Unseen(build_number(unwrap1(pair))),
|
|
||||||
// TODO: maybe have an actual type for these flags instead of just string
|
|
||||||
Rule::resp_text_code_permanentflags => {
|
|
||||||
ResponseCode::PermanentFlags(pair.into_inner().map(|p| p.as_str().to_owned()).collect())
|
|
||||||
}
|
|
||||||
Rule::resp_text_code_other => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let a = pair.as_str().to_owned();
|
|
||||||
let mut b = None;
|
|
||||||
if let Some(pair) = pairs.next() {
|
|
||||||
b = Some(pair.as_str().to_owned());
|
|
||||||
}
|
|
||||||
ResponseCode::Other(a, b)
|
|
||||||
}
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_capability(pair: Pair<Rule>) -> Capability {
|
|
||||||
if !matches!(pair.as_rule(), Rule::capability) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::auth_type => Capability::Auth(pair.as_str().to_uppercase().to_owned()),
|
|
||||||
Rule::atom => match pair.as_str() {
|
|
||||||
"IMAP4rev1" => Capability::Imap4rev1,
|
|
||||||
s => Capability::Atom(s.to_uppercase().to_owned()),
|
|
||||||
},
|
|
||||||
_ => unreachable!("{:?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_capabilities(pair: Pair<Rule>) -> Vec<Capability> {
|
|
||||||
if !matches!(pair.as_rule(), Rule::capability_data) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
pair.into_inner().map(build_capability).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_status(pair: Pair<Rule>) -> Status {
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::resp_status => match pair.as_str().to_uppercase().as_str() {
|
|
||||||
"OK" => Status::Ok,
|
|
||||||
"NO" => Status::No,
|
|
||||||
"BAD" => Status::Bad,
|
|
||||||
s => unreachable!("invalid status {:?}", s),
|
|
||||||
},
|
|
||||||
_ => unreachable!("{:?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_flag_list(pair: Pair<Rule>) -> Vec<MailboxFlag> {
|
|
||||||
if !matches!(pair.as_rule(), Rule::flag_list) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
|
|
||||||
pair.into_inner().map(build_flag).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_flag(mut pair: Pair<Rule>) -> MailboxFlag {
|
|
||||||
if matches!(pair.as_rule(), Rule::flag_fetch) {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
pair = pairs.next().unwrap();
|
|
||||||
|
|
||||||
if matches!(pair.as_rule(), Rule::flag_fetch_recent) {
|
|
||||||
return MailboxFlag::Recent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !matches!(pair.as_rule(), Rule::flag) {
|
|
||||||
unreachable!("{:#?}", pair);
|
|
||||||
}
|
|
||||||
|
|
||||||
match pair.as_str() {
|
|
||||||
"\\Answered" => MailboxFlag::Answered,
|
|
||||||
"\\Flagged" => MailboxFlag::Flagged,
|
|
||||||
"\\Deleted" => MailboxFlag::Deleted,
|
|
||||||
"\\Seen" => MailboxFlag::Seen,
|
|
||||||
"\\Draft" => MailboxFlag::Draft,
|
|
||||||
// s if s.starts_with("\\") => MailboxFlag::Ext(s.to_owned()),
|
|
||||||
// TODO: what??
|
|
||||||
s => MailboxFlag::Ext(s.to_owned()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_mailbox_data(pair: Pair<Rule>) -> MailboxData {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::mailbox_data));
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::mailbox_data_exists => MailboxData::Exists(build_number(unwrap1(pair))),
|
|
||||||
Rule::mailbox_data_flags => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let flags = build_flag_list(pair);
|
|
||||||
MailboxData::Flags(flags)
|
|
||||||
}
|
|
||||||
Rule::mailbox_data_recent => MailboxData::Recent(build_number(unwrap1(pair))),
|
|
||||||
Rule::mailbox_data_list => {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
let (flags, delimiter, name) = build_mailbox_list(pair);
|
|
||||||
MailboxData::List {
|
|
||||||
flags,
|
|
||||||
delimiter,
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Rule::mailbox_data_search => {
|
|
||||||
let uids = pair.into_inner().map(build_number).collect();
|
|
||||||
MailboxData::Search(uids)
|
|
||||||
}
|
|
||||||
_ => unreachable!("{:#?}", pair),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_mailbox_list(pair: Pair<Rule>) -> (Vec<String>, Option<String>, String) {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::mailbox_list));
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let mut pair = pairs.next().unwrap();
|
|
||||||
|
|
||||||
// let mut flags = Vec::new();
|
|
||||||
let flags = if let Rule::mailbox_list_flags = pair.as_rule() {
|
|
||||||
let pairs_ = pair.into_inner();
|
|
||||||
let mut flags = Vec::new();
|
|
||||||
for pair in pairs_ {
|
|
||||||
flags.extend(build_mbx_list_flags(pair));
|
|
||||||
}
|
|
||||||
pair = pairs.next().unwrap();
|
|
||||||
flags
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::mailbox_list_string));
|
|
||||||
let s = build_nstring(unwrap1(pair));
|
|
||||||
|
|
||||||
pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::mailbox));
|
|
||||||
let mailbox = if pair.as_str().to_lowercase() == "inbox" {
|
|
||||||
pair.as_str().to_owned()
|
|
||||||
} else {
|
|
||||||
build_astring(unwrap1(pair))
|
|
||||||
};
|
|
||||||
|
|
||||||
(flags, s, mailbox)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_mbx_list_flags(pair: Pair<Rule>) -> Vec<String> {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::mbx_list_flags));
|
|
||||||
pair.into_inner()
|
|
||||||
.map(|pair| pair.as_str().to_owned())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unwraps a singleton pair (a pair that only has one element in its `inner` list)
|
|
||||||
fn unwrap1(pair: Pair<Rule>) -> Pair<Rule> {
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
pairs.next().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts a numerical type, generic over anything that could possibly be read as a number
|
|
||||||
// TODO: should probably restrict this to a few cases
|
|
||||||
fn build_number<T>(pair: Pair<Rule>) -> T
|
|
||||||
where
|
|
||||||
T: FromStr,
|
|
||||||
T::Err: Debug,
|
|
||||||
{
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::nz_number | Rule::number));
|
|
||||||
pair.as_str().parse::<T>().unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_astring(pair: Pair<Rule>) -> String {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::astring));
|
|
||||||
let pair_str = pair.as_str().to_owned();
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let rule = pairs.peek().map(|p| p.as_rule());
|
|
||||||
if let Some(Rule::string) = rule {
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
build_string(pair)
|
|
||||||
} else {
|
|
||||||
pair_str
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_nstring(pair: Pair<Rule>) -> Option<String> {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::nstring));
|
|
||||||
let pair = unwrap1(pair);
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::nil => None,
|
|
||||||
Rule::string => Some(build_string(pair)),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts a string-type, discarding the surrounding quotes and unescaping the escaped characters
|
|
||||||
fn build_string(pair: Pair<Rule>) -> String {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::string));
|
|
||||||
let pair = unwrap1(pair);
|
|
||||||
|
|
||||||
match pair.as_rule() {
|
|
||||||
Rule::literal => build_literal(pair),
|
|
||||||
// TODO: escaping stuff?
|
|
||||||
Rule::quoted => pair
|
|
||||||
.as_str()
|
|
||||||
.trim_start_matches("\"")
|
|
||||||
.trim_end_matches("\"")
|
|
||||||
.replace("\\\"", "\"")
|
|
||||||
.to_owned(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_literal(s: impl AsRef<str>) -> ParseResult<String> {
|
|
||||||
let mut pairs = Rfc3501::parse(Rule::literal, s.as_ref())?;
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
Ok(build_literal(pair))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_literal(pair: Pair<Rule>) -> String {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::literal));
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let _ = pairs.next().unwrap();
|
|
||||||
let literal_str = pairs.next().unwrap();
|
|
||||||
literal_str.as_str().to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_zone(s: impl AsRef<str>) -> ParseResult<FixedOffset> {
|
|
||||||
let mut pairs = Rfc3501::parse(Rule::zone, s.as_ref())?;
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
Ok(build_zone(pair))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_zone(pair: Pair<Rule>) -> FixedOffset {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::zone));
|
|
||||||
let n = pair.as_str().parse::<i32>().unwrap();
|
|
||||||
let sign = if n != 0 { n / n.abs() } else { 1 };
|
|
||||||
let h = n.abs() / 100;
|
|
||||||
let m = n.abs() % 100;
|
|
||||||
FixedOffset::east(sign * (h * 60 + m) * 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_date_time(pair: Pair<Rule>) -> DateTime<FixedOffset> {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::date_time));
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::date_day_fixed));
|
|
||||||
let day = pair.as_str().trim().parse::<u32>().unwrap();
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::date_month));
|
|
||||||
let month = match pair.as_str() {
|
|
||||||
"Jan" => 1,
|
|
||||||
"Feb" => 2,
|
|
||||||
"Mar" => 3,
|
|
||||||
"Apr" => 4,
|
|
||||||
"May" => 5,
|
|
||||||
"Jun" => 6,
|
|
||||||
"Jul" => 7,
|
|
||||||
"Aug" => 8,
|
|
||||||
"Sep" => 9,
|
|
||||||
"Oct" => 10,
|
|
||||||
"Nov" => 11,
|
|
||||||
"Dec" => 12,
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::date_year));
|
|
||||||
let year = pair.as_str().trim().parse::<i32>().unwrap();
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::time));
|
|
||||||
let mut parts = pair.as_str().split(':');
|
|
||||||
let hour = parts.next().unwrap().parse::<u32>().unwrap();
|
|
||||||
let minute = parts.next().unwrap().parse::<u32>().unwrap();
|
|
||||||
let second = parts.next().unwrap().parse::<u32>().unwrap();
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::zone));
|
|
||||||
let zone = build_zone(pair);
|
|
||||||
|
|
||||||
zone.ymd(year, month, day).and_hms(hour, minute, second)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_address(pair: Pair<Rule>) -> Address {
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::address));
|
|
||||||
|
|
||||||
let mut pairs = pair.into_inner();
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::addr_name));
|
|
||||||
let name = build_nstring(unwrap1(pair));
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::addr_adl));
|
|
||||||
let adl = build_nstring(unwrap1(pair));
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::addr_mailbox));
|
|
||||||
let mailbox = build_nstring(unwrap1(pair));
|
|
||||||
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
assert!(matches!(pair.as_rule(), Rule::addr_host));
|
|
||||||
let host = build_nstring(unwrap1(pair));
|
|
||||||
|
|
||||||
Address {
|
|
||||||
name,
|
|
||||||
adl,
|
|
||||||
mailbox,
|
|
||||||
host,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,155 +0,0 @@
|
||||||
streamed_response = { response ~ ANY* }
|
|
||||||
|
|
||||||
// formal syntax from https://tools.ietf.org/html/rfc3501#section-9
|
|
||||||
addr_adl = { nstring }
|
|
||||||
addr_host = { nstring }
|
|
||||||
addr_mailbox = { nstring }
|
|
||||||
addr_name = { nstring }
|
|
||||||
address = { "(" ~ addr_name ~ sp ~ addr_adl ~ sp ~ addr_mailbox ~ sp ~ addr_host ~ ")" }
|
|
||||||
astring = @{ astring_char{1,} | string }
|
|
||||||
astring_char = @{ atom_char | resp_specials }
|
|
||||||
atom = @{ atom_char{1,} }
|
|
||||||
atom_char = @{ !atom_specials ~ char }
|
|
||||||
atom_specials = @{ "(" | ")" | "{" | sp | ctl | list_wildcards | quoted_specials | resp_specials }
|
|
||||||
auth_type = { atom }
|
|
||||||
base64 = @{ (base64_char{4})* ~ base64_terminal }
|
|
||||||
base64_char = @{ alpha | digit | "+" | "/" }
|
|
||||||
base64_terminal = @{ (base64_char{2} ~ "==") | (base64_char{3} ~ "=") }
|
|
||||||
body = { "(" ~ (body_type_1part | body_type_mpart) ~ ")" }
|
|
||||||
body_ext_1part = { body_fld_md5 ~ (sp ~ body_fld_dsp ~ (sp ~ body_fld_lang ~ (sp ~ body_fld_loc ~ (sp ~ body_extension)*)?)?)? }
|
|
||||||
body_ext_mpart = { body_fld_param ~ (sp ~ body_fld_dsp ~ (sp ~ body_fld_lang ~ (sp ~ body_fld_loc ~ (sp ~ body_extension)*)?)?)? }
|
|
||||||
body_extension = { nstring | number | "(" ~ body_extension ~ (sp ~ body_extension)* ~ ")" }
|
|
||||||
body_fields = { body_fld_param ~ sp ~ body_fld_id ~ sp ~ body_fld_desc ~ sp ~ body_fld_enc ~ sp ~ body_fld_octets }
|
|
||||||
body_fld_desc = { nstring }
|
|
||||||
body_fld_dsp = { "(" ~ string ~ sp ~ body_fld_param ~ ")" | nil }
|
|
||||||
body_fld_enc = { (dquote ~ (^"7BIT" | ^"8BIT" | ^"BINARY" | ^"BASE64" | ^"QUOTED-PRINTABLE") ~ dquote) | string }
|
|
||||||
body_fld_id = { nstring }
|
|
||||||
body_fld_lang = { nstring | "(" ~ string ~ (sp ~ string)* ~ ")" }
|
|
||||||
body_fld_lines = { number }
|
|
||||||
body_fld_loc = { nstring }
|
|
||||||
body_fld_md5 = { nstring }
|
|
||||||
body_fld_octets = { number }
|
|
||||||
body_fld_param = { "(" ~ string ~ sp ~ string ~ (sp ~ string ~ sp ~ string)* ~ ")" | nil}
|
|
||||||
body_type_1part = { (body_type_basic | body_type_msg | body_type_text) ~ (sp ~ body_ext_1part)? }
|
|
||||||
body_type_basic = { media_basic ~ sp ~ body_fields }
|
|
||||||
body_type_mpart = { body{1,} ~ sp ~ media_subtype ~ (sp ~ body_ext_mpart)? }
|
|
||||||
body_type_msg = { media_message ~ sp ~ body_fields ~ sp ~ envelope ~ sp ~ body ~ sp ~ body_fld_lines }
|
|
||||||
body_type_text = { media_text ~ sp ~ body_fields ~ sp ~ body_fld_lines }
|
|
||||||
capability = ${ ^"AUTH=" ~ auth_type | atom }
|
|
||||||
capability_data = { ^"CAPABILITY" ~ (sp ~ ("IMAP4rev1" ~ capability))* ~ sp ~ "IMAP4rev1" ~ (sp ~ capability)* }
|
|
||||||
char8 = @{ '\x01'..'\xff' }
|
|
||||||
continue_req = { "+" ~ sp ~ (resp_text | base64) ~ crlf }
|
|
||||||
date_day_fixed = { (sp ~ digit) | digit{2} }
|
|
||||||
date_month = { "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" }
|
|
||||||
date_time = { dquote_ ~ date_day_fixed ~ "-" ~ date_month ~ "-" ~ date_year ~ sp ~ time ~ sp ~ zone ~ dquote_ }
|
|
||||||
date_year = @{ digit{4} }
|
|
||||||
digit_nz = @{ '\x31'..'\x39' }
|
|
||||||
env_address1 = { "(" ~ address ~ (sp? ~ address)* ~ ")" }
|
|
||||||
env_bcc = { env_address1 | nil }
|
|
||||||
env_cc = { env_address1 | nil }
|
|
||||||
env_date = { nstring }
|
|
||||||
env_from = { env_address1 | nil }
|
|
||||||
env_in_reply_to = { nstring }
|
|
||||||
env_message_id = { nstring }
|
|
||||||
env_reply_to = { env_address1 | nil }
|
|
||||||
env_sender = { env_address1 | nil }
|
|
||||||
env_subject = { nstring }
|
|
||||||
env_to = { env_address1 | nil }
|
|
||||||
envelope = { "(" ~ env_date ~ sp ~ env_subject ~ sp ~ env_from ~ sp ~ env_sender ~ sp ~ env_reply_to ~ sp ~ env_to ~ sp ~ env_cc ~ sp ~ env_bcc ~ sp ~ env_in_reply_to ~ sp ~ env_message_id ~ ")" }
|
|
||||||
flag = { "\\Answered" | "\\Flagged" | "\\Deleted" | "\\Seen" | "\\Draft" | flag_keyword | flag_extension }
|
|
||||||
flag_extension = @{ "\\" ~ atom }
|
|
||||||
flag_fetch = { flag | flag_fetch_recent }
|
|
||||||
flag_fetch_recent = { "\\Recent" }
|
|
||||||
flag_keyword = @{ atom }
|
|
||||||
flag_list = { "(" ~ (flag ~ (sp ~ flag)*)? ~ ")" }
|
|
||||||
flag_perm = { flag | "\\*" }
|
|
||||||
header_fld_name = { astring }
|
|
||||||
header_list = { "(" ~ header_fld_name ~ (sp ~ header_fld_name)* ~ ")" }
|
|
||||||
list_wildcards = @{ "%" | "*" }
|
|
||||||
mailbox = { ^"INBOX" | astring }
|
|
||||||
mailbox_data = { mailbox_data_flags | mailbox_data_list | (^"LSUB" ~ sp ~ mailbox_list) | mailbox_data_search | (^"STATUS" ~ sp ~ mailbox ~ sp ~ ^"(" ~ status_att_list? ~ ^")") | mailbox_data_exists | mailbox_data_recent }
|
|
||||||
mailbox_data_exists = { number ~ sp ~ ^"EXISTS" }
|
|
||||||
mailbox_data_flags = { ^"FLAGS" ~ sp ~ flag_list }
|
|
||||||
mailbox_data_list = { ^"LIST" ~ sp ~ mailbox_list }
|
|
||||||
mailbox_data_recent = { number ~ sp ~ ^"RECENT" }
|
|
||||||
mailbox_data_search = { ^"SEARCH" ~ (sp ~ nz_number)* }
|
|
||||||
mailbox_list = { mailbox_list_flags ~ sp ~ mailbox_list_string ~ sp ~ mailbox }
|
|
||||||
mailbox_list_flags = { "(" ~ mbx_list_flags* ~ ")" }
|
|
||||||
mailbox_list_string = ${ nstring }
|
|
||||||
mbx_list_flags = { (mbx_list_oflag ~ sp)* ~ mbx_list_sflag ~ (sp ~ mbx_list_oflag)* | mbx_list_oflag ~ (sp ~ mbx_list_oflag)* }
|
|
||||||
mbx_list_oflag = { "\\NoInferiors" | flag_extension }
|
|
||||||
mbx_list_sflag = { "\\NoSelect" | "\\Marked" | "\\Unmarked" }
|
|
||||||
media_basic = { ((dquote ~ ("APPLICATION" | "AUDIO" | "IMAGE" | "MESSAGE" | "VIDEO") ~ dquote) | string) ~ sp ~ media_subtype }
|
|
||||||
media_message = { dquote ~ "MESSAGE" ~ dquote ~ sp ~ dquote ~ "RFC822" ~ dquote }
|
|
||||||
media_subtype = { string }
|
|
||||||
media_text = { dquote ~ "TEXT" ~ dquote ~ sp ~ media_subtype }
|
|
||||||
message_data = { nz_number ~ sp ~ (message_data_expunge | message_data_fetch) }
|
|
||||||
message_data_expunge = { ^"EXPUNGE" }
|
|
||||||
message_data_fetch = { ^"FETCH" ~ sp ~ msg_att }
|
|
||||||
msg_att = { "(" ~ msg_att_dyn_or_stat ~ (sp ~ msg_att_dyn_or_stat)* ~ ")" }
|
|
||||||
msg_att_dyn_or_stat = { msg_att_dynamic | msg_att_static }
|
|
||||||
msg_att_dynamic = { ^"FLAGS" ~ sp ~ "(" ~ (flag_fetch ~ (sp ~ flag_fetch)*)? ~ ")" }
|
|
||||||
msg_att_static = { msg_att_static_envelope | msg_att_static_internaldate | (^"RFC822" ~ (^".HEADER" | ^".TEXT") ~ sp ~ nstring) | msg_att_static_rfc822_size | msg_att_static_body_structure | msg_att_static_body_section | msg_att_static_uid }
|
|
||||||
msg_att_static_body_section = { ^"BODY" ~ section ~ ("<" ~ number ~ ">")? ~ sp ~ nstring }
|
|
||||||
msg_att_static_body_structure = { ^"BODY" ~ ^"STRUCTURE"? ~ sp ~ body }
|
|
||||||
msg_att_static_envelope = { ^"ENVELOPE" ~ sp ~ envelope }
|
|
||||||
msg_att_static_internaldate = { ^"INTERNALDATE" ~ sp ~ date_time }
|
|
||||||
msg_att_static_rfc822_size = { ^"RFC822.SIZE" ~ sp ~ number }
|
|
||||||
msg_att_static_uid = { ^"UID" ~ sp ~ uniqueid }
|
|
||||||
nil = { ^"NIL" }
|
|
||||||
nstring = { nil | string }
|
|
||||||
number = @{ digit{1,} }
|
|
||||||
nz_number = @{ digit_nz ~ digit* }
|
|
||||||
quoted = @{ dquote ~ quoted_char* ~ dquote }
|
|
||||||
quoted_char = @{ (!quoted_specials ~ char) | ("\\" ~ quoted_specials) }
|
|
||||||
quoted_specials = @{ dquote | "\\" }
|
|
||||||
resp_cond_bye = { ^"BYE" ~ sp ~ resp_text }
|
|
||||||
resp_cond_state = { resp_status ~ sp ~ resp_text }
|
|
||||||
resp_specials = @{ "]" }
|
|
||||||
resp_status = { (^"OK" | ^"NO" | ^"BAD") }
|
|
||||||
resp_text = { ("[" ~ resp_text_code ~ "]" ~ sp)? ~ text }
|
|
||||||
resp_text_code = { ^"ALERT" | (^"BADCHARSET" ~ (sp ~ "(" ~ astring ~ (sp ~ astring)* ~ ")")?) | capability_data | ^"PARSE" | resp_text_code_permanentflags | ^"READ-ONLY" | resp_text_code_readwrite | ^"TRYCREATE" | resp_text_code_uidnext | resp_text_code_uidvalidity | resp_text_code_unseen | resp_text_code_other }
|
|
||||||
resp_text_code_atom = @{ (!"]" ~ text_char){1,} }
|
|
||||||
resp_text_code_other = { (atom ~ (sp ~ resp_text_code_atom)?) }
|
|
||||||
resp_text_code_permanentflags = { ^"PERMANENTFLAGS" ~ sp ~ "(" ~ (flag_perm ~ (sp ~ flag_perm)*)? ~ ")" }
|
|
||||||
resp_text_code_readwrite = { ^"READ-WRITE" }
|
|
||||||
resp_text_code_uidnext = { ^"UIDNEXT" ~ sp ~ nz_number }
|
|
||||||
resp_text_code_uidvalidity = { ^"UIDVALIDITY" ~ sp ~ nz_number }
|
|
||||||
resp_text_code_unseen = { ^"UNSEEN" ~ sp ~ nz_number }
|
|
||||||
response = { continue_req | response_data | response_done }
|
|
||||||
response_data = { "*" ~ sp ~ (resp_cond_state | resp_cond_bye | mailbox_data | message_data | capability_data) ~ crlf }
|
|
||||||
response_done = { response_tagged | response_fatal }
|
|
||||||
response_fatal = { "*" ~ sp ~ resp_cond_bye ~ crlf }
|
|
||||||
response_tagged = { tag ~ sp ~ resp_cond_state ~ crlf }
|
|
||||||
section = { "[" ~ section_spec? ~ "]" }
|
|
||||||
section_msgtext = { ^"HEADER" | (^"HEADER.FIELDS" ~ ^".NOT"? ~ sp ~ header_list) | ^"TEXT" }
|
|
||||||
section_part = { nz_number ~ ("." ~ nz_number)* }
|
|
||||||
section_spec = { section_msgtext | (section_part ~ ("." ~ section_text)?) }
|
|
||||||
section_text = { section_msgtext | "MIME" }
|
|
||||||
status_att = { ^"MESSAGES" | ^"RECENT" | ^"UIDNEXT" | ^"UIDVALIDITY" | ^"UNSEEN" }
|
|
||||||
status_att_list = { status_att ~ sp ~ number ~ (sp ~ status_att ~ sp ~ number)* }
|
|
||||||
string = ${ quoted | literal }
|
|
||||||
tag = @{ tag_char{1,} }
|
|
||||||
tag_char = @{ !"+" ~ astring_char }
|
|
||||||
text = @{ text_char{1,} }
|
|
||||||
text_char = @{ !cr ~ !lf ~ char }
|
|
||||||
time = @{ digit{2} ~ ":" ~ digit{2} ~ ":" ~ digit{2} }
|
|
||||||
uniqueid = { nz_number }
|
|
||||||
zone = @{ ("+" | "-") ~ digit{4} }
|
|
||||||
|
|
||||||
// custom-implemented functions
|
|
||||||
literal = { #crate::parser::literal_internal }
|
|
||||||
literal_str = { #crate::parser::literal::noop }
|
|
||||||
|
|
||||||
// core rules from https://tools.ietf.org/html/rfc2234#section-6.1
|
|
||||||
alpha = @{ '\x41'..'\x5a' | '\x61'..'\x7a' }
|
|
||||||
char = @{ '\x01'..'\x7f' }
|
|
||||||
cr = _{ "\x0d" }
|
|
||||||
crlf = _{ cr ~ lf }
|
|
||||||
ctl = @{ '\x00'..'\x1f' | "\x7f" }
|
|
||||||
digit = @{ '\x30'..'\x39' }
|
|
||||||
dquote = @{ "\"" }
|
|
||||||
dquote_ = _{ "\"" }
|
|
||||||
lf = _{ "\x0a" }
|
|
||||||
sp = _{ " " }
|
|
||||||
|
|
|
@ -1,209 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
use chrono::FixedOffset;
|
|
||||||
use pest::Parser;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::response::*;
|
|
||||||
|
|
||||||
fn parse<F, R>(r: Rule, f: F) -> impl Fn(&str) -> ParseResult<R>
|
|
||||||
where
|
|
||||||
F: Fn(Pair<Rule>) -> R,
|
|
||||||
{
|
|
||||||
move |s: &str| {
|
|
||||||
let mut pairs = Rfc3501::parse(r, s.as_ref())?;
|
|
||||||
let pair = pairs.next().unwrap();
|
|
||||||
Ok(f(pair))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_literal() {
|
|
||||||
let p = parse(Rule::literal, build_literal);
|
|
||||||
assert_eq!(p("{7}\r\nhellosu"), Ok("hellosu".to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_address() -> Result<()> {
|
|
||||||
let p = parse(Rule::address, build_address);
|
|
||||||
assert_eq!(
|
|
||||||
p(r#"("Terry Gray" NIL "gray" "cac.washington.edu")"#)?,
|
|
||||||
Address {
|
|
||||||
name: Some("Terry Gray".to_owned()),
|
|
||||||
adl: None,
|
|
||||||
mailbox: Some("gray".to_owned()),
|
|
||||||
host: Some("cac.washington.edu".to_owned()),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_zone() {
|
|
||||||
let p = parse(Rule::zone, build_zone);
|
|
||||||
assert_eq!(p("+0000"), Ok(FixedOffset::east(0)));
|
|
||||||
assert_eq!(p("-0200"), Ok(FixedOffset::west(7200)));
|
|
||||||
assert_eq!(p("+0330"), Ok(FixedOffset::east(12600)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_date_time() -> Result<()> {
|
|
||||||
let p = parse(Rule::date_time, build_date_time);
|
|
||||||
assert_eq!(
|
|
||||||
p("\"17-Jul-1996 02:44:25 -0700\"")?,
|
|
||||||
DateTime::parse_from_rfc3339("1996-07-17T02:44:25-07:00")?
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[rustfmt::skip]
|
|
||||||
fn test_capability() {
|
|
||||||
let p = parse(Rule::capability, build_capability);
|
|
||||||
assert_eq!(p("IMAP4rev1"), Ok(Capability::Imap4rev1));
|
|
||||||
assert_eq!(p("LOGINDISABLED"), Ok(Capability::Atom("LOGINDISABLED".to_owned())));
|
|
||||||
assert_eq!(p("AUTH=PLAIN"), Ok(Capability::Auth("PLAIN".to_owned())));
|
|
||||||
assert_eq!(p("auth=plain"), Ok(Capability::Auth("PLAIN".to_owned())));
|
|
||||||
|
|
||||||
assert!(p("(OSU)").is_err());
|
|
||||||
assert!(p("\x01HELLO").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[rustfmt::skip]
|
|
||||||
fn test_nil() {
|
|
||||||
assert!(Rfc3501::parse(Rule::nil, "NIL").is_ok());
|
|
||||||
assert!(Rfc3501::parse(Rule::nil, "anything else").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_section_8() {
|
|
||||||
// this little exchange is from section 8 of rfc3501
|
|
||||||
// https://tools.ietf.org/html/rfc3501#section-8
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("* OK IMAP4rev1 Service Ready\r\n"),
|
|
||||||
Ok(Response::Data(ResponseData {
|
|
||||||
status: Status::Ok,
|
|
||||||
code: None,
|
|
||||||
information: Some("IMAP4rev1 Service Ready".to_owned()),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("a001 OK LOGIN completed\r\n"),
|
|
||||||
Ok(Response::Done(ResponseDone {
|
|
||||||
tag: "a001".to_owned(),
|
|
||||||
status: Status::Ok,
|
|
||||||
code: None,
|
|
||||||
information: Some("LOGIN completed".to_owned()),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("* 18 EXISTS\r\n"),
|
|
||||||
Ok(Response::MailboxData(MailboxData::Exists(18)))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n"),
|
|
||||||
Ok(Response::MailboxData(MailboxData::Flags(vec![
|
|
||||||
MailboxFlag::Answered,
|
|
||||||
MailboxFlag::Flagged,
|
|
||||||
MailboxFlag::Deleted,
|
|
||||||
MailboxFlag::Seen,
|
|
||||||
MailboxFlag::Draft,
|
|
||||||
])))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("* 2 RECENT\r\n"),
|
|
||||||
Ok(Response::MailboxData(MailboxData::Recent(2)))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("* OK [UNSEEN 17] Message 17 is the first unseen message\r\n"),
|
|
||||||
Ok(Response::Data(ResponseData {
|
|
||||||
status: Status::Ok,
|
|
||||||
code: Some(ResponseCode::Unseen(17)),
|
|
||||||
information: Some("Message 17 is the first unseen message".to_owned()),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("* OK [UIDVALIDITY 3857529045] UIDs valid\r\n"),
|
|
||||||
Ok(Response::Data(ResponseData {
|
|
||||||
status: Status::Ok,
|
|
||||||
code: Some(ResponseCode::UidValidity(3857529045)),
|
|
||||||
information: Some("UIDs valid".to_owned()),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response("a002 OK [READ-WRITE] SELECT completed\r\n"),
|
|
||||||
Ok(Response::Done(ResponseDone {
|
|
||||||
tag: "a002".to_owned(),
|
|
||||||
status: Status::Ok,
|
|
||||||
code: Some(ResponseCode::ReadWrite),
|
|
||||||
information: Some("SELECT completed".to_owned()),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
let terry_addr = Address {
|
|
||||||
name: Some("Terry Gray".to_owned()),
|
|
||||||
adl: None,
|
|
||||||
mailbox: Some("gray".to_owned()),
|
|
||||||
host: Some("cac.washington.edu".to_owned()),
|
|
||||||
};
|
|
||||||
let imap_addr = Address {
|
|
||||||
name: None,
|
|
||||||
adl: None,
|
|
||||||
mailbox: Some("imap".to_owned()),
|
|
||||||
host: Some("cac.washington.edu".to_owned()),
|
|
||||||
};
|
|
||||||
let minutes_addr = Address {
|
|
||||||
name: None,
|
|
||||||
adl: None,
|
|
||||||
mailbox: Some("minutes".to_owned()),
|
|
||||||
host: Some("CNRI.Reston.VA.US".to_owned()),
|
|
||||||
};
|
|
||||||
let john_addr = Address {
|
|
||||||
name: Some("John Klensin".to_owned()),
|
|
||||||
adl: None,
|
|
||||||
mailbox: Some("KLENSIN".to_owned()),
|
|
||||||
host: Some("MIT.EDU".to_owned()),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
parse_response(concat!(
|
|
||||||
r#"* 12 FETCH (FLAGS (\Seen) INTERNALDATE "17-Jul-1996 02:44:25 -0700" RFC822.SIZE 4286 ENVELOPE ("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" "CNRI.Reston.VA.US")("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL "<B27397-0100000@cac.washington.edu>") BODY ("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 302892))"#,
|
|
||||||
"\r\n",
|
|
||||||
)),
|
|
||||||
Ok(Response::Fetch(
|
|
||||||
12,
|
|
||||||
vec![
|
|
||||||
AttributeValue::Flags(vec![MailboxFlag::Seen]),
|
|
||||||
AttributeValue::InternalDate(
|
|
||||||
DateTime::parse_from_rfc3339("1996-07-17T02:44:25-07:00").unwrap()
|
|
||||||
),
|
|
||||||
AttributeValue::Rfc822Size(4286),
|
|
||||||
AttributeValue::Envelope(Envelope {
|
|
||||||
date: Some("Wed, 17 Jul 1996 02:23:25 -0700 (PDT)".to_owned()),
|
|
||||||
subject: Some("IMAP4rev1 WG mtg summary and minutes".to_owned()),
|
|
||||||
from: Some(vec![terry_addr.clone()]),
|
|
||||||
sender: Some(vec![terry_addr.clone()]),
|
|
||||||
reply_to: Some(vec![terry_addr.clone()]),
|
|
||||||
to: Some(vec![imap_addr.clone()]),
|
|
||||||
cc: Some(vec![minutes_addr.clone(), john_addr.clone()]),
|
|
||||||
bcc: None,
|
|
||||||
in_reply_to: None,
|
|
||||||
message_id: Some("<B27397-0100000@cac.washington.edu>".to_owned()),
|
|
||||||
}),
|
|
||||||
AttributeValue::BodySection(BodySection {
|
|
||||||
section: None,
|
|
||||||
index: None,
|
|
||||||
data: None,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,294 +0,0 @@
|
||||||
//! Structs and enums that have to do with responses.
|
|
||||||
|
|
||||||
use std::fmt;
|
|
||||||
use std::ops::RangeInclusive;
|
|
||||||
|
|
||||||
use chrono::{DateTime, FixedOffset};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Response {
|
|
||||||
Capabilities(Vec<Capability>),
|
|
||||||
Continue {
|
|
||||||
code: Option<ResponseCode>,
|
|
||||||
information: Option<String>,
|
|
||||||
},
|
|
||||||
Done(ResponseDone),
|
|
||||||
Data(ResponseData),
|
|
||||||
Expunge(u32),
|
|
||||||
Vanished {
|
|
||||||
earlier: bool,
|
|
||||||
uids: Vec<RangeInclusive<u32>>,
|
|
||||||
},
|
|
||||||
Fetch(u32, Vec<AttributeValue>),
|
|
||||||
MailboxData(MailboxData),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ResponseData {
|
|
||||||
pub status: Status,
|
|
||||||
pub code: Option<ResponseCode>,
|
|
||||||
pub information: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ResponseDone {
|
|
||||||
pub tag: String,
|
|
||||||
pub status: Status,
|
|
||||||
pub code: Option<ResponseCode>,
|
|
||||||
pub information: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
||||||
pub enum Capability {
|
|
||||||
Imap4rev1,
|
|
||||||
Auth(String),
|
|
||||||
Atom(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ResponseCode {
|
|
||||||
Alert,
|
|
||||||
BadCharset(Option<Vec<String>>),
|
|
||||||
Capabilities(Vec<Capability>),
|
|
||||||
HighestModSeq(u64), // RFC 4551, section 3.1.1
|
|
||||||
Parse,
|
|
||||||
PermanentFlags(Vec<String>),
|
|
||||||
ReadOnly,
|
|
||||||
ReadWrite,
|
|
||||||
TryCreate,
|
|
||||||
UidNext(u32),
|
|
||||||
UidValidity(u32),
|
|
||||||
Unseen(u32),
|
|
||||||
AppendUid(u32, Vec<UidSetMember>),
|
|
||||||
CopyUid(u32, Vec<UidSetMember>, Vec<UidSetMember>),
|
|
||||||
UidNotSticky,
|
|
||||||
Other(String, Option<String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum UidSetMember {
|
|
||||||
UidRange(RangeInclusive<u32>),
|
|
||||||
Uid(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum AttributeValue {
|
|
||||||
BodySection(BodySection),
|
|
||||||
BodyStructure(BodyStructure),
|
|
||||||
Envelope(Envelope),
|
|
||||||
Flags(Vec<MailboxFlag>),
|
|
||||||
InternalDate(DateTime<FixedOffset>),
|
|
||||||
ModSeq(u64), // RFC 4551, section 3.3.2
|
|
||||||
Rfc822(Option<String>),
|
|
||||||
Rfc822Header(Option<String>),
|
|
||||||
Rfc822Size(u32),
|
|
||||||
Rfc822Text(Option<String>),
|
|
||||||
Uid(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct BodySection {
|
|
||||||
pub section: Option<SectionPath>,
|
|
||||||
pub index: Option<u32>,
|
|
||||||
pub data: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for BodySection {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"BodySection(section={:?} index={:?} data=<{}>",
|
|
||||||
self.section,
|
|
||||||
self.index,
|
|
||||||
self.data.as_ref().map(|s| s.len()).unwrap_or(0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum BodyStructure {
|
|
||||||
Basic {
|
|
||||||
common: BodyContentCommon,
|
|
||||||
other: BodyContentSinglePart,
|
|
||||||
extension: Option<BodyExtension>,
|
|
||||||
},
|
|
||||||
Text {
|
|
||||||
common: BodyContentCommon,
|
|
||||||
other: BodyContentSinglePart,
|
|
||||||
lines: u32,
|
|
||||||
extension: Option<BodyExtension>,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
common: BodyContentCommon,
|
|
||||||
other: BodyContentSinglePart,
|
|
||||||
envelope: Envelope,
|
|
||||||
body: Box<BodyStructure>,
|
|
||||||
lines: u32,
|
|
||||||
extension: Option<BodyExtension>,
|
|
||||||
},
|
|
||||||
Multipart {
|
|
||||||
common: BodyContentCommon,
|
|
||||||
bodies: Vec<BodyStructure>,
|
|
||||||
extension: Option<BodyExtension>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct BodyContentSinglePart {
|
|
||||||
pub id: Option<String>,
|
|
||||||
pub md5: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub transfer_encoding: ContentEncoding,
|
|
||||||
pub octets: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct BodyContentCommon {
|
|
||||||
pub ty: ContentType,
|
|
||||||
pub disposition: Option<ContentDisposition>,
|
|
||||||
pub language: Option<Vec<String>>,
|
|
||||||
pub location: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ContentType {
|
|
||||||
pub ty: String,
|
|
||||||
pub subtype: String,
|
|
||||||
pub params: BodyParams,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ContentDisposition {
|
|
||||||
pub ty: String,
|
|
||||||
pub params: BodyParams,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ContentEncoding {
|
|
||||||
SevenBit,
|
|
||||||
EightBit,
|
|
||||||
Binary,
|
|
||||||
Base64,
|
|
||||||
QuotedPrintable,
|
|
||||||
Other(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type BodyParams = Option<Vec<(String, String)>>;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum BodyExtension {
|
|
||||||
Num(u32),
|
|
||||||
Str(Option<String>),
|
|
||||||
List(Vec<BodyExtension>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Envelope {
|
|
||||||
pub date: Option<String>,
|
|
||||||
pub subject: Option<String>,
|
|
||||||
pub from: Option<Vec<Address>>,
|
|
||||||
pub sender: Option<Vec<Address>>,
|
|
||||||
pub reply_to: Option<Vec<Address>>,
|
|
||||||
pub to: Option<Vec<Address>>,
|
|
||||||
pub cc: Option<Vec<Address>>,
|
|
||||||
pub bcc: Option<Vec<Address>>,
|
|
||||||
pub in_reply_to: Option<String>,
|
|
||||||
pub message_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Address {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub adl: Option<String>,
|
|
||||||
pub mailbox: Option<String>,
|
|
||||||
pub host: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Attribute {
|
|
||||||
Body,
|
|
||||||
Envelope,
|
|
||||||
Flags,
|
|
||||||
InternalDate,
|
|
||||||
ModSeq, // RFC 4551, section 3.3.2
|
|
||||||
Rfc822,
|
|
||||||
Rfc822Size,
|
|
||||||
Rfc822Text,
|
|
||||||
Uid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum MessageSection {
|
|
||||||
Header,
|
|
||||||
Mime,
|
|
||||||
Text,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum SectionPath {
|
|
||||||
Full(MessageSection),
|
|
||||||
Part(Vec<u32>, Option<MessageSection>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum MailboxData {
|
|
||||||
Exists(u32),
|
|
||||||
Flags(Vec<MailboxFlag>),
|
|
||||||
List {
|
|
||||||
flags: Vec<String>,
|
|
||||||
delimiter: Option<String>,
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
Search(Vec<u32>),
|
|
||||||
Status {
|
|
||||||
mailbox: String,
|
|
||||||
status: Vec<StatusAttribute>,
|
|
||||||
},
|
|
||||||
Recent(u32),
|
|
||||||
MetadataSolicited {
|
|
||||||
mailbox: String,
|
|
||||||
values: Vec<Metadata>,
|
|
||||||
},
|
|
||||||
MetadataUnsolicited {
|
|
||||||
mailbox: String,
|
|
||||||
values: Vec<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
|
|
||||||
pub enum MailboxFlag {
|
|
||||||
Answered,
|
|
||||||
Flagged,
|
|
||||||
Deleted,
|
|
||||||
Seen,
|
|
||||||
Draft,
|
|
||||||
Recent,
|
|
||||||
Ext(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct Metadata {
|
|
||||||
pub entry: String,
|
|
||||||
pub value: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum StatusAttribute {
|
|
||||||
HighestModSeq(u64), // RFC 4551
|
|
||||||
Messages(u32),
|
|
||||||
Recent(u32),
|
|
||||||
UidNext(u32),
|
|
||||||
UidValidity(u32),
|
|
||||||
Unseen(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum Status {
|
|
||||||
Ok,
|
|
||||||
No,
|
|
||||||
Bad,
|
|
||||||
PreAuth,
|
|
||||||
Bye,
|
|
||||||
}
|
|
|
@ -1,329 +0,0 @@
|
||||||
use std::ops::RangeInclusive;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct Request<'a>(pub &'a [u8], pub &'a [u8]);
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum AttrMacro {
|
|
||||||
All,
|
|
||||||
Fast,
|
|
||||||
Full,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Response<'a> {
|
|
||||||
Capabilities(Vec<Capability<'a>>),
|
|
||||||
Continue {
|
|
||||||
code: Option<ResponseCode<'a>>,
|
|
||||||
information: Option<&'a str>,
|
|
||||||
},
|
|
||||||
Done {
|
|
||||||
tag: RequestId,
|
|
||||||
status: Status,
|
|
||||||
code: Option<ResponseCode<'a>>,
|
|
||||||
information: Option<&'a str>,
|
|
||||||
},
|
|
||||||
Data {
|
|
||||||
status: Status,
|
|
||||||
code: Option<ResponseCode<'a>>,
|
|
||||||
information: Option<&'a str>,
|
|
||||||
},
|
|
||||||
Expunge(u32),
|
|
||||||
Vanished {
|
|
||||||
earlier: bool,
|
|
||||||
uids: Vec<std::ops::RangeInclusive<u32>>,
|
|
||||||
},
|
|
||||||
Fetch(u32, Vec<AttributeValue<'a>>),
|
|
||||||
MailboxData(MailboxDatum<'a>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Response<'a> {
|
|
||||||
pub fn from_bytes(buf: &'a [u8]) -> crate::ParseResult {
|
|
||||||
crate::oldparser::parse_response(buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum Status {
|
|
||||||
Ok,
|
|
||||||
No,
|
|
||||||
Bad,
|
|
||||||
PreAuth,
|
|
||||||
Bye,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum ResponseCode<'a> {
|
|
||||||
Alert,
|
|
||||||
BadCharset(Option<Vec<&'a str>>),
|
|
||||||
Capabilities(Vec<Capability<'a>>),
|
|
||||||
HighestModSeq(u64), // RFC 4551, section 3.1.1
|
|
||||||
Parse,
|
|
||||||
PermanentFlags(Vec<&'a str>),
|
|
||||||
ReadOnly,
|
|
||||||
ReadWrite,
|
|
||||||
TryCreate,
|
|
||||||
UidNext(u32),
|
|
||||||
UidValidity(u32),
|
|
||||||
Unseen(u32),
|
|
||||||
AppendUid(u32, Vec<UidSetMember>),
|
|
||||||
CopyUid(u32, Vec<UidSetMember>, Vec<UidSetMember>),
|
|
||||||
UidNotSticky,
|
|
||||||
}
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum UidSetMember {
|
|
||||||
UidRange(RangeInclusive<u32>),
|
|
||||||
Uid(u32),
|
|
||||||
}
|
|
||||||
impl From<RangeInclusive<u32>> for UidSetMember {
|
|
||||||
fn from(x: RangeInclusive<u32>) -> Self {
|
|
||||||
UidSetMember::UidRange(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<u32> for UidSetMember {
|
|
||||||
fn from(x: u32) -> Self {
|
|
||||||
UidSetMember::Uid(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum StatusAttribute {
|
|
||||||
HighestModSeq(u64), // RFC 4551
|
|
||||||
Messages(u32),
|
|
||||||
Recent(u32),
|
|
||||||
UidNext(u32),
|
|
||||||
UidValidity(u32),
|
|
||||||
Unseen(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
|
||||||
pub struct Metadata {
|
|
||||||
pub entry: String,
|
|
||||||
pub value: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub enum MailboxDatum<'a> {
|
|
||||||
Exists(u32),
|
|
||||||
Flags(Vec<&'a str>),
|
|
||||||
List {
|
|
||||||
flags: Vec<&'a str>,
|
|
||||||
delimiter: Option<&'a str>,
|
|
||||||
name: &'a str,
|
|
||||||
},
|
|
||||||
Search(Vec<u32>),
|
|
||||||
Status {
|
|
||||||
mailbox: &'a str,
|
|
||||||
status: Vec<StatusAttribute>,
|
|
||||||
},
|
|
||||||
Recent(u32),
|
|
||||||
MetadataSolicited {
|
|
||||||
mailbox: &'a str,
|
|
||||||
values: Vec<Metadata>,
|
|
||||||
},
|
|
||||||
MetadataUnsolicited {
|
|
||||||
mailbox: &'a str,
|
|
||||||
values: Vec<&'a str>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Hash)]
|
|
||||||
pub enum Capability<'a> {
|
|
||||||
Imap4rev1,
|
|
||||||
Auth(&'a str),
|
|
||||||
Atom(&'a str),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Attribute {
|
|
||||||
Body,
|
|
||||||
Envelope,
|
|
||||||
Flags,
|
|
||||||
InternalDate,
|
|
||||||
ModSeq, // RFC 4551, section 3.3.2
|
|
||||||
Rfc822,
|
|
||||||
Rfc822Size,
|
|
||||||
Rfc822Text,
|
|
||||||
Uid,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub enum MessageSection {
|
|
||||||
Header,
|
|
||||||
Mime,
|
|
||||||
Text,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub enum SectionPath {
|
|
||||||
Full(MessageSection),
|
|
||||||
Part(Vec<u32>, Option<MessageSection>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum AttributeValue<'a> {
|
|
||||||
BodySection {
|
|
||||||
section: Option<SectionPath>,
|
|
||||||
index: Option<u32>,
|
|
||||||
data: Option<&'a [u8]>,
|
|
||||||
},
|
|
||||||
BodyStructure(BodyStructure<'a>),
|
|
||||||
Envelope(Box<Envelope<'a>>),
|
|
||||||
Flags(Vec<&'a str>),
|
|
||||||
InternalDate(&'a str),
|
|
||||||
ModSeq(u64), // RFC 4551, section 3.3.2
|
|
||||||
Rfc822(Option<&'a [u8]>),
|
|
||||||
Rfc822Header(Option<&'a [u8]>),
|
|
||||||
Rfc822Size(u32),
|
|
||||||
Rfc822Text(Option<&'a [u8]>),
|
|
||||||
Uid(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub enum BodyStructure<'a> {
|
|
||||||
Basic {
|
|
||||||
common: BodyContentCommon<'a>,
|
|
||||||
other: BodyContentSinglePart<'a>,
|
|
||||||
extension: Option<BodyExtension<'a>>,
|
|
||||||
},
|
|
||||||
Text {
|
|
||||||
common: BodyContentCommon<'a>,
|
|
||||||
other: BodyContentSinglePart<'a>,
|
|
||||||
lines: u32,
|
|
||||||
extension: Option<BodyExtension<'a>>,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
common: BodyContentCommon<'a>,
|
|
||||||
other: BodyContentSinglePart<'a>,
|
|
||||||
envelope: Envelope<'a>,
|
|
||||||
body: Box<BodyStructure<'a>>,
|
|
||||||
lines: u32,
|
|
||||||
extension: Option<BodyExtension<'a>>,
|
|
||||||
},
|
|
||||||
Multipart {
|
|
||||||
common: BodyContentCommon<'a>,
|
|
||||||
bodies: Vec<BodyStructure<'a>>,
|
|
||||||
extension: Option<BodyExtension<'a>>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct BodyContentCommon<'a> {
|
|
||||||
pub ty: ContentType<'a>,
|
|
||||||
pub disposition: Option<ContentDisposition<'a>>,
|
|
||||||
pub language: Option<Vec<&'a str>>,
|
|
||||||
pub location: Option<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct BodyContentSinglePart<'a> {
|
|
||||||
pub id: Option<&'a str>,
|
|
||||||
pub md5: Option<&'a str>,
|
|
||||||
pub description: Option<&'a str>,
|
|
||||||
pub transfer_encoding: ContentEncoding<'a>,
|
|
||||||
pub octets: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct ContentType<'a> {
|
|
||||||
pub ty: &'a str,
|
|
||||||
pub subtype: &'a str,
|
|
||||||
pub params: BodyParams<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct ContentDisposition<'a> {
|
|
||||||
pub ty: &'a str,
|
|
||||||
pub params: BodyParams<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub enum ContentEncoding<'a> {
|
|
||||||
SevenBit,
|
|
||||||
EightBit,
|
|
||||||
Binary,
|
|
||||||
Base64,
|
|
||||||
QuotedPrintable,
|
|
||||||
Other(&'a str),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub enum BodyExtension<'a> {
|
|
||||||
Num(u32),
|
|
||||||
Str(Option<&'a str>),
|
|
||||||
List(Vec<BodyExtension<'a>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type BodyParams<'a> = Option<Vec<(&'a str, &'a str)>>;
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct Envelope<'a> {
|
|
||||||
pub date: Option<&'a [u8]>,
|
|
||||||
pub subject: Option<&'a [u8]>,
|
|
||||||
pub from: Option<Vec<Address<'a>>>,
|
|
||||||
pub sender: Option<Vec<Address<'a>>>,
|
|
||||||
pub reply_to: Option<Vec<Address<'a>>>,
|
|
||||||
pub to: Option<Vec<Address<'a>>>,
|
|
||||||
pub cc: Option<Vec<Address<'a>>>,
|
|
||||||
pub bcc: Option<Vec<Address<'a>>>,
|
|
||||||
pub in_reply_to: Option<&'a [u8]>,
|
|
||||||
pub message_id: Option<&'a [u8]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct Address<'a> {
|
|
||||||
pub name: Option<&'a [u8]>,
|
|
||||||
pub adl: Option<&'a [u8]>,
|
|
||||||
pub mailbox: Option<&'a [u8]>,
|
|
||||||
pub host: Option<&'a [u8]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub struct RequestId(pub String);
|
|
||||||
|
|
||||||
impl RequestId {
|
|
||||||
pub fn as_bytes(&self) -> &[u8] {
|
|
||||||
self.0.as_bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum State {
|
|
||||||
NotAuthenticated,
|
|
||||||
Authenticated,
|
|
||||||
Selected,
|
|
||||||
Logout,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body Structure
|
|
||||||
|
|
||||||
pub struct BodyFields<'a> {
|
|
||||||
pub param: BodyParams<'a>,
|
|
||||||
pub id: Option<&'a str>,
|
|
||||||
pub description: Option<&'a str>,
|
|
||||||
pub transfer_encoding: ContentEncoding<'a>,
|
|
||||||
pub octets: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BodyExt1Part<'a> {
|
|
||||||
pub md5: Option<&'a str>,
|
|
||||||
pub disposition: Option<ContentDisposition<'a>>,
|
|
||||||
pub language: Option<Vec<&'a str>>,
|
|
||||||
pub location: Option<&'a str>,
|
|
||||||
pub extension: Option<BodyExtension<'a>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BodyExtMPart<'a> {
|
|
||||||
pub param: BodyParams<'a>,
|
|
||||||
pub disposition: Option<ContentDisposition<'a>>,
|
|
||||||
pub language: Option<Vec<&'a str>>,
|
|
||||||
pub location: Option<&'a str>,
|
|
||||||
pub extension: Option<BodyExtension<'a>>,
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
CREATE TABLE IF NOT EXISTS "accounts" (
|
|
||||||
"name" TEXT PRIMARY KEY,
|
|
||||||
-- hash of the account details, used to check if accounts have changed
|
|
||||||
"checksum" TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "mail" (
|
|
||||||
"id" INTEGER PRIMARY KEY,
|
|
||||||
"internaldate" TEXT,
|
|
||||||
"message_id" TEXT,
|
|
||||||
"account" TEXT,
|
|
||||||
"folder" TEXT,
|
|
||||||
"uidvalidity" INTEGER,
|
|
||||||
"subject" TEXT,
|
|
||||||
"uid" INTEGER,
|
|
||||||
"filename" TEXT
|
|
||||||
);
|
|
39
notes.md
39
notes.md
|
@ -1,39 +0,0 @@
|
||||||
design ideas
|
|
||||||
---
|
|
||||||
|
|
||||||
- instead of dumb search, have like an omnibar with recency info built in?
|
|
||||||
- this requires some kind of cache and text search
|
|
||||||
- mail view has like a "filter stack"
|
|
||||||
- initially, this is empty, but when u do `/` u can add stuff like `acct:personal`, or `date<2020-03` or `has:attachment` or `from:*@gmail.com`
|
|
||||||
- then, when u hit enter, it gets added to a stack and u can like pop off filters
|
|
||||||
- example wld be liek `[acct:personal] [is:unread] [subject:"(?i)*github*"]` and then when u pop off the filter u just get `[acct:personal] [is:unread]`
|
|
||||||
- tmux-like windows
|
|
||||||
- maybe some of the familiar commands? `<C-b %>` for split for ex,
|
|
||||||
- gluon for scripting language
|
|
||||||
- hook into some global keybinds/hooks struct
|
|
||||||
- need commands:
|
|
||||||
- create dir
|
|
||||||
- move email to dir
|
|
||||||
- transparent self-updates?? this could work with some kind of deprecation scheme for the config files
|
|
||||||
- for ex: v1 has `{ x: Int }`, v2 has `{ [deprecated] x: Int, x2: Float }` and v3 has `{ x2: Float }`
|
|
||||||
this means v1 -> v2 upgrade can be done automatically but because there are _any_ pending deprecated values being used
|
|
||||||
it's not allowed to automatically upgrade to v3
|
|
||||||
- imap repl? or more realistically gluon repl that has an imap interface
|
|
||||||
- basically lets me debug imap commands on-the-spot, with the current connection
|
|
||||||
|
|
||||||
imap routine
|
|
||||||
---
|
|
||||||
|
|
||||||
- basic tcp connection is opened
|
|
||||||
- if tls is "on", then immediately perform tls handshake with the server
|
|
||||||
- if tls is "starttls", check starttls capability
|
|
||||||
- if the server doesn't have starttls capability, die and report to the user
|
|
||||||
- if the server _does_ have starttls, exit the read loop and perform tls handshake over current connection
|
|
||||||
- at this point, tls should be figured out, so moving on to auth
|
|
||||||
- check if the auth type that the user specified is in the list of auth types (prob support plain and oauth2?)
|
|
||||||
|
|
||||||
|
|
||||||
list of shit to do
|
|
||||||
---
|
|
||||||
|
|
||||||
- profile the client to see what needs to be improved?
|
|
8
panorama-core/Cargo.toml
Normal file
8
panorama-core/Cargo.toml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "panorama-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
5
panorama-core/src/lib.rs
Normal file
5
panorama-core/src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
pub struct Panorama {}
|
||||||
|
|
||||||
|
impl Panorama {
|
||||||
|
pub fn init() {}
|
||||||
|
}
|
13
panorama-gui/Cargo.toml
Normal file
13
panorama-gui/Cargo.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
[package]
|
||||||
|
name = "panorama-gui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.42"
|
||||||
|
iced = "0.3.0"
|
||||||
|
iced_native = "0.4.0"
|
||||||
|
structopt = "0.3.22"
|
||||||
|
tokio = { version = "1.8.2", features = ["full"] }
|
6
panorama-gui/src/main.rs
Normal file
6
panorama-gui/src/main.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
*
|
|
227
rfc/rfc2177.txt
227
rfc/rfc2177.txt
|
@ -1,227 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Network Working Group B. Leiba
|
|
||||||
Request for Comments: 2177 IBM T.J. Watson Research Center
|
|
||||||
Category: Standards Track June 1997
|
|
||||||
|
|
||||||
|
|
||||||
IMAP4 IDLE command
|
|
||||||
|
|
||||||
Status of this Memo
|
|
||||||
|
|
||||||
This document specifies an Internet standards track protocol for the
|
|
||||||
Internet community, and requests discussion and suggestions for
|
|
||||||
improvements. Please refer to the current edition of the "Internet
|
|
||||||
Official Protocol Standards" (STD 1) for the standardization state
|
|
||||||
and status of this protocol. Distribution of this memo is unlimited.
|
|
||||||
|
|
||||||
1. Abstract
|
|
||||||
|
|
||||||
The Internet Message Access Protocol [IMAP4] requires a client to
|
|
||||||
poll the server for changes to the selected mailbox (new mail,
|
|
||||||
deletions). It's often more desirable to have the server transmit
|
|
||||||
updates to the client in real time. This allows a user to see new
|
|
||||||
mail immediately. It also helps some real-time applications based on
|
|
||||||
IMAP, which might otherwise need to poll extremely often (such as
|
|
||||||
every few seconds). (While the spec actually does allow a server to
|
|
||||||
push EXISTS responses aysynchronously, a client can't expect this
|
|
||||||
behaviour and must poll.)
|
|
||||||
|
|
||||||
This document specifies the syntax of an IDLE command, which will
|
|
||||||
allow a client to tell the server that it's ready to accept such
|
|
||||||
real-time updates.
|
|
||||||
|
|
||||||
2. Conventions Used in this Document
|
|
||||||
|
|
||||||
In examples, "C:" and "S:" indicate lines sent by the client and
|
|
||||||
server respectively.
|
|
||||||
|
|
||||||
The key words "MUST", "MUST NOT", "SHOULD", "SHOULD NOT", and "MAY"
|
|
||||||
in this document are to be interpreted as described in RFC 2060
|
|
||||||
[IMAP4].
|
|
||||||
|
|
||||||
3. Specification
|
|
||||||
|
|
||||||
IDLE Command
|
|
||||||
|
|
||||||
Arguments: none
|
|
||||||
|
|
||||||
Responses: continuation data will be requested; the client sends
|
|
||||||
the continuation data "DONE" to end the command
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Leiba Standards Track [Page 1]
|
|
||||||
|
|
||||||
RFC 2177 IMAP4 IDLE command June 1997
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Result: OK - IDLE completed after client sent "DONE"
|
|
||||||
NO - failure: the server will not allow the IDLE
|
|
||||||
command at this time
|
|
||||||
BAD - command unknown or arguments invalid
|
|
||||||
|
|
||||||
The IDLE command may be used with any IMAP4 server implementation
|
|
||||||
that returns "IDLE" as one of the supported capabilities to the
|
|
||||||
CAPABILITY command. If the server does not advertise the IDLE
|
|
||||||
capability, the client MUST NOT use the IDLE command and must poll
|
|
||||||
for mailbox updates. In particular, the client MUST continue to be
|
|
||||||
able to accept unsolicited untagged responses to ANY command, as
|
|
||||||
specified in the base IMAP specification.
|
|
||||||
|
|
||||||
The IDLE command is sent from the client to the server when the
|
|
||||||
client is ready to accept unsolicited mailbox update messages. The
|
|
||||||
server requests a response to the IDLE command using the continuation
|
|
||||||
("+") response. The IDLE command remains active until the client
|
|
||||||
responds to the continuation, and as long as an IDLE command is
|
|
||||||
active, the server is now free to send untagged EXISTS, EXPUNGE, and
|
|
||||||
other messages at any time.
|
|
||||||
|
|
||||||
The IDLE command is terminated by the receipt of a "DONE"
|
|
||||||
continuation from the client; such response satisfies the server's
|
|
||||||
continuation request. At that point, the server MAY send any
|
|
||||||
remaining queued untagged responses and then MUST immediately send
|
|
||||||
the tagged response to the IDLE command and prepare to process other
|
|
||||||
commands. As in the base specification, the processing of any new
|
|
||||||
command may cause the sending of unsolicited untagged responses,
|
|
||||||
subject to the ambiguity limitations. The client MUST NOT send a
|
|
||||||
command while the server is waiting for the DONE, since the server
|
|
||||||
will not be able to distinguish a command from a continuation.
|
|
||||||
|
|
||||||
The server MAY consider a client inactive if it has an IDLE command
|
|
||||||
running, and if such a server has an inactivity timeout it MAY log
|
|
||||||
the client off implicitly at the end of its timeout period. Because
|
|
||||||
of that, clients using IDLE are advised to terminate the IDLE and
|
|
||||||
re-issue it at least every 29 minutes to avoid being logged off.
|
|
||||||
This still allows a client to receive immediate mailbox updates even
|
|
||||||
though it need only "poll" at half hour intervals.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Leiba Standards Track [Page 2]
|
|
||||||
|
|
||||||
RFC 2177 IMAP4 IDLE command June 1997
|
|
||||||
|
|
||||||
|
|
||||||
Example: C: A001 SELECT INBOX
|
|
||||||
S: * FLAGS (Deleted Seen)
|
|
||||||
S: * 3 EXISTS
|
|
||||||
S: * 0 RECENT
|
|
||||||
S: * OK [UIDVALIDITY 1]
|
|
||||||
S: A001 OK SELECT completed
|
|
||||||
C: A002 IDLE
|
|
||||||
S: + idling
|
|
||||||
...time passes; new mail arrives...
|
|
||||||
S: * 4 EXISTS
|
|
||||||
C: DONE
|
|
||||||
S: A002 OK IDLE terminated
|
|
||||||
...another client expunges message 2 now...
|
|
||||||
C: A003 FETCH 4 ALL
|
|
||||||
S: * 4 FETCH (...)
|
|
||||||
S: A003 OK FETCH completed
|
|
||||||
C: A004 IDLE
|
|
||||||
S: * 2 EXPUNGE
|
|
||||||
S: * 3 EXISTS
|
|
||||||
S: + idling
|
|
||||||
...time passes; another client expunges message 3...
|
|
||||||
S: * 3 EXPUNGE
|
|
||||||
S: * 2 EXISTS
|
|
||||||
...time passes; new mail arrives...
|
|
||||||
S: * 3 EXISTS
|
|
||||||
C: DONE
|
|
||||||
S: A004 OK IDLE terminated
|
|
||||||
C: A005 FETCH 3 ALL
|
|
||||||
S: * 3 FETCH (...)
|
|
||||||
S: A005 OK FETCH completed
|
|
||||||
C: A006 IDLE
|
|
||||||
|
|
||||||
4. Formal Syntax
|
|
||||||
|
|
||||||
The following syntax specification uses the augmented Backus-Naur
|
|
||||||
Form (BNF) notation as specified in [RFC-822] as modified by [IMAP4].
|
|
||||||
Non-terminals referenced but not defined below are as defined by
|
|
||||||
[IMAP4].
|
|
||||||
|
|
||||||
command_auth ::= append / create / delete / examine / list / lsub /
|
|
||||||
rename / select / status / subscribe / unsubscribe
|
|
||||||
/ idle
|
|
||||||
;; Valid only in Authenticated or Selected state
|
|
||||||
|
|
||||||
idle ::= "IDLE" CRLF "DONE"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Leiba Standards Track [Page 3]
|
|
||||||
|
|
||||||
RFC 2177 IMAP4 IDLE command June 1997
|
|
||||||
|
|
||||||
|
|
||||||
5. References
|
|
||||||
|
|
||||||
[IMAP4] Crispin, M., "Internet Message Access Protocol - Version
|
|
||||||
4rev1", RFC 2060, December 1996.
|
|
||||||
|
|
||||||
6. Security Considerations
|
|
||||||
|
|
||||||
There are no known security issues with this extension.
|
|
||||||
|
|
||||||
7. Author's Address
|
|
||||||
|
|
||||||
Barry Leiba
|
|
||||||
IBM T.J. Watson Research Center
|
|
||||||
30 Saw Mill River Road
|
|
||||||
Hawthorne, NY 10532
|
|
||||||
|
|
||||||
Email: leiba@watson.ibm.com
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Leiba Standards Track [Page 4]
|
|
||||||
|
|
6051
rfc/rfc3501.txt
6051
rfc/rfc3501.txt
File diff suppressed because it is too large
Load diff
|
@ -1,8 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "panorama-smtp"
|
|
||||||
version = "0.1.0"
|
|
||||||
authors = ["Michael Zhang <mail@mzhang.io>"]
|
|
||||||
edition = "2018"
|
|
||||||
license = "GPL-3.0-or-later"
|
|
||||||
|
|
||||||
[dependencies]
|
|
|
@ -1,22 +0,0 @@
|
||||||
SMTP
|
|
||||||
===
|
|
||||||
|
|
||||||
here's the list of RFCs planning to be supported and the status of the
|
|
||||||
implementation of their commands:
|
|
||||||
|
|
||||||
- RFC5321 (SMTP)
|
|
||||||
- HELO: not yet implemented
|
|
||||||
- EHLO: not yet implemented
|
|
||||||
- MAIL: not yet implemented
|
|
||||||
- RCPT: not yet implemented
|
|
||||||
- DATA: not yet implemented
|
|
||||||
- RSET: not yet implemented
|
|
||||||
- VRFY: not yet implemented
|
|
||||||
- EXPN: not yet implemented
|
|
||||||
- HELP: not yet implemented
|
|
||||||
- NOOP: not yet implemented
|
|
||||||
- QUIT: not yet implemented
|
|
||||||
- RFC3207 (SMTP STARTTLS)
|
|
||||||
- STARTTLS: not yet implemented
|
|
||||||
- RFC2554 (SMTP AUTH)
|
|
||||||
- AUTH: not yet implemented
|
|
|
@ -1,7 +0,0 @@
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
#[test]
|
|
||||||
fn it_works() {
|
|
||||||
assert_eq!(2 + 2, 4);
|
|
||||||
}
|
|
||||||
}
|
|
172
src/config.rs
172
src/config.rs
|
@ -1,172 +0,0 @@
|
||||||
//! Module for setting up config files and watchers.
|
|
||||||
//!
|
|
||||||
//! One of the primary goals of panorama is to be able to always hot-reload configuration files.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs::{self, File};
|
|
||||||
use std::io::Read;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use futures::{future::TryFutureExt, stream::StreamExt};
|
|
||||||
use inotify::{Inotify, WatchMask};
|
|
||||||
use tokio::{sync::watch, task::JoinHandle};
|
|
||||||
use xdg::BaseDirectories;
|
|
||||||
|
|
||||||
use crate::report_err;
|
|
||||||
|
|
||||||
/// Alias for a MailConfig receiver.
|
|
||||||
pub type ConfigWatcher = watch::Receiver<Config>;
|
|
||||||
|
|
||||||
/// Configuration
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
|
||||||
pub struct Config {
|
|
||||||
/// Version of the config to use
|
|
||||||
/// (potentially for migration later?)
|
|
||||||
pub version: String,
|
|
||||||
|
|
||||||
/// Directory to store panorama-related data in
|
|
||||||
pub data_dir: PathBuf,
|
|
||||||
|
|
||||||
/// Mail accounts
|
|
||||||
#[serde(rename = "mail")]
|
|
||||||
pub mail_accounts: HashMap<String, MailAccountConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for a single mail account
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
||||||
pub struct MailAccountConfig {
|
|
||||||
/// Imap
|
|
||||||
pub imap: ImapConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuring an IMAP server
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
||||||
pub struct ImapConfig {
|
|
||||||
/// Host of the IMAP server (needs to be hostname for TLS)
|
|
||||||
pub server: String,
|
|
||||||
|
|
||||||
/// Port of the IMAP server
|
|
||||||
pub port: u16,
|
|
||||||
|
|
||||||
/// TLS
|
|
||||||
pub tls: TlsMethod,
|
|
||||||
|
|
||||||
/// Auth
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub auth: ImapAuth,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Method of authentication for the IMAP server
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
||||||
#[serde(tag = "auth")]
|
|
||||||
pub enum ImapAuth {
|
|
||||||
/// Use plain username/password authentication
|
|
||||||
#[serde(rename = "plain")]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
Plain { username: String, password: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Describes when to perform the TLS handshake
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
||||||
pub enum TlsMethod {
|
|
||||||
/// Perform TLS handshake immediately upon connection
|
|
||||||
#[serde(rename = "on")]
|
|
||||||
On,
|
|
||||||
|
|
||||||
/// Perform TLS handshake after issuing the STARTTLS command
|
|
||||||
#[serde(rename = "starttls")]
|
|
||||||
Starttls,
|
|
||||||
|
|
||||||
/// Don't perform TLS handshake at all (unsecured)
|
|
||||||
#[serde(rename = "off")]
|
|
||||||
Off,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_config(path: impl AsRef<Path>) -> Result<Config> {
|
|
||||||
let mut file = File::open(path.as_ref())?;
|
|
||||||
let mut contents = Vec::new();
|
|
||||||
file.read_to_end(&mut contents)?;
|
|
||||||
|
|
||||||
let config = toml::from_slice(&contents)?;
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_inotify_stream(
|
|
||||||
mut inotify: Inotify,
|
|
||||||
config_home: impl AsRef<Path>,
|
|
||||||
config_tx: watch::Sender<Config>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut buffer = vec![0; 1024];
|
|
||||||
let mut event_stream = inotify.event_stream(&mut buffer)?;
|
|
||||||
let config_home = config_home.as_ref().to_path_buf();
|
|
||||||
let config_path = config_home.join("panorama.toml");
|
|
||||||
|
|
||||||
// first shot
|
|
||||||
{
|
|
||||||
let config = read_config(&config_path).await?;
|
|
||||||
config_tx.send(config)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("listening for inotify events");
|
|
||||||
while let Some(v) = event_stream.next().await {
|
|
||||||
let event = v.context("event")?;
|
|
||||||
debug!("inotify event: {:?}", event);
|
|
||||||
|
|
||||||
if let Some(name) = event.name {
|
|
||||||
let path = PathBuf::from(name);
|
|
||||||
let path_c = config_home
|
|
||||||
.clone()
|
|
||||||
.join(path.clone())
|
|
||||||
.canonicalize()
|
|
||||||
.context("osu")?;
|
|
||||||
if !path_c.exists() {
|
|
||||||
debug!("path {:?} doesn't exist", path_c);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: any better way to do this?
|
|
||||||
let config_path_c = config_path.canonicalize().context("cfg_path")?;
|
|
||||||
if config_path_c != path_c {
|
|
||||||
debug!("did not match {:?} {:?}", config_path_c, path_c);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("reading config from {:?}", path_c);
|
|
||||||
let config = read_config(path_c).await.context("read")?;
|
|
||||||
// debug!("sending config {:?}", config);
|
|
||||||
config_tx.send(config)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the entire config watcher system, and return a [ConfigWatcher][self::ConfigWatcher],
|
|
||||||
/// which is a cloneable receiver of config update events.
|
|
||||||
pub fn spawn_config_watcher_system() -> Result<(JoinHandle<()>, ConfigWatcher)> {
|
|
||||||
let mut inotify = Inotify::init()?;
|
|
||||||
|
|
||||||
let xdg = BaseDirectories::new()?;
|
|
||||||
let config_home = xdg.get_config_home().join("panorama");
|
|
||||||
if !config_home.exists() {
|
|
||||||
fs::create_dir_all(&config_home)?;
|
|
||||||
}
|
|
||||||
inotify
|
|
||||||
.add_watch(&config_home, WatchMask::CLOSE_WRITE)
|
|
||||||
.context("adding watch for config home")?;
|
|
||||||
|
|
||||||
// let config_file_path = config_home.join("panorama.toml");
|
|
||||||
// if config_file_path.exists() {
|
|
||||||
// inotify
|
|
||||||
// .add_watch(config_file_path, WatchMask::ALL_EVENTS)
|
|
||||||
// .context("adding watch for config file")?;
|
|
||||||
// }
|
|
||||||
debug!("watching {:?}", config_home);
|
|
||||||
|
|
||||||
let (config_tx, config_update) = watch::channel(Config::default());
|
|
||||||
let handle = tokio::spawn(
|
|
||||||
start_inotify_stream(inotify, config_home, config_tx).unwrap_or_else(report_err),
|
|
||||||
);
|
|
||||||
Ok((handle, config_update))
|
|
||||||
}
|
|
31
src/lib.rs
31
src/lib.rs
|
@ -1,31 +0,0 @@
|
||||||
//! Panorama
|
|
||||||
//! ===
|
|
||||||
|
|
||||||
#![deny(unsafe_code)]
|
|
||||||
#![deny(missing_docs)]
|
|
||||||
// TODO: get rid of this before any kind of public release
|
|
||||||
#![allow(unused_imports, unused_variables)]
|
|
||||||
|
|
||||||
#[macro_use]
|
|
||||||
extern crate anyhow;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate async_trait;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate format_bytes;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate serde;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate sqlx;
|
|
||||||
#[macro_use]
|
|
||||||
extern crate log;
|
|
||||||
|
|
||||||
pub mod config;
|
|
||||||
pub mod mail;
|
|
||||||
pub mod script;
|
|
||||||
pub mod search;
|
|
||||||
pub mod ui;
|
|
||||||
|
|
||||||
/// Consumes any error and dumps it to the logger.
|
|
||||||
pub fn report_err(err: anyhow::Error) {
|
|
||||||
error!("error: {}", err);
|
|
||||||
}
|
|
|
@ -1,205 +0,0 @@
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use futures::{
|
|
||||||
future::{FutureExt, TryFutureExt},
|
|
||||||
stream::{self, Stream, StreamExt, TryStreamExt},
|
|
||||||
};
|
|
||||||
use notify_rust::{Notification, Timeout};
|
|
||||||
use panorama_imap::{
|
|
||||||
client::{
|
|
||||||
auth::{self, Auth},
|
|
||||||
ClientBuilder, ClientConfig,
|
|
||||||
},
|
|
||||||
command::{Command as ImapCommand, FetchItems},
|
|
||||||
response::{AttributeValue, Envelope, MailboxData, Response},
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
|
||||||
task::JoinHandle,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::config::{Config, ConfigWatcher, ImapAuth, MailAccountConfig, TlsMethod};
|
|
||||||
|
|
||||||
use super::{MailCommand, MailEvent, MailStore};
|
|
||||||
|
|
||||||
/// The main function for the IMAP syncing thread
|
|
||||||
pub async fn sync_main(
|
|
||||||
config: Config,
|
|
||||||
acct_name: impl AsRef<str>,
|
|
||||||
acct: MailAccountConfig,
|
|
||||||
mail2ui_tx: UnboundedSender<MailEvent>,
|
|
||||||
mail_store: MailStore,
|
|
||||||
) -> Result<()> {
|
|
||||||
let acct_name = acct_name.as_ref().to_owned();
|
|
||||||
|
|
||||||
// loop ensures that the connection is retried after it dies
|
|
||||||
loop {
|
|
||||||
let builder: ClientConfig = ClientBuilder::default()
|
|
||||||
.hostname(acct.imap.server.clone())
|
|
||||||
.port(acct.imap.port)
|
|
||||||
.tls(matches!(acct.imap.tls, TlsMethod::On))
|
|
||||||
.build()
|
|
||||||
.map_err(|err| anyhow!("err: {}", err))?;
|
|
||||||
|
|
||||||
debug!("connecting to {}:{}", &acct.imap.server, acct.imap.port);
|
|
||||||
let unauth = builder.open().await?;
|
|
||||||
|
|
||||||
let unauth = if matches!(acct.imap.tls, TlsMethod::Starttls) {
|
|
||||||
debug!("attempting to upgrade");
|
|
||||||
let client = unauth.upgrade().await?;
|
|
||||||
debug!("upgrade successful");
|
|
||||||
client
|
|
||||||
} else {
|
|
||||||
unauth
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!("preparing to auth");
|
|
||||||
// check if the authentication method is supported
|
|
||||||
let mut authed = match &acct.imap.auth {
|
|
||||||
ImapAuth::Plain { username, password } => {
|
|
||||||
let auth = auth::Plain {
|
|
||||||
username: username.clone(),
|
|
||||||
password: password.clone(),
|
|
||||||
};
|
|
||||||
auth.perform_auth(unauth).await?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!("authentication successful!");
|
|
||||||
|
|
||||||
let folder_list = authed.list().await?;
|
|
||||||
let _ = mail2ui_tx.send(MailEvent::FolderList(
|
|
||||||
acct_name.clone(),
|
|
||||||
folder_list.clone(),
|
|
||||||
));
|
|
||||||
debug!("mailbox list: {:?}", folder_list);
|
|
||||||
|
|
||||||
for folder in folder_list.iter() {
|
|
||||||
debug!("folder: {}", folder);
|
|
||||||
let select = authed.select(folder).await?;
|
|
||||||
debug!("select response: {:?}", select);
|
|
||||||
|
|
||||||
if let (Some(exists), Some(uidvalidity)) = (select.exists, select.uid_validity) {
|
|
||||||
// figure out which uids don't exist locally yet
|
|
||||||
let new_uids = stream::iter(1..exists).map(Ok).try_filter_map(|uid| {
|
|
||||||
mail_store.try_identify_email(&acct_name, &folder, uid, uidvalidity, None)
|
|
||||||
// invert the option to only select uids that haven't been downloaded
|
|
||||||
.map_ok(move |o| o.map_or_else(move || Some(uid), |v| None))
|
|
||||||
.map_err(|err| err.context("error checking if the email is already downloaded [try_identify_email]"))
|
|
||||||
}).try_collect::<Vec<_>>().await?;
|
|
||||||
|
|
||||||
if !new_uids.is_empty() {
|
|
||||||
debug!("fetching uids {:?}", new_uids);
|
|
||||||
let fetched = authed
|
|
||||||
.uid_fetch(&new_uids, FetchItems::PanoramaAll)
|
|
||||||
.await
|
|
||||||
.context("error fetching uids")?;
|
|
||||||
|
|
||||||
fetched
|
|
||||||
.map(Ok)
|
|
||||||
.try_for_each_concurrent(None, |(uid, attrs)| {
|
|
||||||
mail_store.store_email(&acct_name, &folder, uid, uidvalidity, attrs)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.context("error during fetch-store")?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(50)).await;
|
|
||||||
|
|
||||||
// TODO: remove this later
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// let's just select INBOX for now, maybe have a config for default mailbox later?
|
|
||||||
debug!("selecting the INBOX mailbox");
|
|
||||||
let select = authed.select("INBOX").await?;
|
|
||||||
debug!("select result: {:?}", select);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let message_uids = authed.uid_search().await?;
|
|
||||||
let message_uids = message_uids.into_iter().take(30).collect::<Vec<_>>();
|
|
||||||
let _ = mail2ui_tx.send(MailEvent::MessageUids(
|
|
||||||
acct_name.clone(),
|
|
||||||
message_uids.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// TODO: make this happen concurrently with the main loop?
|
|
||||||
let mut message_list = authed
|
|
||||||
.uid_fetch(&message_uids, FetchItems::All)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
while let Some((uid, attrs)) = message_list.next().await {
|
|
||||||
let evt = MailEvent::UpdateUid(acct_name.clone(), uid, attrs);
|
|
||||||
// TODO: probably odn't care about this?
|
|
||||||
let _ = mail2ui_tx.send(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if IDLE is supported
|
|
||||||
let supports_idle = authed.has_capability("IDLE").await?;
|
|
||||||
if supports_idle {
|
|
||||||
let mut idle_stream = authed.idle().await?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let evt = match idle_stream.next().await {
|
|
||||||
Some(v) => v,
|
|
||||||
None => break,
|
|
||||||
};
|
|
||||||
debug!("got an event: {:?}", evt);
|
|
||||||
|
|
||||||
match evt {
|
|
||||||
Response::MailboxData(MailboxData::Exists(uid)) => {
|
|
||||||
debug!("NEW MESSAGE WITH UID {:?}, droping everything", uid);
|
|
||||||
// send DONE to stop the idle
|
|
||||||
std::mem::drop(idle_stream);
|
|
||||||
|
|
||||||
let handle = Notification::new()
|
|
||||||
.summary("New Email")
|
|
||||||
.body("holy Shit,")
|
|
||||||
.icon("firefox")
|
|
||||||
.timeout(Timeout::Milliseconds(6000))
|
|
||||||
.show()?;
|
|
||||||
|
|
||||||
let message_uids = authed.uid_search().await?;
|
|
||||||
let message_uids =
|
|
||||||
message_uids.into_iter().take(20).collect::<Vec<_>>();
|
|
||||||
let _ = mail2ui_tx.send(MailEvent::MessageUids(
|
|
||||||
acct_name.clone(),
|
|
||||||
message_uids.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// TODO: make this happen concurrently with the main loop?
|
|
||||||
let mut message_list = authed
|
|
||||||
.uid_fetch(&message_uids, FetchItems::All)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
while let Some((uid, attrs)) = message_list.next().await {
|
|
||||||
let evt = MailEvent::UpdateUid(acct_name.clone(), uid, attrs);
|
|
||||||
// debug!("sent {:?}", evt);
|
|
||||||
mail2ui_tx.send(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
idle_stream = authed.idle().await?;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(20)).await;
|
|
||||||
debug!("heartbeat");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if false {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait a bit so we're not hitting the server really fast if the fail happens
|
|
||||||
// early on
|
|
||||||
//
|
|
||||||
// TODO: some kind of smart exponential backoff that considers some time
|
|
||||||
// threshold to be a failing case?
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
use panorama_imap::response::{AttributeValue, Envelope};
|
|
||||||
|
|
||||||
/// Possible events returned from the server that should be sent to the UI
|
|
||||||
#[derive(Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum MailEvent {
|
|
||||||
/// Got the list of folders
|
|
||||||
FolderList(String, Vec<String>),
|
|
||||||
|
|
||||||
/// A list of the UIDs in the current mail view
|
|
||||||
MessageUids(String, Vec<u32>),
|
|
||||||
|
|
||||||
/// Update the given UID with the given attribute list
|
|
||||||
UpdateUid(String, u32, Vec<AttributeValue>),
|
|
||||||
|
|
||||||
/// New message came in with given UID
|
|
||||||
NewUid(String, u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailEvent {
|
|
||||||
/// Retrieves the account name that this event is associated with
|
|
||||||
pub fn acct_name(&self) -> &str {
|
|
||||||
use MailEvent::*;
|
|
||||||
match self {
|
|
||||||
FolderList(name, _)
|
|
||||||
| MessageUids(name, _)
|
|
||||||
| UpdateUid(name, _, _)
|
|
||||||
| NewUid(name, _) => name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,73 +0,0 @@
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Local};
|
|
||||||
use panorama_imap::response::*;
|
|
||||||
|
|
||||||
/// A record that describes the metadata of an email as it appears in the UI list
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct EmailMetadata {
|
|
||||||
/// UID if the message has one
|
|
||||||
pub uid: Option<u32>,
|
|
||||||
|
|
||||||
/// Whether or not this message is unread
|
|
||||||
pub unread: bool,
|
|
||||||
|
|
||||||
/// Date
|
|
||||||
pub date: Option<DateTime<Local>>,
|
|
||||||
|
|
||||||
/// Sender
|
|
||||||
pub from: String,
|
|
||||||
|
|
||||||
/// Subject
|
|
||||||
pub subject: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmailMetadata {
|
|
||||||
/// Construct an EmailMetadata from a list of attributes retrieved from the server
|
|
||||||
pub fn from_attrs(attrs: Vec<AttributeValue>) -> Self {
|
|
||||||
let mut meta = EmailMetadata::default();
|
|
||||||
|
|
||||||
for attr in attrs {
|
|
||||||
match attr {
|
|
||||||
AttributeValue::Flags(flags) => {
|
|
||||||
let flags = flags.into_iter().collect::<HashSet<_>>();
|
|
||||||
if !flags.contains(&MailboxFlag::Seen) {
|
|
||||||
meta.unread = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AttributeValue::Uid(new_uid) => meta.uid = Some(new_uid),
|
|
||||||
AttributeValue::InternalDate(new_date) => {
|
|
||||||
meta.date = Some(new_date.with_timezone(&Local));
|
|
||||||
}
|
|
||||||
AttributeValue::Envelope(Envelope {
|
|
||||||
subject: new_subject,
|
|
||||||
from: new_from,
|
|
||||||
..
|
|
||||||
}) => {
|
|
||||||
if let Some(new_from) = new_from {
|
|
||||||
meta.from = new_from
|
|
||||||
.iter()
|
|
||||||
.filter_map(|addr| addr.name.to_owned())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
if let Some(new_subject) = new_subject {
|
|
||||||
// TODO: probably shouldn't insert quoted-printable here
|
|
||||||
// but this is just the most convenient for it to look right at the moment
|
|
||||||
// there's probably some header that indicates it's quoted-printable
|
|
||||||
// MIME?
|
|
||||||
use quoted_printable::ParseMode;
|
|
||||||
let new_subject =
|
|
||||||
quoted_printable::decode(new_subject.as_bytes(), ParseMode::Robust)
|
|
||||||
.unwrap();
|
|
||||||
let new_subject = String::from_utf8(new_subject).unwrap();
|
|
||||||
meta.subject = new_subject;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
meta
|
|
||||||
}
|
|
||||||
}
|
|
113
src/mail/mod.rs
113
src/mail/mod.rs
|
@ -1,113 +0,0 @@
|
||||||
//! Mail
|
|
||||||
|
|
||||||
mod client;
|
|
||||||
mod event;
|
|
||||||
mod metadata;
|
|
||||||
pub mod store;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures::{
|
|
||||||
future::FutureExt,
|
|
||||||
stream::{Stream, StreamExt},
|
|
||||||
};
|
|
||||||
use notify_rust::{Notification, Timeout};
|
|
||||||
use panorama_imap::{
|
|
||||||
client::{
|
|
||||||
auth::{self, Auth},
|
|
||||||
ClientBuilder, ClientConfig,
|
|
||||||
},
|
|
||||||
command::Command as ImapCommand,
|
|
||||||
response::{AttributeValue, Envelope, MailboxData, Response},
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
|
||||||
task::JoinHandle,
|
|
||||||
};
|
|
||||||
use tokio_stream::wrappers::WatchStream;
|
|
||||||
|
|
||||||
use crate::config::{Config, ConfigWatcher, ImapAuth, MailAccountConfig, TlsMethod};
|
|
||||||
|
|
||||||
pub use self::event::MailEvent;
|
|
||||||
pub use self::metadata::EmailMetadata;
|
|
||||||
pub use self::store::MailStore;
|
|
||||||
|
|
||||||
/// Command sent to the mail thread by something else (i.e. UI)
|
|
||||||
#[derive(Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum MailCommand {
|
|
||||||
/// Refresh the list
|
|
||||||
Refresh,
|
|
||||||
|
|
||||||
/// Send a raw command
|
|
||||||
Raw(ImapCommand),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main entrypoint for the mail listener.
|
|
||||||
pub async fn run_mail(
|
|
||||||
mail_store: MailStore,
|
|
||||||
mut config_watcher: ConfigWatcher,
|
|
||||||
ui2mail_rx: UnboundedReceiver<MailCommand>,
|
|
||||||
mail2ui_tx: UnboundedSender<MailEvent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut curr_conn: Vec<JoinHandle<_>> = Vec::new();
|
|
||||||
|
|
||||||
// let mut config_watcher = WatchStream::new(config_watcher);
|
|
||||||
loop {
|
|
||||||
debug!("listening for configs");
|
|
||||||
let config: Config = match config_watcher.changed().await {
|
|
||||||
Ok(_) => config_watcher.borrow().clone(),
|
|
||||||
_ => break,
|
|
||||||
};
|
|
||||||
debug!("got");
|
|
||||||
|
|
||||||
// TODO: gracefully shut down connection
|
|
||||||
// just gonna drop the connection for now
|
|
||||||
// FUTURE TODO: possible to hash the connections and only reconn the ones that changed
|
|
||||||
debug!("dropping all connections...");
|
|
||||||
for conn in curr_conn.drain(0..) {
|
|
||||||
conn.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (acct_name, acct) in config.mail_accounts.clone().into_iter() {
|
|
||||||
let mail2ui_tx = mail2ui_tx.clone();
|
|
||||||
let mail_store = mail_store.clone();
|
|
||||||
let config2 = config.clone();
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
// debug!("opening imap connection for {:?}", acct);
|
|
||||||
|
|
||||||
// this loop is to make sure accounts are restarted on error
|
|
||||||
loop {
|
|
||||||
match client::sync_main(
|
|
||||||
config2.clone(),
|
|
||||||
&acct_name,
|
|
||||||
acct.clone(),
|
|
||||||
mail2ui_tx.clone(),
|
|
||||||
mail_store.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(err) => {
|
|
||||||
error!("error from sync_main: {}", err);
|
|
||||||
for err in err.chain() {
|
|
||||||
error!("cause: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warn!("connection dropped, retrying");
|
|
||||||
|
|
||||||
// wait a bit so we're not hitting the server really fast if the fail happens
|
|
||||||
// early on
|
|
||||||
//
|
|
||||||
// TODO: some kind of smart exponential backoff that considers some time
|
|
||||||
// threshold to be a failing case?
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
curr_conn.push(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,423 +0,0 @@
|
||||||
//! Module for managing the offline storage of emails
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::mem;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Error, Result};
|
|
||||||
use chrono::{DateTime, Local};
|
|
||||||
use futures::{
|
|
||||||
future::{self, FutureExt, TryFutureExt},
|
|
||||||
stream::{StreamExt, TryStreamExt},
|
|
||||||
};
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
use panorama_imap::response::AttributeValue;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use sqlx::{
|
|
||||||
migrate::{MigrateDatabase, Migrator},
|
|
||||||
sqlite::{Sqlite, SqlitePool},
|
|
||||||
Error as SqlxError, Row,
|
|
||||||
};
|
|
||||||
use tokio::{
|
|
||||||
fs,
|
|
||||||
sync::{broadcast, watch, RwLock},
|
|
||||||
task::JoinHandle,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::config::{Config, ConfigWatcher};
|
|
||||||
|
|
||||||
use super::{EmailMetadata, MailEvent};
|
|
||||||
|
|
||||||
static MIGRATOR: Migrator = sqlx::migrate!();
|
|
||||||
|
|
||||||
/// Manages email storage on disk, for both database and caches
|
|
||||||
///
|
|
||||||
/// This struct is clone-safe: cloning it will just return a reference to the same data structure
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct MailStore {
|
|
||||||
config: Arc<RwLock<Option<Config>>>,
|
|
||||||
inner: Arc<RwLock<Option<MailStoreInner>>>,
|
|
||||||
handle: Arc<JoinHandle<()>>,
|
|
||||||
store_out_tx: Arc<watch::Sender<Option<MailStoreUpdate>>>,
|
|
||||||
|
|
||||||
/// A receiver for listening to updates to the mail store
|
|
||||||
pub store_out_rx: watch::Receiver<Option<MailStoreUpdate>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// This is associated with a particular config. When the config is updated, this gets replaced
|
|
||||||
struct MailStoreInner {
|
|
||||||
pool: SqlitePool,
|
|
||||||
mail_dir: PathBuf,
|
|
||||||
accounts: IndexMap<String, Arc<AccountRef>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
/// Probably an event about new emails? i forgot
|
|
||||||
pub enum MailStoreUpdate {
|
|
||||||
/// The list of accounts has been updated (probably as a result of a config update)
|
|
||||||
AccountListUpdate(()),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailStore {
|
|
||||||
/// Creates a new MailStore
|
|
||||||
pub fn new(config_watcher: ConfigWatcher) -> Self {
|
|
||||||
let config = Arc::new(RwLock::new(None));
|
|
||||||
let config2 = config.clone();
|
|
||||||
let inner = Arc::new(RwLock::new(None));
|
|
||||||
let inner2 = inner.clone();
|
|
||||||
|
|
||||||
let (store_out_tx, store_out_rx) = watch::channel(None);
|
|
||||||
let store_out_tx = Arc::new(store_out_tx);
|
|
||||||
let store_out_tx2 = store_out_tx.clone();
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
match mail_store_config_listener(config_watcher, config2, inner2, store_out_tx2).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
error!("mail store listener error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
MailStore {
|
|
||||||
config,
|
|
||||||
inner,
|
|
||||||
handle: Arc::new(handle),
|
|
||||||
store_out_tx,
|
|
||||||
store_out_rx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nuke all messages with an invalid UIDVALIDITY
|
|
||||||
pub async fn nuke_old_uidvalidity(&self, current: usize) {}
|
|
||||||
|
|
||||||
/// Given a UID and optional message-id try to identify a particular message
|
|
||||||
pub async fn try_identify_email(
|
|
||||||
&self,
|
|
||||||
acct: impl AsRef<str>,
|
|
||||||
folder: impl AsRef<str>,
|
|
||||||
uid: u32,
|
|
||||||
uidvalidity: u32,
|
|
||||||
message_id: Option<&str>,
|
|
||||||
) -> Result<Option<u32>> {
|
|
||||||
let read = self.inner.read().await;
|
|
||||||
let inner = match &*read {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
let existing: Option<(u32,)> = into_opt(
|
|
||||||
sqlx::query_as(
|
|
||||||
r#"
|
|
||||||
SELECT rowid FROM "mail"
|
|
||||||
WHERE account = ? AND folder = ?
|
|
||||||
AND uid = ? AND uidvalidity = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(acct.as_ref())
|
|
||||||
.bind(folder.as_ref())
|
|
||||||
.bind(uid)
|
|
||||||
.bind(uidvalidity)
|
|
||||||
.fetch_one(&inner.pool)
|
|
||||||
.await,
|
|
||||||
)?;
|
|
||||||
mem::drop(read);
|
|
||||||
|
|
||||||
if let Some(existing) = existing {
|
|
||||||
let rowid = existing.0;
|
|
||||||
return Ok(Some(rowid));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stores the given email
|
|
||||||
pub async fn store_email(
|
|
||||||
&self,
|
|
||||||
acct: impl AsRef<str>,
|
|
||||||
folder: impl AsRef<str>,
|
|
||||||
uid: u32,
|
|
||||||
uidvalidity: u32,
|
|
||||||
attrs: Vec<AttributeValue>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut body = None;
|
|
||||||
let mut internaldate = None;
|
|
||||||
for attr in attrs {
|
|
||||||
match attr {
|
|
||||||
AttributeValue::BodySection(body_attr) => body = body_attr.data,
|
|
||||||
AttributeValue::InternalDate(date) => internaldate = Some(date),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = match body {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
let internaldate = match internaldate {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(body.as_bytes());
|
|
||||||
let hash = hasher.finalize();
|
|
||||||
let filename = format!("{}.mail", hex::encode(hash));
|
|
||||||
let path = {
|
|
||||||
match &*self.inner.read().await {
|
|
||||||
Some(inner) => inner.mail_dir.join(&filename),
|
|
||||||
None => return Ok(()),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fs::write(path, &body)
|
|
||||||
.await
|
|
||||||
.context("error writing email to file")?;
|
|
||||||
|
|
||||||
// parse email
|
|
||||||
let mut message_id = None;
|
|
||||||
let mut subject = None;
|
|
||||||
let mail = mailparse::parse_mail(body.as_bytes())
|
|
||||||
.with_context(|| format!("error parsing email with uid {}", uid))?;
|
|
||||||
for header in mail.headers.iter() {
|
|
||||||
let key = header.get_key_ref();
|
|
||||||
let value = header.get_value();
|
|
||||||
match key.to_ascii_lowercase().as_str() {
|
|
||||||
"message-id" => message_id = Some(value),
|
|
||||||
"subject" => subject = Some(value),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("message-id: {:?}", message_id);
|
|
||||||
|
|
||||||
let read = self.inner.read().await;
|
|
||||||
let inner = match &*read {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
let existing = into_opt(
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
SELECT * FROM "mail"
|
|
||||||
WHERE account = ? AND folder = ?
|
|
||||||
AND uid = ? AND uidvalidity = ?
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(acct.as_ref())
|
|
||||||
.bind(folder.as_ref())
|
|
||||||
.bind(uid)
|
|
||||||
.bind(uidvalidity)
|
|
||||||
.fetch_one(&inner.pool)
|
|
||||||
.await,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
if existing.is_none() {
|
|
||||||
let id = sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO "mail" (
|
|
||||||
account, subject, message_id, folder, uid, uidvalidity,
|
|
||||||
filename, internaldate
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(acct.as_ref())
|
|
||||||
.bind(subject)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(folder.as_ref())
|
|
||||||
.bind(uid)
|
|
||||||
.bind(uidvalidity)
|
|
||||||
.bind(filename)
|
|
||||||
.bind(internaldate.to_rfc3339())
|
|
||||||
.execute(&inner.pool)
|
|
||||||
.await
|
|
||||||
.context("error inserting email into db")?
|
|
||||||
.last_insert_rowid();
|
|
||||||
}
|
|
||||||
mem::drop(read);
|
|
||||||
|
|
||||||
// self.email_events
|
|
||||||
// .send(EmailUpdateInfo {})
|
|
||||||
// .context("error sending email update info to the broadcast channel")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event handerl
|
|
||||||
pub async fn handle_mail_event(&self, evt: MailEvent) -> Result<()> {
|
|
||||||
debug!("TODO: handle {:?}", evt);
|
|
||||||
match evt {
|
|
||||||
MailEvent::FolderList(acct, folders) => {
|
|
||||||
let inner = self.inner.write().await;
|
|
||||||
let acct_ref = match inner.as_ref().and_then(|inner| inner.accounts.get(&acct)) {
|
|
||||||
Some(inner) => inner.clone(),
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
mem::drop(inner);
|
|
||||||
acct_ref.set_folders(folders).await;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a map of the accounts that are currently being tracked as well as a reference to the
|
|
||||||
/// account handles themselves
|
|
||||||
pub async fn list_accounts(&self) -> IndexMap<String, Arc<AccountRef>> {
|
|
||||||
let read = self.inner.read().await;
|
|
||||||
let inner = match read.as_ref() {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return IndexMap::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
inner.accounts.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mail_store_config_listener(
|
|
||||||
mut config_watcher: ConfigWatcher,
|
|
||||||
config: Arc<RwLock<Option<Config>>>,
|
|
||||||
inner: Arc<RwLock<Option<MailStoreInner>>>,
|
|
||||||
store_out_tx: Arc<watch::Sender<Option<MailStoreUpdate>>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
while let Ok(()) = config_watcher.changed().await {
|
|
||||||
let new_config = config_watcher.borrow().clone();
|
|
||||||
|
|
||||||
let fut = future::try_join(
|
|
||||||
async {
|
|
||||||
let mut write = config.write().await;
|
|
||||||
write.replace(new_config.clone());
|
|
||||||
Ok::<_, Error>(())
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
let new_inner = MailStoreInner::init_with_config(new_config.clone()).await?;
|
|
||||||
let mut write = inner.write().await;
|
|
||||||
write.replace(new_inner);
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match fut.await {
|
|
||||||
Ok(_) => store_out_tx.send(Some(MailStoreUpdate::AccountListUpdate(())))?,
|
|
||||||
Err(e) => {
|
|
||||||
error!("during mail loop: {}", e);
|
|
||||||
panic!();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailStoreInner {
|
|
||||||
async fn init_with_config(config: Config) -> Result<Self> {
|
|
||||||
let data_dir = config.data_dir.to_string_lossy();
|
|
||||||
let data_dir = PathBuf::from(shellexpand::tilde(data_dir.as_ref()).as_ref());
|
|
||||||
|
|
||||||
let mail_dir = data_dir.join("mail");
|
|
||||||
if !mail_dir.exists() {
|
|
||||||
fs::create_dir_all(&mail_dir).await?;
|
|
||||||
}
|
|
||||||
info!("using mail dir: {:?}", mail_dir);
|
|
||||||
|
|
||||||
// create database parent
|
|
||||||
let db_path = data_dir.join("panorama.db");
|
|
||||||
let db_parent = db_path.parent();
|
|
||||||
if let Some(path) = db_parent {
|
|
||||||
fs::create_dir_all(path).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let db_path_str = db_path.to_string_lossy();
|
|
||||||
let db_path = format!("sqlite:{}", db_path_str);
|
|
||||||
info!("using database path: {}", db_path_str);
|
|
||||||
|
|
||||||
// create the database file if it doesn't already exist -_ -
|
|
||||||
if !Sqlite::database_exists(&db_path_str).await? {
|
|
||||||
Sqlite::create_database(&db_path_str).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool = SqlitePool::connect(&db_path_str).await?;
|
|
||||||
MIGRATOR.run(&pool).await?;
|
|
||||||
debug!("run migrations : {:?}", MIGRATOR);
|
|
||||||
|
|
||||||
let accounts = config
|
|
||||||
.mail_accounts
|
|
||||||
.keys()
|
|
||||||
.map(|acct| {
|
|
||||||
let folders = RwLock::new(Vec::new());
|
|
||||||
(
|
|
||||||
acct.to_owned(),
|
|
||||||
Arc::new(AccountRef {
|
|
||||||
folders,
|
|
||||||
pool: pool.clone(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(MailStoreInner {
|
|
||||||
mail_dir,
|
|
||||||
pool,
|
|
||||||
accounts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// Holds a reference to an account
|
|
||||||
pub struct AccountRef {
|
|
||||||
folders: RwLock<Vec<String>>,
|
|
||||||
pool: SqlitePool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountRef {
|
|
||||||
/// Gets the folders on this account
|
|
||||||
pub async fn get_folders(&self) -> Vec<String> {
|
|
||||||
self.folders.read().await.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the folders on this account
|
|
||||||
pub async fn set_folders(&self, folders: Vec<String>) {
|
|
||||||
*self.folders.write().await = folders;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the n latest messages in the given folder
|
|
||||||
pub async fn get_newest_n_messages(
|
|
||||||
&self,
|
|
||||||
folder: impl AsRef<str>,
|
|
||||||
n: usize,
|
|
||||||
) -> Result<Vec<EmailMetadata>> {
|
|
||||||
let folder = folder.as_ref();
|
|
||||||
let messages: Vec<EmailMetadata> = sqlx::query_as(
|
|
||||||
r#"
|
|
||||||
SELECT internaldate, subject FROM mail
|
|
||||||
WHERE folder = ?
|
|
||||||
ORDER BY internaldate DESC
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(folder)
|
|
||||||
.fetch(&self.pool)
|
|
||||||
.map_ok(|(date, subject): (String, String)| EmailMetadata {
|
|
||||||
date: Some(
|
|
||||||
DateTime::parse_from_rfc3339(&date)
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Local),
|
|
||||||
),
|
|
||||||
subject,
|
|
||||||
..EmailMetadata::default()
|
|
||||||
})
|
|
||||||
.try_collect()
|
|
||||||
.await?;
|
|
||||||
debug!("found {} messages", messages.len());
|
|
||||||
Ok(messages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_opt<T>(res: Result<T, SqlxError>) -> Result<Option<T>> {
|
|
||||||
match res {
|
|
||||||
Ok(v) => Ok(Some(v)),
|
|
||||||
Err(SqlxError::RowNotFound) => Ok(None),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
156
src/main.rs
156
src/main.rs
|
@ -1,156 +0,0 @@
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use fern::colors::{Color, ColoredLevelConfig};
|
|
||||||
use futures::future::TryFutureExt;
|
|
||||||
use panorama::{
|
|
||||||
config::{spawn_config_watcher_system, ConfigWatcher},
|
|
||||||
mail::{self, MailEvent, MailStore},
|
|
||||||
report_err,
|
|
||||||
ui::{self, UiParams},
|
|
||||||
};
|
|
||||||
use structopt::StructOpt;
|
|
||||||
use tokio::{
|
|
||||||
runtime::{Builder as RuntimeBuilder, Runtime},
|
|
||||||
sync::mpsc,
|
|
||||||
task::LocalSet,
|
|
||||||
};
|
|
||||||
use xdg::BaseDirectories;
|
|
||||||
|
|
||||||
#[derive(Debug, StructOpt)]
|
|
||||||
#[structopt(author, about)]
|
|
||||||
struct Opt {
|
|
||||||
/// The path to the log file. By default, does not log.
|
|
||||||
#[structopt(long = "log-file")]
|
|
||||||
log_file: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Run this application headlessly
|
|
||||||
#[structopt(long = "headless")]
|
|
||||||
headless: bool,
|
|
||||||
|
|
||||||
/// Don't watch the config file for changes. (NYI)
|
|
||||||
// TODO: implement this or decide if it's useless
|
|
||||||
#[structopt(long = "no-watch-config")]
|
|
||||||
_no_watch_config: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
// parse command line arguments into options struct
|
|
||||||
let opt = Opt::from_args();
|
|
||||||
setup_logger(opt.log_file.as_ref())?;
|
|
||||||
|
|
||||||
let rt = Runtime::new().unwrap();
|
|
||||||
rt.block_on(run(opt)).unwrap();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// #[tokio::main(flavor = "multi_thread")]
|
|
||||||
async fn run(opt: Opt) -> Result<()> {
|
|
||||||
let _xdg = BaseDirectories::new()?;
|
|
||||||
let (_config_thread, config_update) = spawn_config_watcher_system()?;
|
|
||||||
let mail_store = MailStore::new(config_update.clone());
|
|
||||||
|
|
||||||
// used to notify the runtime that the process should exit
|
|
||||||
let (exit_tx, mut exit_rx) = mpsc::channel::<()>(1);
|
|
||||||
|
|
||||||
// send messages from the UI thread to the mail thread
|
|
||||||
let (_ui2mail_tx, ui2mail_rx) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
// send messages from the mail thread to the UI thread
|
|
||||||
let (mail2ui_tx, mail2ui_rx) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
// send messages from the UI thread to the vm thread
|
|
||||||
let (ui2vm_tx, _ui2vm_rx) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
let config_update2 = config_update.clone();
|
|
||||||
let mail_store2 = mail_store.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
mail::run_mail(mail_store2, config_update2, ui2mail_rx, mail2ui_tx)
|
|
||||||
.unwrap_or_else(report_err)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
if !opt.headless {
|
|
||||||
let config_update2 = config_update.clone();
|
|
||||||
run_ui(
|
|
||||||
config_update2,
|
|
||||||
mail_store.clone(),
|
|
||||||
exit_tx,
|
|
||||||
mail2ui_rx,
|
|
||||||
ui2vm_tx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
exit_rx.recv().await;
|
|
||||||
|
|
||||||
// TODO: graceful shutdown
|
|
||||||
// yada yada create a background process and pass off the connections so they can be safely
|
|
||||||
// shutdown
|
|
||||||
std::process::exit(0);
|
|
||||||
// Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawns the entire UI in a different thread, since it must be thread-local
|
|
||||||
fn run_ui(
|
|
||||||
config_update: ConfigWatcher,
|
|
||||||
mail_store: MailStore,
|
|
||||||
exit_tx: mpsc::Sender<()>,
|
|
||||||
mail2ui_rx: mpsc::UnboundedReceiver<MailEvent>,
|
|
||||||
_ui2vm_tx: mpsc::UnboundedSender<()>,
|
|
||||||
) {
|
|
||||||
let stdout = std::io::stdout();
|
|
||||||
|
|
||||||
let rt = RuntimeBuilder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
|
||||||
let localset = LocalSet::new();
|
|
||||||
let params = UiParams {
|
|
||||||
config_update,
|
|
||||||
mail_store,
|
|
||||||
stdout,
|
|
||||||
exit_tx,
|
|
||||||
mail2ui_rx,
|
|
||||||
};
|
|
||||||
|
|
||||||
localset.spawn_local(async {
|
|
||||||
ui::run_ui2(params).unwrap_or_else(report_err).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
rt.block_on(localset);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_logger(log_file: Option<impl AsRef<Path>>) -> Result<()> {
|
|
||||||
let colors = ColoredLevelConfig::new()
|
|
||||||
.info(Color::Blue)
|
|
||||||
.debug(Color::BrightBlack)
|
|
||||||
.warn(Color::Yellow)
|
|
||||||
.error(Color::Red);
|
|
||||||
let mut logger = fern::Dispatch::new()
|
|
||||||
.filter(|meta| {
|
|
||||||
meta.target() != "tokio_util::codec::framed_impl"
|
|
||||||
&& !meta.target().starts_with("rustls::client")
|
|
||||||
&& !meta.target().starts_with("sqlx::query")
|
|
||||||
})
|
|
||||||
.format(move |out, message, record| {
|
|
||||||
out.finish(format_args!(
|
|
||||||
"{}[{}][{}] {}",
|
|
||||||
chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"),
|
|
||||||
record.target(),
|
|
||||||
colors.color(record.level()),
|
|
||||||
message
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.level(log::LevelFilter::Trace);
|
|
||||||
if let Some(log_file) = log_file {
|
|
||||||
logger = logger.chain(fern::log_file(log_file)?);
|
|
||||||
}
|
|
||||||
logger.apply()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
//! Everything dealing with scripting
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use gluon::{import::add_extern_module, ThreadExt};
|
|
||||||
|
|
||||||
// use crate::ui::{UiCommand, UiUpdate};
|
|
||||||
|
|
||||||
/// Creates a VM for running scripts
|
|
||||||
pub async fn create_script_vm() -> Result<()> {
|
|
||||||
let vm = gluon::new_vm_async().await;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
//! Searching
|
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
|
|
||||||
/// A search index manager
|
|
||||||
///
|
|
||||||
/// This is clone-safe: cloning this struct will return references to the same object
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SearchIndex {}
|
|
||||||
|
|
||||||
impl SearchIndex {
|
|
||||||
/// Create a new instance of the search index
|
|
||||||
pub fn new(config: Config) -> Self {
|
|
||||||
SearchIndex {}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
use panorama_tui::crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
|
|
||||||
use super::input::{HandlesInput, InputResult};
|
|
||||||
use super::TermType;
|
|
||||||
|
|
||||||
#[derive(Clone, Default, Debug)]
|
|
||||||
pub struct ColonPrompt {
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ColonPrompt {
|
|
||||||
pub fn init(term: TermType) -> Result<Self> {
|
|
||||||
let s = term.size()?;
|
|
||||||
term.set_cursor(1, s.height - 1)?;
|
|
||||||
term.show_cursor()?;
|
|
||||||
Ok(ColonPrompt::default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for ColonPrompt {
|
|
||||||
fn drop(&mut self) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HandlesInput for ColonPrompt {
|
|
||||||
fn handle_key(&mut self, term: TermType, evt: KeyEvent) -> Result<InputResult> {
|
|
||||||
let KeyEvent { code, .. } = evt;
|
|
||||||
match code {
|
|
||||||
KeyCode::Esc => return Ok(InputResult::Pop),
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
let mut b = [0; 2];
|
|
||||||
self.value += c.encode_utf8(&mut b);
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let cmd = self.value.clone();
|
|
||||||
self.value.clear();
|
|
||||||
debug!("executing colon command: {:?}", cmd);
|
|
||||||
return Ok(InputResult::Pop);
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
let mut new_len = self.value.len();
|
|
||||||
if new_len > 0 {
|
|
||||||
new_len -= 1;
|
|
||||||
self.value.truncate(new_len);
|
|
||||||
} else {
|
|
||||||
return Ok(InputResult::Pop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(InputResult::Ok)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
use std::any::Any;
|
|
||||||
use std::fmt::Debug;
|
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicBool, AtomicI8, Ordering},
|
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use downcast_rs::Downcast;
|
|
||||||
use panorama_tui::crossterm::event::{self, Event, KeyCode, KeyEvent};
|
|
||||||
|
|
||||||
use super::colon_prompt::ColonPrompt;
|
|
||||||
use super::TermType;
|
|
||||||
|
|
||||||
pub trait HandlesInput: Any + Debug + Downcast {
|
|
||||||
fn handle_key(&mut self, term: TermType, evt: KeyEvent) -> Result<InputResult> {
|
|
||||||
Ok(InputResult::Ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downcast_rs::impl_downcast!(HandlesInput);
|
|
||||||
|
|
||||||
pub enum InputResult {
|
|
||||||
Ok,
|
|
||||||
|
|
||||||
/// Push a new state
|
|
||||||
Push(Box<dyn HandlesInput>),
|
|
||||||
|
|
||||||
/// Pops a state from the stack
|
|
||||||
Pop,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct BaseInputHandler(pub Arc<AtomicBool>, pub Arc<AtomicI8>);
|
|
||||||
|
|
||||||
impl HandlesInput for BaseInputHandler {
|
|
||||||
fn handle_key(&mut self, term: TermType, evt: KeyEvent) -> Result<InputResult> {
|
|
||||||
let KeyEvent { code, .. } = evt;
|
|
||||||
match code {
|
|
||||||
KeyCode::Char('q') => self.0.store(true, Ordering::Relaxed),
|
|
||||||
KeyCode::Char('j') => self.1.store(1, Ordering::Relaxed),
|
|
||||||
KeyCode::Char('k') => self.1.store(-1, Ordering::Relaxed),
|
|
||||||
KeyCode::Char(':') => {
|
|
||||||
let colon_prompt = Box::new(ColonPrompt::init(term)?);
|
|
||||||
return Ok(InputResult::Push(colon_prompt));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(InputResult::Ok)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
pub struct Keybinds {}
|
|
|
@ -1,251 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicI8, AtomicU32, Ordering},
|
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use chrono::{DateTime, Datelike, Duration, Local};
|
|
||||||
use chrono_humanize::HumanTime;
|
|
||||||
use panorama_imap::response::Envelope;
|
|
||||||
use panorama_tui::{
|
|
||||||
crossterm::event::{KeyCode, KeyEvent},
|
|
||||||
tui::{
|
|
||||||
buffer::Buffer,
|
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
text::{Span, Spans},
|
|
||||||
widgets::*,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use tokio::{sync::RwLock, task::JoinHandle};
|
|
||||||
|
|
||||||
use crate::mail::{
|
|
||||||
store::{AccountRef, MailStoreUpdate},
|
|
||||||
EmailMetadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{FrameType, HandlesInput, InputResult, MailStore, TermType, Window, UI};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// A singular UI view of a list of mail
|
|
||||||
pub struct MailView {
|
|
||||||
pub mail_store: MailStore,
|
|
||||||
pub message_list: TableState,
|
|
||||||
pub selected: Arc<AtomicU32>,
|
|
||||||
pub change: Arc<AtomicI8>,
|
|
||||||
current: Arc<RwLock<Option<Current>>>,
|
|
||||||
mail_store_listener: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct Current {
|
|
||||||
account: Arc<AccountRef>,
|
|
||||||
folder: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HandlesInput for MailView {
|
|
||||||
fn handle_key(&mut self, term: TermType, evt: KeyEvent) -> Result<InputResult> {
|
|
||||||
let KeyEvent { code, .. } = evt;
|
|
||||||
match code {
|
|
||||||
// KeyCode::Char('q') => self.0.store(true, Ordering::Relaxed),
|
|
||||||
// KeyCode::Char('j') => self.1.store(1, Ordering::Relaxed),
|
|
||||||
// KeyCode::Char('k') => self.1.store(-1, Ordering::Relaxed),
|
|
||||||
KeyCode::Char(':') => {
|
|
||||||
// let colon_prompt = Box::new(ColonPrompt::init(term));
|
|
||||||
// return Ok(InputResult::Push(colon_prompt));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(InputResult::Ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait(?Send)]
|
|
||||||
impl Window for MailView {
|
|
||||||
fn name(&self) -> String {
|
|
||||||
String::from("email")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn draw(&self, f: &mut FrameType<'_, '_>, area: Rect, ui: &UI) -> Result<()> {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.margin(0)
|
|
||||||
.constraints([Constraint::Length(20), Constraint::Max(5000)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let accts = self.mail_store.list_accounts().await;
|
|
||||||
|
|
||||||
// folder list
|
|
||||||
let mut items = vec![];
|
|
||||||
for (acct_name, acct_ref) in accts.iter() {
|
|
||||||
let folders = acct_ref.get_folders().await;
|
|
||||||
items.push(ListItem::new(acct_name.to_owned()));
|
|
||||||
for folder in folders {
|
|
||||||
items.push(ListItem::new(format!(" {}", folder)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dirlist = List::new(items)
|
|
||||||
.block(Block::default().borders(Borders::NONE).title(Span::styled(
|
|
||||||
"hellosu",
|
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
|
||||||
)))
|
|
||||||
.style(Style::default().fg(Color::White))
|
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::ITALIC))
|
|
||||||
.highlight_symbol(">>");
|
|
||||||
|
|
||||||
let mut rows = vec![];
|
|
||||||
if let Some(current) = self.current.read().await.as_ref() {
|
|
||||||
let messages = current
|
|
||||||
.account
|
|
||||||
.get_newest_n_messages("INBOX", chunks[1].height as usize)
|
|
||||||
.await?;
|
|
||||||
for meta in messages.iter() {
|
|
||||||
let mut row = Row::new(vec![
|
|
||||||
String::from(if meta.unread { "\u{2b24}" } else { "" }),
|
|
||||||
meta.uid.map(|u| u.to_string()).unwrap_or_default(),
|
|
||||||
meta.date.map(|d| humanize_timestamp(d)).unwrap_or_default(),
|
|
||||||
meta.from.clone(),
|
|
||||||
meta.subject.clone(),
|
|
||||||
]);
|
|
||||||
if meta.unread {
|
|
||||||
row = row.style(
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::LightCyan)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
rows.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let table = Table::new(rows)
|
|
||||||
.style(Style::default().fg(Color::White))
|
|
||||||
.widths(&[
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Max(3),
|
|
||||||
Constraint::Min(20),
|
|
||||||
Constraint::Min(35),
|
|
||||||
Constraint::Max(5000),
|
|
||||||
])
|
|
||||||
.header(
|
|
||||||
Row::new(vec!["", "UID", "Date", "From", "Subject"])
|
|
||||||
.style(Style::default().add_modifier(Modifier::BOLD)),
|
|
||||||
)
|
|
||||||
.highlight_style(Style::default().bg(Color::DarkGray));
|
|
||||||
|
|
||||||
f.render_widget(dirlist, chunks[0]);
|
|
||||||
f.render_widget(table, chunks[1]);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update(&mut self) {
|
|
||||||
// make the change
|
|
||||||
if self
|
|
||||||
.change
|
|
||||||
.compare_exchange(-1, 0, Ordering::Relaxed, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
self.move_up();
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.change
|
|
||||||
.compare_exchange(1, 0, Ordering::Relaxed, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
self.move_down();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Turn a timestamp into a format that a human might read when viewing it in a table.
|
|
||||||
///
|
|
||||||
/// This means, for dates within the past 24 hours, report it in a relative format.
|
|
||||||
///
|
|
||||||
/// For dates sent this year, omit the year entirely.
|
|
||||||
fn humanize_timestamp(date: DateTime<Local>) -> String {
|
|
||||||
let now = Local::now();
|
|
||||||
let diff = now - date;
|
|
||||||
|
|
||||||
if diff < Duration::days(1) {
|
|
||||||
HumanTime::from(date).to_string()
|
|
||||||
} else if date.year() == now.year() {
|
|
||||||
date.format("%b %e %T").to_string()
|
|
||||||
} else {
|
|
||||||
date.to_rfc2822()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MailView {
|
|
||||||
pub fn new(mail_store: MailStore) -> Self {
|
|
||||||
let current = Arc::new(RwLock::new(None));
|
|
||||||
let current2 = current.clone();
|
|
||||||
|
|
||||||
let mut listener = mail_store.store_out_rx.clone();
|
|
||||||
let mail_store2 = mail_store.clone();
|
|
||||||
let mail_store_listener = tokio::spawn(async move {
|
|
||||||
while let Ok(()) = listener.changed().await {
|
|
||||||
let updated = listener.borrow().clone();
|
|
||||||
debug!("new update from mail store: {:?}", updated);
|
|
||||||
|
|
||||||
// TODO: maybe do the processing of updates somewhere else?
|
|
||||||
// in case events get missed
|
|
||||||
match updated {
|
|
||||||
Some(MailStoreUpdate::AccountListUpdate(_)) => {
|
|
||||||
// TODO: maybe have a default account?
|
|
||||||
let accounts = mail_store2.list_accounts().await;
|
|
||||||
if let Some((acct_name, acct_ref)) = accounts.iter().next() {
|
|
||||||
let mut write = current2.write().await;
|
|
||||||
*write = Some(Current {
|
|
||||||
account: acct_ref.clone(),
|
|
||||||
folder: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
MailView {
|
|
||||||
mail_store,
|
|
||||||
current,
|
|
||||||
message_list: TableState::default(),
|
|
||||||
selected: Arc::new(AtomicU32::default()),
|
|
||||||
change: Arc::new(AtomicI8::default()),
|
|
||||||
mail_store_listener,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_down(&mut self) {
|
|
||||||
// if self.message_uids.is_empty() {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// let len = self.message_uids.len();
|
|
||||||
// if let Some(selected) = self.message_list.selected() {
|
|
||||||
// if selected + 1 < len {
|
|
||||||
// self.message_list.select(Some(selected + 1));
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// self.message_list.select(Some(0));
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_up(&mut self) {
|
|
||||||
// if self.message_uids.is_empty() {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// let len = self.message_uids.len();
|
|
||||||
// if let Some(selected) = self.message_list.selected() {
|
|
||||||
// if selected >= 1 {
|
|
||||||
// self.message_list.select(Some(selected - 1));
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// self.message_list.select(Some(len - 1));
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
/// Commands that are sent from the UI to the scripting VM
|
|
||||||
pub enum UiCommand {
|
|
||||||
HSplit,
|
|
||||||
VSplit,
|
|
||||||
OpenMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates that are sent from the scripting VM to the UI
|
|
||||||
pub enum UiUpdate {}
|
|
237
src/ui/mod.rs
237
src/ui/mod.rs
|
@ -1,237 +0,0 @@
|
||||||
//! UI library
|
|
||||||
|
|
||||||
mod colon_prompt;
|
|
||||||
mod input;
|
|
||||||
// mod keybinds;
|
|
||||||
mod mail_view;
|
|
||||||
// mod messages;
|
|
||||||
mod windows;
|
|
||||||
|
|
||||||
use std::any::Any;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::Stdout;
|
|
||||||
use std::mem;
|
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
Arc,
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use chrono::{Local, TimeZone};
|
|
||||||
use downcast_rs::Downcast;
|
|
||||||
use futures::{future::FutureExt, select, stream::StreamExt};
|
|
||||||
use panorama_imap::response::{AttributeValue, Envelope};
|
|
||||||
use panorama_tui::{
|
|
||||||
crossterm::{
|
|
||||||
cursor,
|
|
||||||
event::{self, Event, EventStream, KeyCode, KeyEvent},
|
|
||||||
execute, queue, style, terminal,
|
|
||||||
},
|
|
||||||
tui::{
|
|
||||||
backend::CrosstermBackend,
|
|
||||||
layout::{Constraint, Direction, Layout},
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
text::{Span, Spans},
|
|
||||||
widgets::*,
|
|
||||||
},
|
|
||||||
Frame, Terminal,
|
|
||||||
};
|
|
||||||
use tokio::{sync::mpsc, time};
|
|
||||||
|
|
||||||
use crate::config::ConfigWatcher;
|
|
||||||
use crate::mail::{EmailMetadata, MailEvent, MailStore};
|
|
||||||
|
|
||||||
use self::colon_prompt::ColonPrompt;
|
|
||||||
use self::input::{BaseInputHandler, HandlesInput, InputResult};
|
|
||||||
use self::mail_view::MailView;
|
|
||||||
// pub(crate) use self::messages::*;
|
|
||||||
use self::windows::*;
|
|
||||||
|
|
||||||
pub(crate) type FrameType<'a, 'b> = Frame<'a, &'b mut Stdout>;
|
|
||||||
// pub(crate) type FrameType<'a, 'b, 'c> = &'c mut Frame<'a, CrosstermBackend<&'b mut Stdout>>;
|
|
||||||
pub(crate) type TermType<'b> = &'b mut Terminal<Stdout>;
|
|
||||||
|
|
||||||
/// Parameters for passing to the UI thread
|
|
||||||
pub struct UiParams {
|
|
||||||
/// Config updates
|
|
||||||
pub config_update: ConfigWatcher,
|
|
||||||
|
|
||||||
/// Mail store
|
|
||||||
pub mail_store: MailStore,
|
|
||||||
|
|
||||||
/// Handle to the screen
|
|
||||||
pub stdout: Stdout,
|
|
||||||
|
|
||||||
/// A channel for telling the UI to quit
|
|
||||||
pub exit_tx: mpsc::Sender<()>,
|
|
||||||
|
|
||||||
/// All the events coming in from the mail thread
|
|
||||||
pub mail2ui_rx: mpsc::UnboundedReceiver<MailEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main entrypoint for the UI
|
|
||||||
pub async fn run_ui2(params: UiParams) -> Result<()> {
|
|
||||||
let mut stdout = params.stdout;
|
|
||||||
let mut mail2ui_rx = params.mail2ui_rx;
|
|
||||||
let exit_tx = params.exit_tx;
|
|
||||||
|
|
||||||
execute!(stdout, cursor::Hide, terminal::EnterAlternateScreen)?;
|
|
||||||
terminal::enable_raw_mode()?;
|
|
||||||
|
|
||||||
// let backend = CrosstermBackend::new(&mut stdout);
|
|
||||||
let mut term = Terminal::new(&mut stdout)?;
|
|
||||||
let mut ui_events = EventStream::new();
|
|
||||||
|
|
||||||
let should_exit = Arc::new(AtomicBool::new(false));
|
|
||||||
|
|
||||||
let mail_store = params.mail_store;
|
|
||||||
let mut ui = UI {
|
|
||||||
should_exit: should_exit.clone(),
|
|
||||||
window_layout: WindowLayout::default(),
|
|
||||||
windows: HashMap::new(),
|
|
||||||
page_names: HashMap::new(),
|
|
||||||
mail_store: mail_store.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.open_window(MailView::new(mail_store));
|
|
||||||
|
|
||||||
// let mut input_states: Vec<Box<dyn HandlesInput>> = vec![];
|
|
||||||
|
|
||||||
while !should_exit.load(Ordering::Relaxed) {
|
|
||||||
term.pre_draw()?;
|
|
||||||
{
|
|
||||||
let mut frame = term.get_frame();
|
|
||||||
ui.draw(&mut frame).await?;
|
|
||||||
}
|
|
||||||
term.post_draw()?;
|
|
||||||
|
|
||||||
select! {
|
|
||||||
// got an event from the mail thread
|
|
||||||
evt = mail2ui_rx.recv().fuse() => if let Some(evt) = evt {
|
|
||||||
ui.process_mail_event(evt).await?;
|
|
||||||
},
|
|
||||||
|
|
||||||
// got an event from the ui thread
|
|
||||||
evt = ui_events.next().fuse() => if let Some(evt) = evt {
|
|
||||||
let evt = evt?;
|
|
||||||
ui.process_event(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for approx 60fps
|
|
||||||
// _ = time::sleep(FRAME_DURATION).fuse() => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mem::drop(ui);
|
|
||||||
mem::drop(term);
|
|
||||||
|
|
||||||
execute!(
|
|
||||||
stdout,
|
|
||||||
style::ResetColor,
|
|
||||||
cursor::Show,
|
|
||||||
terminal::LeaveAlternateScreen
|
|
||||||
)?;
|
|
||||||
terminal::disable_raw_mode()?;
|
|
||||||
|
|
||||||
exit_tx.send(()).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UI
|
|
||||||
pub struct UI {
|
|
||||||
should_exit: Arc<AtomicBool>,
|
|
||||||
window_layout: WindowLayout,
|
|
||||||
windows: HashMap<LayoutId, Box<dyn Window>>,
|
|
||||||
page_names: HashMap<PageId, String>,
|
|
||||||
mail_store: MailStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UI {
|
|
||||||
async fn draw(&mut self, f: &mut FrameType<'_, '_>) -> Result<()> {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.margin(0)
|
|
||||||
.constraints([Constraint::Max(5000), Constraint::Length(1)])
|
|
||||||
.split(f.size());
|
|
||||||
|
|
||||||
let pages = self.window_layout.list_pages();
|
|
||||||
|
|
||||||
// draw a list of pages at the bottom
|
|
||||||
let titles = self
|
|
||||||
.window_layout
|
|
||||||
.list_pages()
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, id)| {
|
|
||||||
self.page_names
|
|
||||||
.get(id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| i.to_string())
|
|
||||||
})
|
|
||||||
.map(Spans::from)
|
|
||||||
.collect();
|
|
||||||
let tabs = Tabs::new(titles).style(Style::default().bg(Color::DarkGray));
|
|
||||||
f.render_widget(tabs, chunks[1]);
|
|
||||||
debug!("drew chunks");
|
|
||||||
|
|
||||||
// render all other windows
|
|
||||||
let visible = self.window_layout.visible_windows(chunks[0]);
|
|
||||||
for (layout_id, area) in visible.into_iter() {
|
|
||||||
if let Some(window) = self.windows.get(&layout_id) {
|
|
||||||
window.draw(f, area, self).await?;
|
|
||||||
debug!("drew {:?} {:?}", layout_id, area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_window(&mut self, window: impl Window) {
|
|
||||||
debug!("opened window {:?}", window.name());
|
|
||||||
let (layout_id, page_id) = self.window_layout.new_page();
|
|
||||||
|
|
||||||
let window = Box::new(window);
|
|
||||||
self.windows.insert(layout_id, window);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main entrypoint for handling any kind of event coming from the terminal
|
|
||||||
fn process_event(&mut self, evt: Event) {
|
|
||||||
if let Event::Key(evt) = evt {
|
|
||||||
if let KeyEvent {
|
|
||||||
code: KeyCode::Char('q'),
|
|
||||||
..
|
|
||||||
} = evt
|
|
||||||
{
|
|
||||||
self.should_exit.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle states in the state stack
|
|
||||||
// although this is written in a for loop, every case except one should break
|
|
||||||
let should_pop = false;
|
|
||||||
// for input_state in input_states.iter_mut().rev() {
|
|
||||||
// match input_state.handle_key(&mut term, evt)? {
|
|
||||||
// InputResult::Ok => break,
|
|
||||||
// InputResult::Push(state) => {
|
|
||||||
// input_states.push(state);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// InputResult::Pop => {
|
|
||||||
// should_pop = true;
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
if should_pop {
|
|
||||||
debug!("pop state");
|
|
||||||
// input_states.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_mail_event(&mut self, evt: MailEvent) -> Result<()> {
|
|
||||||
self.mail_store.handle_mail_event(evt).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,105 +0,0 @@
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures::future::Future;
|
|
||||||
use panorama_tui::tui::layout::Rect;
|
|
||||||
|
|
||||||
use super::{FrameType, HandlesInput, UI};
|
|
||||||
|
|
||||||
#[async_trait(?Send)]
|
|
||||||
pub trait Window: HandlesInput {
|
|
||||||
/// Return some kind of name
|
|
||||||
fn name(&self) -> String;
|
|
||||||
|
|
||||||
/// Main draw function
|
|
||||||
async fn draw(&self, f: &mut FrameType<'_, '_>, area: Rect, ui: &UI) -> Result<()>;
|
|
||||||
// async fn draw(&self, f: FrameType, area: Rect, ui: Rc<UI>);
|
|
||||||
|
|
||||||
/// Update function
|
|
||||||
async fn update(&mut self) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
downcast_rs::impl_downcast!(Window);
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub struct LayoutId(usize);
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub struct PageId(usize);
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct WindowLayout {
|
|
||||||
ctr: usize,
|
|
||||||
currently_active: Option<LayoutId>,
|
|
||||||
|
|
||||||
ids: HashMap<LayoutId, PageId>,
|
|
||||||
page_order: Vec<PageId>,
|
|
||||||
pages: HashMap<PageId, PageGraph>,
|
|
||||||
|
|
||||||
layout_cache: HashMap<PageId, HashMap<LayoutId, Rect>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WindowLayout {
|
|
||||||
/// Adds a new page to the list
|
|
||||||
pub fn new_page(&mut self) -> (LayoutId, PageId) {
|
|
||||||
let id = LayoutId(self.ctr);
|
|
||||||
self.ctr += 1;
|
|
||||||
|
|
||||||
let pg = PageGraph::new(id);
|
|
||||||
let pid = PageId(self.ctr);
|
|
||||||
self.ctr += 1;
|
|
||||||
self.pages.insert(pid, pg);
|
|
||||||
self.page_order.push(pid);
|
|
||||||
self.ids.insert(id, pid);
|
|
||||||
|
|
||||||
if self.currently_active.is_none() {
|
|
||||||
self.currently_active = Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
(id, pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_pages(&self) -> &[PageId] {
|
|
||||||
&self.page_order
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a set of all windows visible on the current page, given the size of the allotted space
|
|
||||||
pub fn visible_windows(&self, area: Rect) -> HashMap<LayoutId, Rect> {
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
if let Some(page) = self
|
|
||||||
.currently_active
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|id| self.ids.get(id))
|
|
||||||
.and_then(|pid| self.pages.get(pid))
|
|
||||||
{
|
|
||||||
let mut q = VecDeque::new();
|
|
||||||
q.push_back(page.root);
|
|
||||||
|
|
||||||
while !q.is_empty() {
|
|
||||||
let front = q.pop_front().expect("not empty");
|
|
||||||
// TODO: how to subdivide properly?
|
|
||||||
map.insert(front, area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct PageGraph {
|
|
||||||
root: LayoutId,
|
|
||||||
adj: HashMap<LayoutId, HashSet<(LayoutId, Dir)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
enum Dir {}
|
|
||||||
|
|
||||||
impl PageGraph {
|
|
||||||
pub fn new(id: LayoutId) -> Self {
|
|
||||||
PageGraph {
|
|
||||||
root: id,
|
|
||||||
adj: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::{
|
|
||||||
cursor::MoveTo,
|
|
||||||
style::{Color, SetBackgroundColor, SetForegroundColor},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{Rect, Screen};
|
|
||||||
|
|
||||||
pub struct DrawBuf {
|
|
||||||
rect: Rect,
|
|
||||||
buffer: Vec<Cell>,
|
|
||||||
dirty: Vec<(u16, Range<u16>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct Cell {
|
|
||||||
sym: char,
|
|
||||||
fg: Color,
|
|
||||||
bg: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DrawBuf {
|
|
||||||
pub fn new(rect: Rect) -> Self {
|
|
||||||
DrawBuf {
|
|
||||||
rect,
|
|
||||||
buffer: vec![
|
|
||||||
Cell {
|
|
||||||
sym: ' ',
|
|
||||||
fg: Color::Reset,
|
|
||||||
bg: Color::Reset
|
|
||||||
};
|
|
||||||
(rect.w * rect.h) as usize
|
|
||||||
],
|
|
||||||
dirty: (0..rect.h).map(|row| (row, 0..rect.w)).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw(&mut self, w: &mut Screen) -> Result<()> {
|
|
||||||
for (row, range) in self.dirty.drain(..) {
|
|
||||||
queue!(w, MoveTo(row, range.start))?;
|
|
||||||
for i in range {
|
|
||||||
let idx = row * self.rect.w + i;
|
|
||||||
let cell = &self.buffer[idx as usize];
|
|
||||||
queue!(w, SetForegroundColor(cell.fg), SetBackgroundColor(cell.bg),)?;
|
|
||||||
println!("{}", cell.sym);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
//! UI module
|
|
||||||
|
|
||||||
mod drawbuf;
|
|
||||||
mod table;
|
|
||||||
mod tabs;
|
|
||||||
mod widget;
|
|
||||||
|
|
||||||
use std::fmt::Debug;
|
|
||||||
use std::io::{Stdout, Write};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use chrono::Local;
|
|
||||||
use crossterm::{
|
|
||||||
cursor::{self, MoveTo},
|
|
||||||
event::{self, Event, KeyCode, KeyEvent},
|
|
||||||
style::{self, Color, SetBackgroundColor, SetForegroundColor},
|
|
||||||
terminal::{self, Clear, ClearType},
|
|
||||||
};
|
|
||||||
use tokio::time;
|
|
||||||
|
|
||||||
use crate::ExitSender;
|
|
||||||
|
|
||||||
use self::drawbuf::DrawBuf;
|
|
||||||
use self::table::Table;
|
|
||||||
use self::tabs::Tabs;
|
|
||||||
use self::widget::Widget;
|
|
||||||
|
|
||||||
const FRAME_DURATION: Duration = Duration::from_millis(20);
|
|
||||||
|
|
||||||
/// Type alias for the screen object we're drawing to
|
|
||||||
pub type Screen = Stdout;
|
|
||||||
|
|
||||||
/// Rectangle
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub struct Rect {
|
|
||||||
pub x: u16,
|
|
||||||
pub y: u16,
|
|
||||||
pub w: u16,
|
|
||||||
pub h: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Rect {
|
|
||||||
/// Construct a new rectangle from (x, y) and (w, h)
|
|
||||||
pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
|
|
||||||
Rect { x, y, w, h }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UI entrypoint.
|
|
||||||
pub async fn run_ui(mut w: Stdout, exit: ExitSender) -> Result<()> {
|
|
||||||
execute!(w, cursor::Hide, terminal::EnterAlternateScreen)?;
|
|
||||||
terminal::enable_raw_mode()?;
|
|
||||||
|
|
||||||
let (term_width, term_height) = terminal::size()?;
|
|
||||||
let bounds = Rect::new(0, 0, term_width, term_height);
|
|
||||||
let mut drawbuf = DrawBuf::new(bounds);
|
|
||||||
|
|
||||||
let mut table = Table::default();
|
|
||||||
table.push_row(vec!["ur mom Lol!".to_owned()]);
|
|
||||||
table.push_row(vec!["hek".to_owned()]);
|
|
||||||
|
|
||||||
let mut tabs = Tabs::new();
|
|
||||||
tabs.add_tab("Mail", table);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
queue!(
|
|
||||||
w,
|
|
||||||
SetBackgroundColor(Color::Reset),
|
|
||||||
SetForegroundColor(Color::Reset),
|
|
||||||
Clear(ClearType::All),
|
|
||||||
MoveTo(0, 0),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// let now = Local::now();
|
|
||||||
// println!("time {}", now);
|
|
||||||
|
|
||||||
let (term_width, term_height) = terminal::size()?;
|
|
||||||
let bounds = Rect::new(5, 5, term_width - 10, term_height - 10);
|
|
||||||
// table.draw(&mut w, bounds)?;
|
|
||||||
// tabs.draw(&mut w, bounds)?;
|
|
||||||
drawbuf.draw(&mut w)?;
|
|
||||||
w.flush()?;
|
|
||||||
|
|
||||||
// approx 60fps
|
|
||||||
time::sleep(FRAME_DURATION).await;
|
|
||||||
|
|
||||||
// check to see if there's even an event this frame. otherwise, just keep going
|
|
||||||
let event = if event::poll(FRAME_DURATION)? {
|
|
||||||
let event = event::read()?;
|
|
||||||
// table.update(&event);
|
|
||||||
|
|
||||||
if let Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Char('q'),
|
|
||||||
..
|
|
||||||
}) = event
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(event)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
tabs.update(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
execute!(
|
|
||||||
w,
|
|
||||||
style::ResetColor,
|
|
||||||
cursor::Show,
|
|
||||||
terminal::LeaveAlternateScreen
|
|
||||||
)?;
|
|
||||||
terminal::disable_raw_mode()?;
|
|
||||||
|
|
||||||
exit.send(()).await?;
|
|
||||||
debug!("sent exit");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,114 +0,0 @@
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::{
|
|
||||||
cursor,
|
|
||||||
event::{Event, KeyCode, KeyEvent},
|
|
||||||
style::{self, Color},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{DrawBuf, Rect, Screen, Widget};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Table {
|
|
||||||
selected_row: Option<u16>,
|
|
||||||
rows: Vec<Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Table {
|
|
||||||
pub fn push_row(&mut self, row: Vec<String>) {
|
|
||||||
self.rows.push(row);
|
|
||||||
if self.selected_row.is_none() {
|
|
||||||
self.selected_row = Some(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for Table {
|
|
||||||
fn update(&mut self, event: Option<Event>) {
|
|
||||||
if let Some(Event::Key(KeyEvent { code, .. })) = event {
|
|
||||||
match code {
|
|
||||||
KeyCode::Char('j') => {
|
|
||||||
if let Some(selected_row) = &mut self.selected_row {
|
|
||||||
*selected_row = (self.rows.len() as u16 - 1).min(*selected_row + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char('k') => {
|
|
||||||
if let Some(selected_row) = &mut self.selected_row {
|
|
||||||
if *selected_row > 0 {
|
|
||||||
*selected_row -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(&self, buf: &mut DrawBuf, rect: Rect) -> Result<()> {
|
|
||||||
if !self.rows.is_empty() {
|
|
||||||
let mut columns = Vec::new();
|
|
||||||
for row in self.rows.iter() {
|
|
||||||
for (i, cell) in row.iter().enumerate() {
|
|
||||||
if columns.is_empty() || columns.len() - 1 < i {
|
|
||||||
columns.push(0);
|
|
||||||
} else {
|
|
||||||
columns[i] = cell.len().max(columns[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, row) in self.rows.iter().enumerate() {
|
|
||||||
// queue!(w, cursor::MoveTo(rect.x, rect.y + i as u16))?;
|
|
||||||
// if let Some(v) = self.selected_row {
|
|
||||||
// if v == i as u16 {
|
|
||||||
// // queue!(
|
|
||||||
// // w,
|
|
||||||
// // style::SetBackgroundColor(Color::White),
|
|
||||||
// // style::SetForegroundColor(Color::Black)
|
|
||||||
// // )?;
|
|
||||||
// } else {
|
|
||||||
// // queue!(
|
|
||||||
// // w,
|
|
||||||
// // style::SetForegroundColor(Color::White),
|
|
||||||
// // style::SetBackgroundColor(Color::Black)
|
|
||||||
// // )?;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
let mut s = String::with_capacity(rect.w as usize);
|
|
||||||
for (j, cell) in row.iter().enumerate() {
|
|
||||||
s += &cell;
|
|
||||||
for _ in 0..columns[j] + 1 {
|
|
||||||
s += " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _ in 0..(rect.w - s.len() as u16) {
|
|
||||||
s += " ";
|
|
||||||
}
|
|
||||||
println!("{}", s);
|
|
||||||
}
|
|
||||||
|
|
||||||
let d = "\u{b7}".repeat(rect.w as usize);
|
|
||||||
// queue!(
|
|
||||||
// w,
|
|
||||||
// style::SetBackgroundColor(Color::Black),
|
|
||||||
// style::SetForegroundColor(Color::White)
|
|
||||||
// )?;
|
|
||||||
for j in self.rows.len() as u16..rect.h {
|
|
||||||
// queue!(w, cursor::MoveTo(rect.x, rect.y + j))?;
|
|
||||||
println!("{}", d);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let msg = "Nothing in this table!";
|
|
||||||
let x = rect.x + (rect.w - msg.len() as u16) / 2;
|
|
||||||
let y = rect.y + rect.h / 2;
|
|
||||||
// queue!(w, cursor::MoveTo(x, y))?;
|
|
||||||
println!("{}", msg);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalidate(&mut self) {
|
|
||||||
// TODO: do something
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::{cursor::MoveTo, event::Event};
|
|
||||||
|
|
||||||
use super::{DrawBuf, Rect, Screen, Widget};
|
|
||||||
|
|
||||||
pub struct Tabs {
|
|
||||||
id_incr: usize,
|
|
||||||
active_id: usize,
|
|
||||||
names: Vec<(usize, String)>,
|
|
||||||
contents: HashMap<usize, Box<dyn Widget>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Tabs {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Tabs {
|
|
||||||
id_incr: 0,
|
|
||||||
active_id: 0,
|
|
||||||
names: Vec::new(),
|
|
||||||
contents: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_tab(&mut self, name: impl AsRef<str>, drawable: impl Widget + 'static) {
|
|
||||||
let id = self.id_incr;
|
|
||||||
self.id_incr += 1;
|
|
||||||
|
|
||||||
self.names.push((id, name.as_ref().to_owned()));
|
|
||||||
self.contents.insert(id, Box::new(drawable));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for Tabs {
|
|
||||||
fn update(&mut self, event: Option<Event>) {}
|
|
||||||
|
|
||||||
fn draw(&self, buf: &mut DrawBuf, rect: Rect) -> Result<()> {
|
|
||||||
// queue!(w, MoveTo(rect.x, rect.y))?;
|
|
||||||
for (id, name) in self.names.iter() {
|
|
||||||
println!(" {} ", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_rect = Rect::new(rect.x, rect.y + 1, rect.w, rect.h - 1);
|
|
||||||
if let Some(widget) = self.contents.get(&self.active_id) {
|
|
||||||
widget.draw(buf, new_rect)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalidate(&mut self) {}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::event::Event;
|
|
||||||
|
|
||||||
use super::{DrawBuf, Rect, Screen};
|
|
||||||
|
|
||||||
pub trait Widget {
|
|
||||||
/// Updates the widget given an event
|
|
||||||
fn update(&mut self, event: Option<Event>);
|
|
||||||
|
|
||||||
/// Draws this UI element to the screen
|
|
||||||
fn draw(&self, buf: &mut DrawBuf, rect: Rect) -> Result<()>;
|
|
||||||
|
|
||||||
/// Invalidates this UI element, queueing it for redraw
|
|
||||||
fn invalidate(&mut self);
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "panorama-tui"
|
|
||||||
version = "0.1.0"
|
|
||||||
authors = ["Michael Zhang <mail@mzhang.io>"]
|
|
||||||
edition = "2018"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1.0.40"
|
|
||||||
crossterm = { version = "0.19.0", features = ["event-stream"] }
|
|
||||||
futures = "0.3.13"
|
|
||||||
log = "0.4.14"
|
|
||||||
tokio = { version = "1.4.0", features = ["full"] }
|
|
||||||
tui = { version = "0.14.0", default-features = false, features = ["crossterm"] }
|
|
340
tui/src/lib.rs
340
tui/src/lib.rs
|
@ -1,340 +0,0 @@
|
||||||
//! messily modified version of `tui` (MIT licensed) with async support for events
|
|
||||||
//!
|
|
||||||
//! (note: still uses synchronous stdout api since crossterm doesn't have tokio support
|
|
||||||
//! and no way i'm porting crossterm to tokio)
|
|
||||||
|
|
||||||
#[macro_use]
|
|
||||||
extern crate log;
|
|
||||||
|
|
||||||
pub extern crate crossterm;
|
|
||||||
pub extern crate tui;
|
|
||||||
|
|
||||||
use std::io::{Stdout, Write};
|
|
||||||
use std::mem;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::{
|
|
||||||
cursor::{MoveTo, Show},
|
|
||||||
execute, queue,
|
|
||||||
style::{
|
|
||||||
Attribute as CAttribute, Color as CColor, Print, SetAttribute, SetBackgroundColor,
|
|
||||||
SetForegroundColor,
|
|
||||||
},
|
|
||||||
terminal::{self, Clear, ClearType},
|
|
||||||
};
|
|
||||||
use futures::future::Future;
|
|
||||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
|
||||||
use tui::{
|
|
||||||
buffer::{Buffer, Cell},
|
|
||||||
layout::Rect,
|
|
||||||
style::{Color, Modifier},
|
|
||||||
widgets::Widget,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Terminal<W> {
|
|
||||||
stdout: W,
|
|
||||||
buffers: [Buffer; 2],
|
|
||||||
current: usize,
|
|
||||||
hidden_cursor: bool,
|
|
||||||
viewport: Viewport,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<W: Write> Terminal<W> {
|
|
||||||
pub fn new(stdout: W) -> Result<Self> {
|
|
||||||
let (width, height) = terminal::size()?;
|
|
||||||
let size = Rect::new(0, 0, width, height);
|
|
||||||
Terminal::with_options(
|
|
||||||
stdout,
|
|
||||||
TerminalOptions {
|
|
||||||
viewport: Viewport {
|
|
||||||
area: size,
|
|
||||||
resize_behavior: ResizeBehavior::Auto,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_options(stdout: W, options: TerminalOptions) -> Result<Self> {
|
|
||||||
Ok(Terminal {
|
|
||||||
stdout,
|
|
||||||
buffers: [
|
|
||||||
Buffer::empty(options.viewport.area),
|
|
||||||
Buffer::empty(options.viewport.area),
|
|
||||||
],
|
|
||||||
current: 0,
|
|
||||||
hidden_cursor: false,
|
|
||||||
viewport: options.viewport,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pre_draw(&mut self) -> Result<()> {
|
|
||||||
self.autoresize()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn post_draw(&mut self) -> Result<()> {
|
|
||||||
self.flush()?;
|
|
||||||
|
|
||||||
self.buffers[1 - self.current].reset();
|
|
||||||
self.current = 1 - self.current;
|
|
||||||
|
|
||||||
self.stdout.flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn draw<F, F2>(&mut self, f: F) -> Result<()>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut Frame<W>) -> F2,
|
|
||||||
F2: Future<Output = ()>,
|
|
||||||
{
|
|
||||||
self.autoresize()?;
|
|
||||||
|
|
||||||
let mut frame = self.get_frame();
|
|
||||||
f(&mut frame).await;
|
|
||||||
self.flush()?;
|
|
||||||
|
|
||||||
self.buffers[1 - self.current].reset();
|
|
||||||
self.current = 1 - self.current;
|
|
||||||
|
|
||||||
self.stdout.flush()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn autoresize(&mut self) -> Result<()> {
|
|
||||||
if self.viewport.resize_behavior == ResizeBehavior::Auto {
|
|
||||||
let size = self.size()?;
|
|
||||||
if size != self.viewport.area {
|
|
||||||
self.resize(size)?;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resize(&mut self, area: Rect) -> Result<()> {
|
|
||||||
self.buffers[self.current].resize(area);
|
|
||||||
self.buffers[1 - self.current].resize(area);
|
|
||||||
self.viewport.area = area;
|
|
||||||
self.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear(&mut self) -> Result<()> {
|
|
||||||
execute!(self.stdout, Clear(ClearType::All))?;
|
|
||||||
// Reset the back buffer to make sure the next update will redraw everything.
|
|
||||||
self.buffers[1 - self.current].reset();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush(&mut self) -> Result<()> {
|
|
||||||
let previous_buffer = &self.buffers[1 - self.current];
|
|
||||||
let current_buffer = &self.buffers[self.current];
|
|
||||||
let updates = previous_buffer
|
|
||||||
.diff(current_buffer)
|
|
||||||
.into_iter()
|
|
||||||
.map(|(a, b, c)| (a, b, c.clone()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
self.draw_backend(updates.into_iter())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_frame(&mut self) -> Frame<W> {
|
|
||||||
Frame {
|
|
||||||
terminal: self,
|
|
||||||
cursor_position: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_backend<I>(&mut self, content: I) -> Result<()>
|
|
||||||
where
|
|
||||||
I: Iterator<Item = (u16, u16, Cell)>,
|
|
||||||
{
|
|
||||||
let mut fg = Color::Reset;
|
|
||||||
let mut bg = Color::Reset;
|
|
||||||
let mut modifier = Modifier::empty();
|
|
||||||
let mut last_pos: Option<(u16, u16)> = None;
|
|
||||||
for (x, y, cell) in content {
|
|
||||||
// Move the cursor if the previous location was not (x - 1, y)
|
|
||||||
if !matches!(last_pos, Some(p) if x == p.0 + 1 && y == p.1) {
|
|
||||||
queue!(self.stdout, MoveTo(x, y))?;
|
|
||||||
}
|
|
||||||
last_pos = Some((x, y));
|
|
||||||
if cell.modifier != modifier {
|
|
||||||
let diff = ModifierDiff {
|
|
||||||
from: modifier,
|
|
||||||
to: cell.modifier,
|
|
||||||
};
|
|
||||||
diff.queue(&mut self.stdout)?;
|
|
||||||
modifier = cell.modifier;
|
|
||||||
}
|
|
||||||
if cell.fg != fg {
|
|
||||||
let color = color_conv(cell.fg);
|
|
||||||
queue!(self.stdout, SetForegroundColor(color))?;
|
|
||||||
fg = cell.fg;
|
|
||||||
}
|
|
||||||
if cell.bg != bg {
|
|
||||||
let color = color_conv(cell.bg);
|
|
||||||
queue!(self.stdout, SetBackgroundColor(color))?;
|
|
||||||
bg = cell.bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
queue!(self.stdout, Print(&cell.symbol))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
queue!(
|
|
||||||
self.stdout,
|
|
||||||
SetForegroundColor(CColor::Reset),
|
|
||||||
SetBackgroundColor(CColor::Reset),
|
|
||||||
SetAttribute(CAttribute::Reset)
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_buffer_mut(&mut self) -> &mut Buffer {
|
|
||||||
&mut self.buffers[self.current]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_cursor(&mut self) -> Result<()> {
|
|
||||||
execute!(self.stdout, Show)?;
|
|
||||||
self.hidden_cursor = false;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_cursor(&mut self, x: u16, y: u16) -> Result<()> {
|
|
||||||
execute!(self.stdout, MoveTo(x, y))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn size(&self) -> Result<Rect> {
|
|
||||||
let (width, height) = terminal::size()?;
|
|
||||||
Ok(Rect::new(0, 0, width, height))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_conv(color: Color) -> CColor {
|
|
||||||
match color {
|
|
||||||
Color::Reset => CColor::Reset,
|
|
||||||
Color::Black => CColor::Black,
|
|
||||||
Color::Red => CColor::DarkRed,
|
|
||||||
Color::Green => CColor::DarkGreen,
|
|
||||||
Color::Yellow => CColor::DarkYellow,
|
|
||||||
Color::Blue => CColor::DarkBlue,
|
|
||||||
Color::Magenta => CColor::DarkMagenta,
|
|
||||||
Color::Cyan => CColor::DarkCyan,
|
|
||||||
Color::Gray => CColor::Grey,
|
|
||||||
Color::DarkGray => CColor::DarkGrey,
|
|
||||||
Color::LightRed => CColor::Red,
|
|
||||||
Color::LightGreen => CColor::Green,
|
|
||||||
Color::LightBlue => CColor::Blue,
|
|
||||||
Color::LightYellow => CColor::Yellow,
|
|
||||||
Color::LightMagenta => CColor::Magenta,
|
|
||||||
Color::LightCyan => CColor::Cyan,
|
|
||||||
Color::White => CColor::White,
|
|
||||||
Color::Indexed(i) => CColor::AnsiValue(i),
|
|
||||||
Color::Rgb(r, g, b) => CColor::Rgb { r, g, b },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Frame<'a, W> {
|
|
||||||
terminal: &'a mut Terminal<W>,
|
|
||||||
cursor_position: Option<(u16, u16)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, W: Write> Frame<'a, W> {
|
|
||||||
pub fn set_cursor(&mut self, x: u16, y: u16) {
|
|
||||||
self.cursor_position = Some((x, y));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn size(&self) -> Rect {
|
|
||||||
self.terminal.viewport.area
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_widget<W2>(&mut self, widget: W2, area: Rect)
|
|
||||||
where
|
|
||||||
W2: Widget,
|
|
||||||
{
|
|
||||||
widget.render(area, self.terminal.current_buffer_mut());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct ModifierDiff {
|
|
||||||
pub from: Modifier,
|
|
||||||
pub to: Modifier,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModifierDiff {
|
|
||||||
fn queue<W: Write>(&self, w: &mut W) -> Result<()> {
|
|
||||||
//use crossterm::Attribute;
|
|
||||||
let removed = self.from - self.to;
|
|
||||||
if removed.contains(Modifier::REVERSED) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NoReverse))?;
|
|
||||||
}
|
|
||||||
if removed.contains(Modifier::BOLD) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
|
||||||
if self.to.contains(Modifier::DIM) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::Dim))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if removed.contains(Modifier::ITALIC) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NoItalic))?;
|
|
||||||
}
|
|
||||||
if removed.contains(Modifier::UNDERLINED) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NoUnderline))?;
|
|
||||||
}
|
|
||||||
if removed.contains(Modifier::DIM) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
|
||||||
}
|
|
||||||
if removed.contains(Modifier::CROSSED_OUT) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
|
|
||||||
}
|
|
||||||
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::NoBlink))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let added = self.to - self.from;
|
|
||||||
if added.contains(Modifier::REVERSED) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::Reverse))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::BOLD) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::Bold))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::ITALIC) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::Italic))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::UNDERLINED) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::Underlined))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::DIM) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::Dim))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::CROSSED_OUT) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::CrossedOut))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::SLOW_BLINK) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::SlowBlink))?;
|
|
||||||
}
|
|
||||||
if added.contains(Modifier::RAPID_BLINK) {
|
|
||||||
queue!(w, SetAttribute(CAttribute::RapidBlink))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
enum ResizeBehavior {
|
|
||||||
Fixed,
|
|
||||||
Auto,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Viewport {
|
|
||||||
area: Rect,
|
|
||||||
resize_behavior: ResizeBehavior,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
/// Options to pass to [`Terminal::with_options`]
|
|
||||||
pub struct TerminalOptions {
|
|
||||||
/// Viewport used to draw to the terminal
|
|
||||||
pub viewport: Viewport,
|
|
||||||
}
|
|
Loading…
Reference in a new issue