mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-02 11:07:06 +00:00
The client itself: the sync engine and mirror, the Gmail and IMAP providers, the screener, the three boxes and two piles, compose and send, the sanitiser and tracker stripping, contacts, clips, backup, notifications and the guide. The pipeline that ships it. Three runners now build in parallel and the publish gate wants all four platform keys in latest.json before a release leaves draft. Linux gets two more packages Tauri does not build: a flatpak repackaged from the deb against GNOME 48, and a Nix package that relinks the deb against nixpkgs so it runs as a native Wayland client rather than through Xwayland. CI runs the Rust suite on all three desktops rather than on Linux alone, and rebuilds the flatpak manifest on every push to main. Two things that were only ever exercised on macOS and were wrong everywhere else. The menu bar was built by adjusting the submenus macOS is given, so on a platform whose default menu has no File or View the new File menu landed after Edit, Check for Updates landed nowhere, and the three places and the reading pane had no menu at all. The deb declared libwebkit2gtk-4.1-0 and libgtk-3-0 twice, because tauri.conf.json named the dependencies Tauri already emits. The updater key exists now, so releases can sign their artifacts.
153 lines
5.5 KiB
Rust
153 lines
5.5 KiB
Rust
// The export format, which is the part somebody else's software has to be able to read.
|
|
|
|
use rusqlite::Connection;
|
|
|
|
use crate::db;
|
|
use crate::dto::Destination;
|
|
use crate::state;
|
|
|
|
use super::{escape_from_lines, mbox_entry, write_mbox};
|
|
|
|
fn open() -> Connection {
|
|
db::memory().expect("a pair of in-memory databases")
|
|
}
|
|
|
|
fn message(conn: &Connection, id: &str, from: &str, date_ms: i64, raw: &str) {
|
|
conn.execute(
|
|
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
|
from_address, subject, hydrated)
|
|
VALUES (?1, ?1, ?1, ?1, ?2, ?3, 'Subject', 1)",
|
|
rusqlite::params![id, date_ms, from],
|
|
)
|
|
.expect("a message");
|
|
conn.execute(
|
|
"INSERT INTO bodies (message_id, raw, fetched_at) VALUES (?1, ?2, 1)",
|
|
rusqlite::params![id, raw.as_bytes()],
|
|
)
|
|
.expect("a body");
|
|
}
|
|
|
|
#[test]
|
|
fn a_body_line_that_looks_like_a_separator_is_escaped() {
|
|
// The one thing mbox can get wrong. A message quoting an email header would otherwise split
|
|
// into two messages in whatever reads the file.
|
|
let mut out = Vec::new();
|
|
escape_from_lines(b"Hello\nFrom Ana, quoted\nBye\n", &mut out);
|
|
assert_eq!(
|
|
String::from_utf8(out).unwrap(),
|
|
"Hello\n>From Ana, quoted\nBye\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_from_in_the_middle_of_a_line_is_left_alone() {
|
|
let mut out = Vec::new();
|
|
escape_from_lines(b"a note From Ana\n", &mut out);
|
|
assert_eq!(String::from_utf8(out).unwrap(), "a note From Ana\n");
|
|
}
|
|
|
|
#[test]
|
|
fn an_entry_carries_the_separator_the_format_asks_for() {
|
|
let mut out = Vec::new();
|
|
mbox_entry("[email protected]", 1_760_000_000_000, b"Subject: Hi\n\nHello\n", &mut out);
|
|
let text = String::from_utf8(out).unwrap();
|
|
assert!(text.starts_with("From [email protected] "), "got {text}");
|
|
assert!(text.contains("\nSubject: Hi\n"));
|
|
assert!(text.ends_with("\n\n"), "an entry ends with a blank line");
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_with_no_body_on_the_device_is_not_an_empty_entry() {
|
|
let conn = open();
|
|
message(&conn, "m1", "[email protected]", 1_760_000_000_000, "Subject: One\n\nOne\n");
|
|
// Headers with no body, which is what the mirror holds until a thread is opened.
|
|
conn.execute(
|
|
"INSERT INTO messages (id, provider_thread_id, thread_key, message_id, date_ms,
|
|
from_address, subject, hydrated)
|
|
VALUES ('m2', 'm2', 'm2', 'm2', 1760000001000, '[email protected]', 'Two', 1)",
|
|
[],
|
|
)
|
|
.expect("a message with no body");
|
|
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let path = dir.path().join("out.mbox");
|
|
let written = write_mbox(&conn, &path).expect("an mbox");
|
|
|
|
assert_eq!(written, 1, "the export is of what is on the device");
|
|
let text = std::fs::read_to_string(&path).expect("the file");
|
|
assert!(text.contains("From [email protected] "));
|
|
assert!(!text.contains("[email protected]"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_decisions_round_trip_through_the_journal() {
|
|
let from = open();
|
|
state::write::set_rule(&from, "[email protected]", false, Destination::Inbox, None)
|
|
.expect("a rule");
|
|
state::write::add_note(&from, "<t1@example>", "Ask about the oak finish", None)
|
|
.expect("a note");
|
|
|
|
let records: Vec<_> = state::merge::devices(&from)
|
|
.expect("devices")
|
|
.into_iter()
|
|
.flat_map(|device| state::merge::export(&from, &device, 0).expect("export"))
|
|
.collect();
|
|
let encoded = state::merge::encode(&records).expect("encode");
|
|
|
|
// A second device with nothing on it, which is what an import into a fresh install is.
|
|
let to = open();
|
|
state::merge::absorb(&to, &state::merge::decode(&encoded).expect("decode")).expect("absorb");
|
|
|
|
assert_eq!(
|
|
state::read::destination_for(&to, "[email protected]").expect("rule"),
|
|
Some(Destination::Inbox)
|
|
);
|
|
assert_eq!(state::read::notes_on(&to, "<t1@example>").expect("notes").len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn importing_the_same_export_twice_changes_nothing() {
|
|
let from = open();
|
|
state::write::set_rule(&from, "[email protected]", false, Destination::Feed, None)
|
|
.expect("a rule");
|
|
let records: Vec<_> = state::merge::devices(&from)
|
|
.expect("devices")
|
|
.into_iter()
|
|
.flat_map(|device| state::merge::export(&from, &device, 0).expect("export"))
|
|
.collect();
|
|
|
|
let to = open();
|
|
state::merge::absorb(&to, &records).expect("first");
|
|
state::merge::absorb(&to, &records).expect("second");
|
|
|
|
let rules = state::read::rules(&to, "acct").expect("rules");
|
|
assert_eq!(rules.len(), 1, "an import is a merge, not an append");
|
|
}
|
|
|
|
#[test]
|
|
fn importing_into_an_account_that_has_moved_on_is_a_merge_rather_than_a_loss() {
|
|
let from = open();
|
|
state::write::set_rule(&from, "[email protected]", false, Destination::Feed, None)
|
|
.expect("the exported rule");
|
|
let records: Vec<_> = state::merge::devices(&from)
|
|
.expect("devices")
|
|
.into_iter()
|
|
.flat_map(|device| state::merge::export(&from, &device, 0).expect("export"))
|
|
.collect();
|
|
|
|
let to = open();
|
|
state::write::set_rule(&to, "[email protected]", false, Destination::Inbox, None)
|
|
.expect("a decision made since");
|
|
state::merge::absorb(&to, &records).expect("absorb");
|
|
|
|
assert_eq!(
|
|
state::read::destination_for(&to, "[email protected]").expect("kept"),
|
|
Some(Destination::Inbox),
|
|
"a decision made here is not lost by importing one made there"
|
|
);
|
|
assert_eq!(
|
|
state::read::destination_for(&to, "[email protected]").expect("arrived"),
|
|
Some(Destination::Feed)
|
|
);
|
|
}
|