ElyMail's window lists accounts and adds Gmail, Microsoft, iCloud (app-specific password), IMAP and POP3 ones; a check logs in to both servers and reports each. Accounts live in the store as JSON with a case-insensitive unique address; secrets go to the keychain. Removal names the cache it deletes, takes the secret first and puts it back when local removal fails. Originals are set aside until commit and restored after a crash. Requests are numbered so late results stay with their own dialog. The account list is keyboard-navigable.
204 lines
6.9 KiB
Rust
204 lines
6.9 KiB
Rust
mod fake;
|
|
|
|
use std::path::Path;
|
|
|
|
use ely_mail_engine::{Error, Fetched, Key, MailboxId, MessageId, Store};
|
|
use rusqlite::Connection;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
/// The sample as a server sends it: CRLF lines.
|
|
fn sample() -> Vec<u8> {
|
|
let path = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../tests/fixtures/mail/sample.eml"
|
|
);
|
|
let text = std::fs::read_to_string(path).unwrap();
|
|
text.replace("\r\n", "\n")
|
|
.replace('\n', "\r\n")
|
|
.into_bytes()
|
|
}
|
|
|
|
fn headers(raw: &[u8]) -> &[u8] {
|
|
let end = raw.windows(4).position(|w| w == b"\r\n\r\n").unwrap();
|
|
&raw[..end + 4]
|
|
}
|
|
|
|
fn inbox(dir: &Path) -> (Store, MailboxId) {
|
|
let store = Store::open(dir).unwrap();
|
|
let account = store
|
|
.add_account(&fake::account("ann@example.com"))
|
|
.unwrap();
|
|
let inbox = store.mailbox(account, "INBOX").unwrap();
|
|
(store, inbox)
|
|
}
|
|
|
|
fn imap(uidvalidity: u32, uid: u32) -> Key {
|
|
Key::Imap { uidvalidity, uid }
|
|
}
|
|
|
|
/// Rows whose index holds the phrase.
|
|
fn found(dir: &Path, phrase: &str) -> Vec<i64> {
|
|
let conn = Connection::open(dir.join("mail.db")).unwrap();
|
|
let mut rows = conn
|
|
.prepare("SELECT rowid FROM search WHERE search MATCH ?1 ORDER BY rowid")
|
|
.unwrap();
|
|
rows.query_map([format!("\"{phrase}\"")], |row| row.get(0))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect()
|
|
}
|
|
|
|
fn original_path(dir: &Path, raw: &[u8]) -> std::path::PathBuf {
|
|
let hash: String = Sha256::digest(raw)
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect();
|
|
dir.join("content").join(&hash[..2]).join(hash)
|
|
}
|
|
|
|
#[test]
|
|
fn a_whole_message_is_indexed_and_kept_by_its_hash() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mut store, inbox) = inbox(dir.path());
|
|
let raw = sample();
|
|
let id = store
|
|
.put(inbox, &imap(7, 42), &["\\Seen"], Fetched::Whole(&raw))
|
|
.unwrap();
|
|
let stored = store.get(id).unwrap();
|
|
assert_eq!(stored.key, imap(7, 42));
|
|
assert_eq!(stored.subject.as_deref(), Some("Café report"));
|
|
assert_eq!(stored.from[0].email, "zhang@example.com");
|
|
assert_eq!(stored.message_id.as_deref(), Some("m1@example.com"));
|
|
assert_eq!(stored.flags, ["\\Seen"]);
|
|
assert_eq!(stored.attachments, Some(1));
|
|
assert!(stored.whole);
|
|
assert_eq!(store.original(id).unwrap().as_deref(), Some(raw.as_slice()));
|
|
assert_eq!(std::fs::read(original_path(dir.path(), &raw)).unwrap(), raw);
|
|
assert_eq!(found(dir.path(), "numbers"), [id.0]);
|
|
assert_eq!(found(dir.path(), "report.pdf"), [id.0]);
|
|
let sizes = store.sizes().unwrap();
|
|
assert_eq!(sizes.originals, raw.len() as u64);
|
|
assert!(sizes.search > 0 && sizes.index > 0, "{sizes:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn headers_first_then_the_whole_message() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mut store, inbox) = inbox(dir.path());
|
|
let raw = sample();
|
|
let key = imap(7, 42);
|
|
let id = store
|
|
.put(inbox, &key, &[], Fetched::Headers(headers(&raw)))
|
|
.unwrap();
|
|
let stored = store.get(id).unwrap();
|
|
assert_eq!(stored.subject.as_deref(), Some("Café report"));
|
|
assert_eq!((stored.whole, stored.attachments), (false, None));
|
|
assert_eq!(store.original(id).unwrap(), None);
|
|
assert_eq!(found(dir.path(), "report"), [id.0]);
|
|
assert!(found(dir.path(), "numbers").is_empty());
|
|
|
|
let again = store
|
|
.put(inbox, &key, &["\\Flagged"], Fetched::Headers(headers(&raw)))
|
|
.unwrap();
|
|
assert_eq!(again, id);
|
|
assert_eq!(store.get(id).unwrap().flags, ["\\Flagged"]);
|
|
|
|
assert_eq!(
|
|
store.put(inbox, &key, &[], Fetched::Whole(&raw)).unwrap(),
|
|
id
|
|
);
|
|
assert_eq!(store.get(id).unwrap().attachments, Some(1));
|
|
assert_eq!(found(dir.path(), "numbers"), [id.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn uidvalidity_is_part_of_the_identity() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mut store, inbox) = inbox(dir.path());
|
|
let raw = sample();
|
|
let old = store
|
|
.put(inbox, &imap(1, 5), &[], Fetched::Headers(headers(&raw)))
|
|
.unwrap();
|
|
let new = store
|
|
.put(inbox, &imap(2, 5), &[], Fetched::Headers(headers(&raw)))
|
|
.unwrap();
|
|
assert_ne!(old, new);
|
|
assert_eq!(store.find(inbox, &imap(1, 5)).unwrap(), Some(old));
|
|
let pop = Key::Pop { uidl: "abc".into() };
|
|
let id = store
|
|
.put(inbox, &pop, &[], Fetched::Headers(headers(&raw)))
|
|
.unwrap();
|
|
assert_eq!(store.get(id).unwrap().key, pop);
|
|
}
|
|
|
|
#[test]
|
|
fn a_shared_original_goes_with_its_last_message() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mut store, inbox) = inbox(dir.path());
|
|
let raw = sample();
|
|
let file = original_path(dir.path(), &raw);
|
|
let one = store
|
|
.put(inbox, &imap(1, 1), &[], Fetched::Whole(&raw))
|
|
.unwrap();
|
|
let two = store
|
|
.put(inbox, &imap(1, 2), &[], Fetched::Whole(&raw))
|
|
.unwrap();
|
|
assert_eq!(store.sizes().unwrap().originals, raw.len() as u64);
|
|
store.remove(one).unwrap();
|
|
assert!(file.exists());
|
|
assert_eq!(found(dir.path(), "numbers"), [two.0]);
|
|
store.remove(two).unwrap();
|
|
assert!(!file.exists());
|
|
assert!(found(dir.path(), "numbers").is_empty());
|
|
let err = store.remove(MessageId(two.0)).unwrap_err();
|
|
assert!(
|
|
matches!(&err, Error::Store(text) if text.contains("no message")),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_original_is_refused() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mut store, inbox) = inbox(dir.path());
|
|
let raw = sample();
|
|
let id = store
|
|
.put(inbox, &imap(1, 1), &[], Fetched::Whole(&raw))
|
|
.unwrap();
|
|
std::fs::write(original_path(dir.path(), &raw), b"not the message").unwrap();
|
|
let err = store.original(id).unwrap_err();
|
|
assert!(
|
|
matches!(&err, Error::Store(text) if text.contains("damaged")),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_search_index_rebuilds_from_the_store() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (mut store, inbox) = inbox(dir.path());
|
|
let whole = store
|
|
.put(inbox, &imap(1, 1), &[], Fetched::Whole(&sample()))
|
|
.unwrap();
|
|
let html = b"Subject: news\r\nContent-Type: text/html\r\n\r\n\
|
|
<style>.hidden{color:red}</style><p>Quarterly<b>results</b></p>\
|
|
<script>trackingpixel()</script>";
|
|
let page = store
|
|
.put(inbox, &imap(1, 2), &[], Fetched::Whole(html))
|
|
.unwrap();
|
|
assert_eq!(found(dir.path(), "results"), [page.0]);
|
|
assert!(found(dir.path(), "tracking").is_empty());
|
|
assert!(found(dir.path(), "hidden").is_empty());
|
|
assert!(found(dir.path(), "Quarterlyresults").is_empty());
|
|
|
|
Connection::open(dir.path().join("mail.db"))
|
|
.unwrap()
|
|
.execute("DELETE FROM search", [])
|
|
.unwrap();
|
|
assert!(found(dir.path(), "numbers").is_empty());
|
|
assert_eq!(store.rebuild_search().unwrap(), 2);
|
|
assert_eq!(found(dir.path(), "numbers"), [whole.0]);
|
|
assert_eq!(found(dir.path(), "zhang"), [whole.0]);
|
|
assert_eq!(found(dir.path(), "results"), [page.0]);
|
|
}
|