This commit is contained in:
Michael Zhang 2021-07-19 18:16:08 -05:00
parent c3f2037668
commit 2893b22d03
Signed by: michael
GPG key ID: BDA47A31A3C8EE6B
71 changed files with 1506 additions and 14801 deletions

View file

@ -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
View file

@ -5,3 +5,4 @@
/public /public
/hellosu /hellosu
/hellosu.db* /hellosu.db*

3677
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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

View file

@ -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
View file

@ -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
View file

@ -1 +0,0 @@
book

View file

@ -1,6 +0,0 @@
[book]
authors = ["Michael Zhang"]
language = "en"
multilingual = false
src = "src"
title = "Panorama"

View file

@ -1,5 +0,0 @@
# Summary
- [Intro](./intro.md)
- [Config](./config.md)
- [Code Structure](./code.md)

View file

@ -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.

View file

@ -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/

View file

@ -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
View file

@ -1 +0,0 @@
out.rs

View file

@ -1,2 +0,0 @@
src/builders
src/oldparser

View file

@ -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 = []

View file

@ -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?

View file

@ -1,4 +0,0 @@
target
corpus
artifacts

View file

@ -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 = ["."]

View file

@ -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);
});

View file

@ -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());
}
}

View file

@ -1 +0,0 @@
pub mod command;

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -1,3 +0,0 @@
pub struct Client {
}

View file

@ -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)
}
}

View file

@ -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, ""),
}
}
}

View file

@ -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;

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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 = _{ " " }

View file

@ -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,
}),
]
))
);
}

View file

@ -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,
}

View file

@ -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>>,
}

View file

@ -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
);

View file

@ -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
View 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
View file

@ -0,0 +1,5 @@
pub struct Panorama {}
impl Panorama {
pub fn init() {}
}

13
panorama-gui/Cargo.toml Normal file
View 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
View file

@ -0,0 +1,6 @@
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
Ok(())
}

View file

@ -1 +0,0 @@
*

View file

@ -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]

File diff suppressed because it is too large Load diff

View file

@ -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]

View file

@ -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

View file

@ -1,7 +0,0 @@
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}

View file

@ -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))
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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,
}
}
}

View file

@ -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
}
}

View file

@ -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(())
}

View file

@ -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()),
}
}

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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 {}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -1 +0,0 @@
pub struct Keybinds {}

View file

@ -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));
// }
}
}

View file

@ -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 {}

View file

@ -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(())
}
}

View file

@ -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(),
}
}
}

View file

@ -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(())
}
}

View file

@ -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(())
}

View file

@ -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
}
}

View file

@ -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) {}
}

View file

@ -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);
}

View file

@ -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"] }

View file

@ -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,
}