Initial commit: Outlook-competitor desktop mail client

Tauri v2 (Rust backend, React/TypeScript frontend). IMAP/POP3/SMTP core,
Google OAuth (mail/calendar/contacts sync), CalDAV, local calendar with
recurrence, rules engine, contacts, tasks, unified inbox, context menus,
drag & drop, snooze, templates, and more.
This commit is contained in:
2026-07-28 20:56:05 +02:00
commit 20bfd2cf6b
107 changed files with 25728 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+5979
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
tokio = { version = "1", features = ["full"] }
imap = "3.0.0-alpha.15"
native-tls = "0.2"
mailparse = "0.15"
lettre = { version = "0.11", default-features = false, features = ["tokio1-native-tls", "smtp-transport", "builder", "pool"] }
rusqlite = { version = "0.31", features = ["bundled"] }
keyring = "4"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
base64 = "0.22"
anyhow = "1"
thiserror = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "native-tls"] }
webbrowser = "1"
sha2 = "0.10"
quick-xml = "0.41"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main",
"compose-*"
],
"permissions": [
"core:default",
"dialog:default",
"notification:default",
"core:webview:allow-create-webview-window",
"core:window:allow-close",
"core:window:allow-set-focus"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+71
View File
@@ -0,0 +1,71 @@
use crate::db::Db;
use crate::error::AppResult;
use crate::models::{Account, AuthMethod, NewAccount};
use keyring::Entry;
use uuid::Uuid;
const SERVICE: &str = "com.mailclient.app";
fn keyring_entry(account_id: &str) -> AppResult<Entry> {
Ok(Entry::new(SERVICE, account_id)?)
}
pub fn store_password(account_id: &str, password: &str) -> AppResult<()> {
keyring_entry(account_id)?.set_password(password)?;
Ok(())
}
pub fn get_password(account_id: &str) -> AppResult<String> {
Ok(keyring_entry(account_id)?.get_password()?)
}
pub fn delete_password(account_id: &str) -> AppResult<()> {
let _ = keyring_entry(account_id)?.delete_credential();
Ok(())
}
pub fn create_account(db: &Db, new: NewAccount) -> AppResult<Account> {
let id = Uuid::new_v4().to_string();
let account = Account {
id: id.clone(),
label: new.label,
email: new.email,
username: new.username,
protocol: new.protocol,
incoming_host: new.incoming_host,
incoming_port: new.incoming_port,
smtp_host: new.smtp_host,
smtp_port: new.smtp_port,
smtp_starttls: new.smtp_starttls,
auth_method: AuthMethod::Password,
};
store_password(&id, &new.password)?;
db.save_account(&account)?;
Ok(account)
}
pub fn create_google_account(db: &Db, email: String, refresh_token: &str) -> AppResult<Account> {
let id = Uuid::new_v4().to_string();
let account = Account {
id: id.clone(),
label: email.clone(),
email: email.clone(),
username: email,
protocol: crate::models::Protocol::Imap,
incoming_host: "imap.gmail.com".into(),
incoming_port: 993,
smtp_host: "smtp.gmail.com".into(),
smtp_port: 465,
smtp_starttls: false,
auth_method: AuthMethod::GoogleOauth,
};
store_password(&id, refresh_token)?;
db.save_account(&account)?;
Ok(account)
}
pub fn remove_account(db: &Db, account_id: &str) -> AppResult<()> {
delete_password(account_id)?;
db.delete_account(account_id)?;
Ok(())
}
+205
View File
@@ -0,0 +1,205 @@
use crate::error::{AppError, AppResult};
use crate::ics;
use crate::models::Event;
use quick_xml::events::Event as XmlEvent;
use quick_xml::reader::Reader;
/// Everything needed to authenticate a request against a CalDAV calendar
/// collection. `url` must be the direct collection URL (e.g. Nextcloud's
/// `.../remote.php/dav/calendars/<user>/<calendar>/`) — this module does not do
/// principal/calendar-home-set auto-discovery, the user supplies it directly.
pub struct CaldavCreds {
pub url: String,
pub username: String,
pub password: String,
}
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
fn check_status(resp: &reqwest::blocking::Response, action: &str) -> AppResult<()> {
if !resp.status().is_success() {
return Err(AppError::Other(format!("CalDAV {action} fehlgeschlagen: HTTP {}", resp.status())));
}
Ok(())
}
fn local_name(bytes: &[u8]) -> String {
// `local_name()` already strips any namespace prefix (e.g. "d:href" -> "href"),
// which is what lets this work across servers using different prefixes.
String::from_utf8_lossy(bytes).to_string()
}
struct RawResponse {
href: String,
etag: Option<String>,
calendar_data: Option<String>,
}
fn apply_field(cur: &mut RawResponse, field: &str, text: &str) {
match field {
"href" => cur.href.push_str(text),
"getetag" => cur.etag = Some(cur.etag.take().unwrap_or_default() + text),
"calendar-data" => cur.calendar_data = Some(cur.calendar_data.take().unwrap_or_default() + text),
_ => {}
}
}
/// Parses a CalDAV/WebDAV multistatus (207) XML response into one entry per
/// `<d:response>`. Namespace-prefix-agnostic (matches by local element name only),
/// since different servers use different prefixes for the same DAV/CalDAV elements.
fn parse_multistatus(xml: &str) -> Vec<RawResponse> {
let mut reader = Reader::from_str(xml);
let mut buf = Vec::new();
let mut out = Vec::new();
let mut current: Option<RawResponse> = None;
let mut field: Option<String> = None;
loop {
let event = match reader.read_event_into(&mut buf) {
Ok(XmlEvent::Eof) => break,
Ok(e) => e,
Err(_) => break,
};
match event {
XmlEvent::Start(e) => {
let name = local_name(e.local_name().as_ref());
match name.as_str() {
"response" => current = Some(RawResponse { href: String::new(), etag: None, calendar_data: None }),
"href" | "getetag" | "calendar-data" => field = Some(name),
_ => {}
}
}
XmlEvent::Text(t) => {
if let (Some(cur), Some(f)) = (current.as_mut(), field.as_deref()) {
let text = t.decode().map(|c| c.into_owned()).unwrap_or_default();
apply_field(cur, f, &text);
}
}
XmlEvent::CData(t) => {
if let (Some(cur), Some(f)) = (current.as_mut(), field.as_deref()) {
let text = String::from_utf8_lossy(t.as_ref()).to_string();
apply_field(cur, f, &text);
}
}
XmlEvent::End(e) => {
let name = local_name(e.local_name().as_ref());
match name.as_str() {
"href" | "getetag" | "calendar-data" => field = None,
"response" => {
if let Some(c) = current.take() {
out.push(c);
}
}
_ => {}
}
}
_ => {}
}
buf.clear();
}
out
}
fn resolve_href(base_url: &str, href: &str) -> String {
if href.starts_with("http://") || href.starts_with("https://") {
return href.to_string();
}
reqwest::Url::parse(base_url)
.ok()
.and_then(|base| base.join(href).ok())
.map(|u| u.to_string())
.unwrap_or_else(|| href.to_string())
}
/// Fetches every VEVENT resource in the calendar collection via a single REPORT
/// request. Each `Event` returned has its href/etag as separate return fields
/// (the caller decides how to persist them — this app reuses the `google_event_id`
/// column to store the href, mirroring how Google Calendar sync already stores its
/// own external id there).
pub fn list_events(creds: &CaldavCreds) -> AppResult<Vec<(String, Event)>> {
let body = r#"<?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>"#;
let resp = client()
.request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &creds.url)
.basic_auth(&creds.username, Some(&creds.password))
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", "1")
.body(body)
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
let status = resp.status();
if status.as_u16() != 207 && !status.is_success() {
let text = resp.text().unwrap_or_default();
return Err(AppError::Other(format!(
"CalDAV-Abfrage fehlgeschlagen (HTTP {status}). Prüfe Kalender-URL/Zugangsdaten. Server-Antwort: {text}"
)));
}
let text = resp.text().map_err(|e| AppError::Other(e.to_string()))?;
let responses = parse_multistatus(&text);
let mut out = Vec::new();
for r in responses {
let Some(data) = r.calendar_data else { continue };
if let Some(event) = ics::parse(&data).into_iter().next() {
out.push((r.href, event));
}
}
Ok(out)
}
/// Creates a new resource on the server for `event` and returns its href.
pub fn create_event(creds: &CaldavCreds, event: &Event) -> AppResult<String> {
let filename = format!("{}.ics", event.id);
let url = format!("{}/{}", creds.url.trim_end_matches('/'), filename);
let ics_body = ics::export(std::slice::from_ref(event));
let resp = client()
.put(&url)
.basic_auth(&creds.username, Some(&creds.password))
.header("Content-Type", "text/calendar; charset=utf-8")
.body(ics_body)
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
check_status(&resp, "Erstellen")?;
Ok(filename)
}
/// Overwrites the resource at `href` with `event`'s current data.
pub fn update_event(creds: &CaldavCreds, href: &str, event: &Event) -> AppResult<()> {
let url = resolve_href(&creds.url, href);
let ics_body = ics::export(std::slice::from_ref(event));
let resp = client()
.put(&url)
.basic_auth(&creds.username, Some(&creds.password))
.header("Content-Type", "text/calendar; charset=utf-8")
.body(ics_body)
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
check_status(&resp, "Aktualisieren")
}
pub fn delete_event(creds: &CaldavCreds, href: &str) -> AppResult<()> {
let url = resolve_href(&creds.url, href);
let resp = client()
.delete(&url)
.basic_auth(&creds.username, Some(&creds.password))
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
if resp.status().as_u16() == 404 {
return Ok(());
}
check_status(&resp, "Löschen")
}
File diff suppressed because it is too large Load Diff
+1530
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("IMAP error: {0}")]
Imap(String),
#[error("SMTP error: {0}")]
Smtp(String),
#[error("POP3 error: {0}")]
Pop3(String),
#[error("Database error: {0}")]
Db(String),
#[error("Keychain error: {0}")]
Keychain(String),
#[error("Parse error: {0}")]
Parse(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("{0}")]
Other(String),
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl From<rusqlite::Error> for AppError {
fn from(e: rusqlite::Error) -> Self {
AppError::Db(e.to_string())
}
}
impl From<keyring::Error> for AppError {
fn from(e: keyring::Error) -> Self {
AppError::Keychain(e.to_string())
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError::Other(e.to_string())
}
}
pub type AppResult<T> = Result<T, AppError>;
+200
View File
@@ -0,0 +1,200 @@
use crate::error::{AppError, AppResult};
use crate::models::Event;
use chrono::{Local, NaiveDate, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
const EVENTS_URL: &str = "https://www.googleapis.com/calendar/v3/calendars/primary/events";
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
fn rfc3339(ts: i64) -> String {
Utc.timestamp_opt(ts, 0).single().map(|d| d.to_rfc3339()).unwrap_or_default()
}
#[derive(Debug, Deserialize)]
struct GDateTime {
#[serde(rename = "dateTime")]
date_time: Option<String>,
date: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GEvent {
id: String,
#[serde(default)]
summary: String,
#[serde(default)]
location: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
status: Option<String>,
start: Option<GDateTime>,
end: Option<GDateTime>,
}
#[derive(Debug, Deserialize)]
struct ListResponse {
#[serde(default)]
items: Vec<GEvent>,
}
fn parse_gdatetime(d: &GDateTime) -> Option<(i64, bool)> {
if let Some(dt) = &d.date_time {
let parsed = chrono::DateTime::parse_from_rfc3339(dt).ok()?;
return Some((parsed.timestamp(), false));
}
if let Some(date) = &d.date {
let nd = NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()?;
let dt = nd.and_hms_opt(0, 0, 0)?;
let local = Local.from_local_datetime(&dt).single()?;
return Some((local.timestamp(), true));
}
None
}
fn check_status(resp: reqwest::blocking::Response, action: &str) -> AppResult<reqwest::blocking::Response> {
if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 {
let status = resp.status();
let body = resp.text().unwrap_or_default();
return Err(AppError::Other(format!(
"Kalenderzugriff verweigert ({action}, {status}). Entweder fehlt die Berechtigung (Konto neu verbinden) \
oder die Google Calendar API ist im Google-Cloud-Projekt nicht aktiviert (APIs & Services → Library). \
Google-Antwort: {body}"
)));
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
return Err(AppError::Other(format!("Google Kalender: {action} fehlgeschlagen ({status}): {body}")));
}
Ok(resp)
}
pub fn list_events(access_token: &str, time_min: i64, time_max: i64) -> AppResult<Vec<Event>> {
let resp = client()
.get(EVENTS_URL)
.bearer_auth(access_token)
.query(&[
("timeMin", rfc3339(time_min)),
("timeMax", rfc3339(time_max)),
("singleEvents", "true".to_string()),
("maxResults", "2500".to_string()),
("orderBy", "startTime".to_string()),
])
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
let resp = check_status(resp, "Abrufen")?;
let parsed: ListResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
let mut out = Vec::new();
for g in parsed.items {
if g.status.as_deref() == Some("cancelled") {
continue;
}
let (Some(start), Some(end)) = (g.start.as_ref(), g.end.as_ref()) else {
continue;
};
let (Some((start_ts, all_day)), Some((end_ts, _))) = (parse_gdatetime(start), parse_gdatetime(end)) else {
continue;
};
let id = Uuid::new_v4().to_string();
out.push(Event {
id: id.clone(),
base_id: id,
title: if g.summary.is_empty() { "(Ohne Titel)".to_string() } else { g.summary },
location: g.location,
description: g.description,
start_ts,
end_ts,
all_day,
account_id: None,
google_event_id: Some(g.id),
rrule: None,
reminder_minutes: None,
category: None,
exdates: None,
series_id: None,
});
}
Ok(out)
}
#[derive(Serialize)]
struct GDateTimeOut {
#[serde(rename = "dateTime", skip_serializing_if = "Option::is_none")]
date_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
date: Option<String>,
}
fn to_gdatetime(ts: i64, all_day: bool) -> GDateTimeOut {
if all_day {
let date = Local
.timestamp_opt(ts, 0)
.single()
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_default();
GDateTimeOut { date_time: None, date: Some(date) }
} else {
GDateTimeOut { date_time: Some(rfc3339(ts)), date: None }
}
}
fn event_body(event: &Event) -> serde_json::Value {
json!({
"summary": event.title,
"location": event.location,
"description": event.description,
"start": to_gdatetime(event.start_ts, event.all_day),
"end": to_gdatetime(event.end_ts, event.all_day),
})
}
#[derive(Deserialize)]
struct InsertResponse {
id: String,
}
pub fn insert_event(access_token: &str, event: &Event) -> AppResult<String> {
let resp = client()
.post(EVENTS_URL)
.bearer_auth(access_token)
.json(&event_body(event))
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
let resp = check_status(resp, "Erstellen")?;
let parsed: InsertResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
Ok(parsed.id)
}
pub fn update_event(access_token: &str, google_id: &str, event: &Event) -> AppResult<()> {
let url = format!("{EVENTS_URL}/{google_id}");
let resp = client()
.put(&url)
.bearer_auth(access_token)
.json(&event_body(event))
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
check_status(resp, "Aktualisieren")?;
Ok(())
}
pub fn delete_event(access_token: &str, google_id: &str) -> AppResult<()> {
let url = format!("{EVENTS_URL}/{google_id}");
let resp = client()
.delete(&url)
.bearer_auth(access_token)
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
// Google returns 410 Gone if the event was already removed server-side — treat as success.
if resp.status().as_u16() == 410 {
return Ok(());
}
check_status(resp, "Löschen")?;
Ok(())
}
+228
View File
@@ -0,0 +1,228 @@
use crate::error::{AppError, AppResult};
use serde::Deserialize;
const CONNECTIONS_URL: &str = "https://people.googleapis.com/v1/people/me/connections";
const OTHER_CONTACTS_URL: &str = "https://people.googleapis.com/v1/otherContacts";
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
#[derive(Debug, Deserialize)]
struct GName {
#[serde(rename = "displayName")]
display_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GEmailAddress {
value: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GOrganization {
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GPhoneNumber {
value: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GAddress {
#[serde(rename = "formattedValue")]
formatted_value: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GPerson {
#[serde(rename = "resourceName", default)]
resource_name: String,
names: Option<Vec<GName>>,
#[serde(rename = "emailAddresses")]
email_addresses: Option<Vec<GEmailAddress>>,
#[serde(default)]
organizations: Option<Vec<GOrganization>>,
#[serde(rename = "phoneNumbers", default)]
phone_numbers: Option<Vec<GPhoneNumber>>,
#[serde(default)]
addresses: Option<Vec<GAddress>>,
}
#[derive(Debug, Deserialize)]
struct ConnectionsResponse {
#[serde(default)]
connections: Vec<GPerson>,
#[serde(rename = "nextPageToken")]
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OtherContactsResponse {
#[serde(default, rename = "otherContacts")]
other_contacts: Vec<GPerson>,
#[serde(rename = "nextPageToken")]
next_page_token: Option<String>,
}
pub struct ParsedContact {
pub name: String,
pub email: Option<String>,
pub phone: Option<String>,
pub address: Option<String>,
pub company: Option<String>,
pub resource_name: String,
}
fn check_status(resp: reqwest::blocking::Response) -> AppResult<reqwest::blocking::Response> {
if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 {
let status = resp.status();
let body = resp.text().unwrap_or_default();
return Err(AppError::Other(format!(
"Kontaktzugriff verweigert ({status}). Entweder fehlt die Berechtigung (Konto neu verbinden) oder die \
People API ist im Google-Cloud-Projekt nicht aktiviert (APIs & Services → Library). Google-Antwort: {body}"
)));
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
return Err(AppError::Other(format!("Google Kontakte: Abrufen fehlgeschlagen ({status}): {body}")));
}
Ok(resp)
}
fn parse_person(person: GPerson) -> Option<ParsedContact> {
let email = person
.email_addresses
.as_ref()
.and_then(|list| list.first())
.and_then(|e| e.value.clone())
.filter(|e| !e.is_empty());
let phone = person
.phone_numbers
.as_ref()
.and_then(|list| list.first())
.and_then(|p| p.value.clone())
.filter(|p| !p.is_empty());
let address = person
.addresses
.as_ref()
.and_then(|list| list.first())
.and_then(|a| a.formatted_value.clone())
.filter(|a| !a.is_empty());
let display_name = person
.names
.as_ref()
.and_then(|list| list.first())
.and_then(|n| n.display_name.clone())
.filter(|n| !n.is_empty());
if email.is_none() && phone.is_none() && address.is_none() && display_name.is_none() {
eprintln!("People API: Kontakt „{}“ übersprungen (keine verwertbaren Felder)", person.resource_name);
return None;
}
let name = display_name
.or_else(|| email.clone())
.or_else(|| phone.clone())
.unwrap_or_else(|| "Unbenannt".to_string());
let company = person
.organizations
.as_ref()
.and_then(|list| list.first())
.and_then(|o| o.name.clone());
let resource_name = person.resource_name.clone();
Some(ParsedContact { name, email, phone, address, company, resource_name })
}
/// Pulls every contact explicitly saved to "My Contacts", paginating through
/// the People API's connections endpoint.
fn list_connections(access_token: &str) -> AppResult<Vec<ParsedContact>> {
let mut out = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut req = client().get(CONNECTIONS_URL).bearer_auth(access_token).query(&[
("personFields", "names,emailAddresses,organizations,phoneNumbers,addresses"),
("pageSize", "1000"),
]);
if let Some(token) = &page_token {
req = req.query(&[("pageToken", token.as_str())]);
}
let resp = req.send().map_err(|e| AppError::Other(e.to_string()))?;
let resp = check_status(resp)?;
let parsed: ConnectionsResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
out.extend(parsed.connections.into_iter().filter_map(parse_person));
page_token = parsed.next_page_token;
if page_token.is_none() {
break;
}
}
Ok(out)
}
/// Pulls "Other contacts" — people auto-collected from mail interactions that
/// were never explicitly saved. This is where most of a typical Gmail user's
/// actual correspondents live, so skipping it would make sync look empty
/// even for an account with plenty of real email history.
fn list_other_contacts(access_token: &str) -> AppResult<Vec<ParsedContact>> {
let mut out = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut req = client().get(OTHER_CONTACTS_URL).bearer_auth(access_token).query(&[
("readMask", "names,emailAddresses,phoneNumbers"),
("pageSize", "1000"),
]);
if let Some(token) = &page_token {
req = req.query(&[("pageToken", token.as_str())]);
}
let resp = req.send().map_err(|e| AppError::Other(e.to_string()))?;
let resp = check_status(resp)?;
let parsed: OtherContactsResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
out.extend(parsed.other_contacts.into_iter().filter_map(parse_person));
page_token = parsed.next_page_token;
if page_token.is_none() {
break;
}
}
Ok(out)
}
/// Pulls both "My Contacts" and auto-collected "Other contacts". Duplicates
/// across the two lists are harmless — the caller upserts by email.
///
/// "Other contacts" needs its own separate scope (`contacts.other.readonly`)
/// beyond plain `contacts.readonly` — an account that hasn't reconnected since
/// that scope was added will get a 403 here. That must not wipe out the
/// "My Contacts" results we already fetched, so it's treated as non-fatal.
pub fn list_contacts(access_token: &str) -> AppResult<Vec<ParsedContact>> {
let mut out = list_connections(access_token)?;
match list_other_contacts(access_token) {
Ok(other) => out.extend(other),
Err(e) => eprintln!("People API: 'Andere Kontakte' übersprungen ({e})"),
}
Ok(out)
}
/// Deletes a contact on the Google side. `resource_name` is the People API
/// resource id (e.g. "people/c1234567890123456789"), already URL-path-safe.
pub fn delete_contact(access_token: &str, resource_name: &str) -> AppResult<()> {
let url = format!("https://people.googleapis.com/v1/{resource_name}:deleteContact");
let resp = client()
.delete(&url)
.bearer_auth(access_token)
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
if resp.status().as_u16() == 404 {
return Ok(());
}
check_status(resp)?;
Ok(())
}
+303
View File
@@ -0,0 +1,303 @@
use crate::models::Event;
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
use uuid::Uuid;
fn escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(',', "\\,")
.replace(';', "\\;")
.replace('\n', "\\n")
}
fn unescape(s: &str) -> String {
s.replace("\\n", "\n")
.replace("\\N", "\n")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
}
fn unfold(data: &str) -> String {
let mut out = String::with_capacity(data.len());
for raw_line in data.split('\n') {
let line = raw_line.trim_end_matches('\r');
if (line.starts_with(' ') || line.starts_with('\t')) && !out.is_empty() {
out.push_str(&line[1..]);
} else {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}
}
out
}
/// Parses a DTSTART/DTEND property line (with any params) into a unix timestamp
/// and whether it represents an all-day (date-only) value.
fn parse_dt(line: &str) -> Option<(i64, bool)> {
let colon = line.find(':')?;
let params = &line[..colon];
let value = line[colon + 1..].trim();
let is_date_only = params.to_uppercase().contains("VALUE=DATE") && !params.to_uppercase().contains("VALUE=DATE-TIME");
if is_date_only || (value.len() == 8 && !value.contains('T')) {
let date = NaiveDate::parse_from_str(value, "%Y%m%d").ok()?;
let dt = date.and_hms_opt(0, 0, 0)?;
let local = Local.from_local_datetime(&dt).single()?;
return Some((local.timestamp(), true));
}
if let Some(stripped) = value.strip_suffix('Z') {
let dt = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
return Some((Utc.from_utc_datetime(&dt).timestamp(), false));
}
let dt = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S").ok()?;
let local = Local.from_local_datetime(&dt).single()?;
Some((local.timestamp(), false))
}
fn format_dt(ts: i64, all_day: bool) -> String {
if all_day {
Local.timestamp_opt(ts, 0).single().map(|d| d.format("%Y%m%d").to_string()).unwrap_or_default()
} else {
Utc.timestamp_opt(ts, 0).single().map(|d| d.format("%Y%m%dT%H%M%SZ").to_string()).unwrap_or_default()
}
}
pub fn export(events: &[Event]) -> String {
let mut out = String::new();
out.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//MailClient//DE\r\nCALSCALE:GREGORIAN\r\n");
for e in events {
out.push_str("BEGIN:VEVENT\r\n");
out.push_str(&format!("UID:{}\r\n", e.id));
if e.all_day {
out.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_dt(e.start_ts, true)));
out.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_dt(e.end_ts, true)));
} else {
out.push_str(&format!("DTSTART:{}\r\n", format_dt(e.start_ts, false)));
out.push_str(&format!("DTEND:{}\r\n", format_dt(e.end_ts, false)));
}
out.push_str(&format!("SUMMARY:{}\r\n", escape(&e.title)));
if let Some(loc) = e.location.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("LOCATION:{}\r\n", escape(loc)));
}
if let Some(desc) = e.description.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("DESCRIPTION:{}\r\n", escape(desc)));
}
if let Some(rrule) = e.rrule.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("RRULE:{rrule}\r\n"));
}
out.push_str("END:VEVENT\r\n");
}
out.push_str("END:VCALENDAR\r\n");
out
}
#[derive(serde::Serialize)]
pub struct InviteInfo {
pub uid: String,
pub sequence: i32,
pub organizer_email: Option<String>,
pub organizer_name: Option<String>,
pub summary: String,
pub start_ts: i64,
pub end_ts: i64,
pub all_day: bool,
}
fn extract_mailto(line: &str) -> Option<String> {
let value = line.rsplit(':').next()?.trim();
Some(value.strip_prefix("mailto:").unwrap_or(value).trim().to_lowercase()).filter(|s| !s.is_empty())
}
fn extract_cn(line: &str) -> Option<String> {
let params = line.split(':').next()?;
let idx = params.to_uppercase().find("CN=")?;
let rest = &params[idx + 3..];
let end = rest.find(';').unwrap_or(rest.len());
let name = rest[..end].trim_matches('"').trim();
if name.is_empty() { None } else { Some(name.to_string()) }
}
/// Extracts organizer/UID/sequence plus the same title/time fields as `parse`, from
/// the first VEVENT in an invite. Used to build an iTIP-style REPLY (Accept/Decline/
/// Tentative) — deliberately separate from `parse`/`Event` since the general calendar
/// model has no room for iTIP participant metadata and doesn't need it.
pub fn parse_invite(data: &str) -> Option<InviteInfo> {
let unfolded = unfold(data);
let mut uid = String::new();
let mut sequence = 0i32;
let mut organizer_email = None;
let mut organizer_name = None;
let mut title = String::new();
let mut start_ts: Option<i64> = None;
let mut end_ts: Option<i64> = None;
let mut all_day = false;
let mut in_event = false;
for line in unfolded.lines() {
let upper = line.to_uppercase();
if upper == "BEGIN:VEVENT" {
in_event = true;
continue;
}
if !in_event {
continue;
}
if upper == "END:VEVENT" {
break;
}
if upper.starts_with("UID") {
if let Some(idx) = line.find(':') {
uid = line[idx + 1..].trim().to_string();
}
} else if upper.starts_with("SEQUENCE") {
if let Some(idx) = line.find(':') {
sequence = line[idx + 1..].trim().parse().unwrap_or(0);
}
} else if upper.starts_with("ORGANIZER") {
organizer_email = extract_mailto(line);
organizer_name = extract_cn(line);
} else if upper.starts_with("DTSTART") {
if let Some((ts, ad)) = parse_dt(line) {
start_ts = Some(ts);
all_day = ad;
}
} else if upper.starts_with("DTEND") {
if let Some((ts, _)) = parse_dt(line) {
end_ts = Some(ts);
}
} else if upper.starts_with("SUMMARY") {
if let Some(idx) = line.find(':') {
title = unescape(&line[idx + 1..]);
}
}
}
let start = start_ts?;
if uid.is_empty() {
return None;
}
Some(InviteInfo {
uid,
sequence,
organizer_email,
organizer_name,
summary: if title.is_empty() { "(Ohne Titel)".to_string() } else { title },
start_ts: start,
end_ts: end_ts.unwrap_or(start + if all_day { 86400 } else { 3600 }),
all_day,
})
}
/// Builds an iTIP METHOD:REPLY calendar object for an invite response
/// (PARTSTAT one of ACCEPTED/DECLINED/TENTATIVE).
pub fn build_reply(invite: &InviteInfo, attendee_email: &str, partstat: &str) -> String {
let mut out = String::new();
out.push_str("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//MailClient//DE\r\nMETHOD:REPLY\r\n");
out.push_str("BEGIN:VEVENT\r\n");
out.push_str(&format!("UID:{}\r\n", invite.uid));
out.push_str(&format!("SEQUENCE:{}\r\n", invite.sequence));
if invite.all_day {
out.push_str(&format!("DTSTART;VALUE=DATE:{}\r\n", format_dt(invite.start_ts, true)));
out.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_dt(invite.end_ts, true)));
} else {
out.push_str(&format!("DTSTART:{}\r\n", format_dt(invite.start_ts, false)));
out.push_str(&format!("DTEND:{}\r\n", format_dt(invite.end_ts, false)));
}
out.push_str(&format!("SUMMARY:{}\r\n", escape(&invite.summary)));
if let Some(organizer) = &invite.organizer_email {
out.push_str(&format!("ORGANIZER:mailto:{organizer}\r\n"));
}
out.push_str(&format!("ATTENDEE;PARTSTAT={partstat};ROLE=REQ-PARTICIPANT:mailto:{attendee_email}\r\n"));
out.push_str("END:VEVENT\r\n");
out.push_str("END:VCALENDAR\r\n");
out
}
pub fn parse(data: &str) -> Vec<Event> {
let unfolded = unfold(data);
let mut out = Vec::new();
let mut title = String::new();
let mut location: Option<String> = None;
let mut description: Option<String> = None;
let mut start_ts: Option<i64> = None;
let mut end_ts: Option<i64> = None;
let mut all_day = false;
let mut in_event = false;
let mut rrule: Option<String> = None;
for line in unfolded.lines() {
let upper = line.to_uppercase();
if upper == "BEGIN:VEVENT" {
in_event = true;
title.clear();
location = None;
description = None;
start_ts = None;
end_ts = None;
all_day = false;
rrule = None;
continue;
}
if !in_event {
continue;
}
if upper == "END:VEVENT" {
in_event = false;
if let (Some(start), true) = (start_ts, !title.is_empty() || start_ts.is_some()) {
let end = end_ts.unwrap_or(start + if all_day { 86400 } else { 3600 });
let id = Uuid::new_v4().to_string();
out.push(Event {
id: id.clone(),
base_id: id,
title: if title.is_empty() { "(Ohne Titel)".to_string() } else { title.clone() },
location: location.clone(),
description: description.clone(),
start_ts: start,
end_ts: end,
all_day,
account_id: None,
google_event_id: None,
rrule: rrule.clone(),
reminder_minutes: None,
category: None,
exdates: None,
series_id: None,
});
}
continue;
}
if upper.starts_with("DTSTART") {
if let Some((ts, ad)) = parse_dt(line) {
start_ts = Some(ts);
all_day = ad;
}
} else if upper.starts_with("DTEND") {
if let Some((ts, _)) = parse_dt(line) {
end_ts = Some(ts);
}
} else if upper.starts_with("SUMMARY") {
if let Some(idx) = line.find(':') {
title = unescape(&line[idx + 1..]);
}
} else if upper.starts_with("LOCATION") {
if let Some(idx) = line.find(':') {
location = Some(unescape(&line[idx + 1..]));
}
} else if upper.starts_with("DESCRIPTION") {
if let Some(idx) = line.find(':') {
description = Some(unescape(&line[idx + 1..]));
}
} else if upper.starts_with("RRULE") {
if let Some(idx) = line.find(':') {
rrule = Some(line[idx + 1..].trim().to_string());
}
}
}
out
}
+118
View File
@@ -0,0 +1,118 @@
use crate::db::Db;
use crate::error::{AppError, AppResult};
use crate::imap_client;
use crate::models::{Account, Protocol};
use imap::extensions::idle;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_notification::NotificationExt;
/// Last-known unseen count per account, used to detect *new* mail (an
/// increase) rather than notifying on every mailbox change IDLE reports
/// (read/flag changes on other clients also wake IDLE).
pub struct UnseenState(pub Mutex<HashMap<String, u32>>);
impl UnseenState {
pub fn new() -> Self {
UnseenState(Mutex::new(HashMap::new()))
}
}
/// User-configurable toggle for the native OS desktop notification on new
/// mail (the in-app popup is a separate, purely frontend-driven concern).
pub struct NotificationSettings(pub AtomicBool);
impl NotificationSettings {
pub fn new() -> Self {
NotificationSettings(AtomicBool::new(true))
}
}
const RECONNECT_BACKOFF_SECS: u64 = 20;
/// Spawns an IDLE watcher for every IMAP account currently in the DB.
/// Called once at app startup.
pub fn spawn_watchers(app: AppHandle) {
let accounts = {
let db = app.state::<Db>();
db.list_accounts().unwrap_or_default()
};
for account in accounts.into_iter().filter(|a| a.protocol == Protocol::Imap) {
spawn_watcher(app.clone(), account.id);
}
}
/// Spawns a persistent IMAP IDLE watcher for one account. Reconnects with a
/// backoff on any I/O error, and exits cleanly once the account no longer
/// exists in the DB (checked between IDLE cycles) — e.g. after deletion.
pub fn spawn_watcher(app: AppHandle, account_id: String) {
tauri::async_runtime::spawn(async move {
loop {
let account = {
let db = app.state::<Db>();
db.list_accounts()
.unwrap_or_default()
.into_iter()
.find(|a| a.id == account_id && a.protocol == Protocol::Imap)
};
let Some(account) = account else { break };
let app_c = app.clone();
let result = tauri::async_runtime::spawn_blocking(move || run_idle_loop(&app_c, &account)).await;
if !matches!(result, Ok(Ok(()))) {
tokio::time::sleep(Duration::from_secs(RECONNECT_BACKOFF_SECS)).await;
}
}
});
}
fn run_idle_loop(app: &AppHandle, account: &Account) -> AppResult<()> {
let mut session = imap_client::open_session(account)?;
session.select("INBOX").map_err(|e| AppError::Imap(e.to_string()))?;
loop {
{
let db = app.state::<Db>();
let still_exists = db.list_accounts().unwrap_or_default().iter().any(|a| a.id == account.id);
if !still_exists {
return Ok(());
}
}
session
.idle()
.wait_while(idle::stop_on_any)
.map_err(|e| AppError::Imap(e.to_string()))?;
let Ok(count) = imap_client::unseen_count(&mut session, "INBOX") else {
continue;
};
let prev = {
let state = app.state::<UnseenState>();
let mut map = state.0.lock().unwrap();
map.insert(account.id.clone(), count)
};
if let Some(prev) = prev {
if count > prev {
let delta = count - prev;
let body = if delta == 1 {
format!("1 neue Nachricht in {}", account.label)
} else {
format!("{delta} neue Nachrichten in {}", account.label)
};
let desktop_enabled = app.state::<NotificationSettings>().0.load(Ordering::Relaxed);
if desktop_enabled {
let _ = app.notification().builder().title("Neue E-Mail").body(body).show();
}
let _ = app.emit(
"new-mail",
serde_json::json!({ "accountId": account.id, "folder": "INBOX" }),
);
}
}
}
}
+259
View File
@@ -0,0 +1,259 @@
use crate::error::{AppError, AppResult};
use crate::mailutil::{parse_message_full, split_from, thread_ids};
use crate::models::{Account, AuthMethod, Folder, MessageFull, MessageSummary};
use crate::{accounts, oauth};
use imap::types::Flag;
use mailparse::{parse_mail, MailHeaderMap};
type ImapSession = imap::Session<imap::Connection>;
const FETCH_LIMIT: u32 = 60;
const EXPORT_LIMIT: u32 = 500;
pub fn connect(host: &str, port: u16, username: &str, password: &str) -> AppResult<ImapSession> {
let client = imap::ClientBuilder::new(host, port)
.connect()
.map_err(|e| AppError::Imap(e.to_string()))?;
client
.login(username, password)
.map_err(|(e, _)| AppError::Imap(e.to_string()))
}
struct XOAuth2 {
user: String,
access_token: String,
}
impl imap::Authenticator for XOAuth2 {
type Response = String;
fn process(&self, _challenge: &[u8]) -> Self::Response {
format!("user={}\x01auth=Bearer {}\x01\x01", self.user, self.access_token)
}
}
pub fn connect_oauth(host: &str, port: u16, email: &str, access_token: &str) -> AppResult<ImapSession> {
let client = imap::ClientBuilder::new(host, port)
.connect()
.map_err(|e| AppError::Imap(e.to_string()))?;
let auth = XOAuth2 {
user: email.to_string(),
access_token: access_token.to_string(),
};
client
.authenticate("XOAUTH2", &auth)
.map_err(|(e, _)| AppError::Imap(e.to_string()))
}
/// Opens an authenticated IMAP session for the given account, resolving either its
/// stored password or (for OAuth accounts) refreshing a fresh access token first.
pub fn open_session(account: &Account) -> AppResult<ImapSession> {
match account.auth_method {
AuthMethod::Password => {
let password = accounts::get_password(&account.id)?;
connect(&account.incoming_host, account.incoming_port, &account.username, &password)
}
AuthMethod::GoogleOauth => {
let refresh_token = accounts::get_password(&account.id)?;
let access_token = oauth::refresh_access_token(&refresh_token)?;
connect_oauth(&account.incoming_host, account.incoming_port, &account.email, &access_token)
}
}
}
pub fn list_folders(session: &mut ImapSession) -> AppResult<Vec<Folder>> {
let names = session
.list(None, Some("*"))
.map_err(|e| AppError::Imap(e.to_string()))?;
let mut out = Vec::new();
for n in names.iter() {
let delim = n.delimiter().unwrap_or("/");
let display = n.name().rsplit(delim).next().unwrap_or(n.name()).to_string();
let unread = session
.status(n.name(), "(UNSEEN)")
.ok()
.and_then(|m| m.unseen)
.unwrap_or(0);
out.push(Folder {
name: n.name().to_string(),
display_name: display,
unread,
});
}
Ok(out)
}
pub fn unseen_count(session: &mut ImapSession, folder: &str) -> AppResult<u32> {
let mailbox = session
.status(folder, "(UNSEEN)")
.map_err(|e| AppError::Imap(e.to_string()))?;
Ok(mailbox.unseen.unwrap_or(0))
}
pub fn fetch_messages(
session: &mut ImapSession,
account_id: &str,
folder: &str,
) -> AppResult<Vec<MessageSummary>> {
let mailbox = session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
if mailbox.exists == 0 {
return Ok(vec![]);
}
let end = mailbox.exists;
let start = if end > FETCH_LIMIT { end - FETCH_LIMIT + 1 } else { 1 };
let seq = format!("{}:{}", start, end);
let fetches = session
.fetch(seq, "(UID FLAGS RFC822.HEADER)")
.map_err(|e| AppError::Imap(e.to_string()))?;
let mut out = Vec::new();
for f in fetches.iter() {
let uid = match f.uid {
Some(u) => u,
None => continue,
};
let header_bytes = f.header().unwrap_or(&[]);
let parsed = parse_mail(header_bytes).ok();
let from_raw = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("From"))
.unwrap_or_default();
let (from_name, from_addr) = split_from(&from_raw);
let subject = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("Subject"))
.unwrap_or_else(|| "(kein Betreff)".to_string());
let date_raw = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("Date"))
.unwrap_or_default();
let timestamp = mailparse::dateparse(&date_raw).unwrap_or(0);
let is_read = f.flags().iter().any(|fl| matches!(fl, Flag::Seen));
let flagged = f.flags().iter().any(|fl| matches!(fl, Flag::Flagged));
let (message_id, thread_id) = thread_ids(parsed.as_ref(), &format!("{account_id}-{folder}-{uid}"));
out.push(MessageSummary {
uid,
account_id: account_id.to_string(),
folder: folder.to_string(),
from_name,
from_addr,
subject,
date: date_raw,
timestamp,
snippet: String::new(),
is_read,
has_attachments: false,
flagged,
message_id,
thread_id,
category: None,
});
}
out.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(out)
}
pub fn fetch_raw(session: &mut ImapSession, folder: &str, uid: u32) -> AppResult<Vec<u8>> {
session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
let fetches = session
.uid_fetch(uid.to_string(), "(UID RFC822)")
.map_err(|e| AppError::Imap(e.to_string()))?;
let f = fetches
.iter()
.next()
.ok_or_else(|| AppError::NotFound(format!("message uid {uid} not found")))?;
Ok(f.body().unwrap_or(&[]).to_vec())
}
pub fn fetch_full_message(
session: &mut ImapSession,
account_id: &str,
folder: &str,
uid: u32,
) -> AppResult<MessageFull> {
let raw = fetch_raw(session, folder, uid)?;
parse_message_full(account_id, folder, uid, &raw)
}
pub fn set_flag(session: &mut ImapSession, folder: &str, uid: u32, flag: &str, add: bool) -> AppResult<()> {
session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
let query = if add {
format!("+FLAGS ({flag})")
} else {
format!("-FLAGS ({flag})")
};
session
.uid_store(uid.to_string(), query)
.map_err(|e| AppError::Imap(e.to_string()))?;
Ok(())
}
pub fn move_message(
session: &mut ImapSession,
from_folder: &str,
uid: u32,
to_folder: &str,
) -> AppResult<()> {
session.select(from_folder).map_err(|e| AppError::Imap(e.to_string()))?;
if session.uid_mv(uid.to_string(), to_folder).is_ok() {
return Ok(());
}
// Server doesn't support MOVE (RFC 6851) -> fall back to COPY + delete + expunge.
session
.uid_copy(uid.to_string(), to_folder)
.map_err(|e| AppError::Imap(e.to_string()))?;
session
.uid_store(uid.to_string(), "+FLAGS (\\Deleted)")
.map_err(|e| AppError::Imap(e.to_string()))?;
session.expunge().map_err(|e| AppError::Imap(e.to_string()))?;
Ok(())
}
pub fn delete_message(session: &mut ImapSession, folder: &str, uid: u32) -> AppResult<()> {
session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
session
.uid_store(uid.to_string(), "+FLAGS (\\Deleted)")
.map_err(|e| AppError::Imap(e.to_string()))?;
session.expunge().map_err(|e| AppError::Imap(e.to_string()))?;
Ok(())
}
pub fn create_folder(session: &mut ImapSession, name: &str) -> AppResult<()> {
session.create(name).map_err(|e| AppError::Imap(e.to_string()))
}
pub fn rename_folder(session: &mut ImapSession, from: &str, to: &str) -> AppResult<()> {
session.rename(from, to).map_err(|e| AppError::Imap(e.to_string()))
}
pub fn delete_folder(session: &mut ImapSession, name: &str) -> AppResult<()> {
session.delete(name).map_err(|e| AppError::Imap(e.to_string()))
}
pub fn append_message(session: &mut ImapSession, folder: &str, raw: &[u8]) -> AppResult<()> {
session
.append(folder, raw)
.finish()
.map_err(|e| AppError::Imap(e.to_string()))?;
Ok(())
}
/// Fetches raw RFC822 bytes for up to `EXPORT_LIMIT` of the most recent messages in a folder.
pub fn export_folder(session: &mut ImapSession, folder: &str) -> AppResult<Vec<Vec<u8>>> {
let mailbox = session.select(folder).map_err(|e| AppError::Imap(e.to_string()))?;
if mailbox.exists == 0 {
return Ok(vec![]);
}
let end = mailbox.exists;
let start = if end > EXPORT_LIMIT { end - EXPORT_LIMIT + 1 } else { 1 };
let seq = format!("{}:{}", start, end);
let fetches = session
.fetch(seq, "RFC822")
.map_err(|e| AppError::Imap(e.to_string()))?;
Ok(fetches
.iter()
.filter_map(|f| f.body())
.filter(|b| !b.is_empty())
.map(|b| b.to_vec())
.collect())
}
+137
View File
@@ -0,0 +1,137 @@
mod accounts;
mod caldav;
mod commands;
mod db;
mod error;
mod gcal;
mod gcontacts;
mod ics;
mod idle;
mod imap_client;
mod mailutil;
mod mbox;
mod models;
mod oauth;
mod pop3_client;
mod recurrence;
mod reminders;
mod smtp_client;
mod vcard;
use db::Db;
use idle::UnseenState;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
let data_dir = app.path().app_data_dir()?;
let db = Db::open(&data_dir)?;
app.manage(db);
app.manage(UnseenState::new());
app.manage(idle::NotificationSettings::new());
app.manage(commands::PendingSends::new());
idle::spawn_watchers(app.handle().clone());
reminders::spawn_poller(app.handle().clone());
commands::rehydrate_scheduled_sends(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::add_account,
commands::list_accounts,
commands::delete_account,
commands::update_account,
commands::list_folders,
commands::create_folder,
commands::rename_folder,
commands::delete_folder,
commands::mark_folder_read,
commands::apply_rules_to_folder,
commands::sync_messages,
commands::list_cached_messages,
commands::get_message,
commands::send_message,
commands::send_message_at,
commands::cancel_scheduled_send,
commands::mark_read,
commands::delete_message,
commands::search_messages,
commands::search_all_messages,
commands::set_message_category,
commands::toggle_flag,
commands::move_message,
commands::list_contacts,
commands::search_contacts,
commands::add_contact,
commands::update_contact,
commands::delete_contact,
commands::toggle_contact_favorite,
commands::export_contacts_vcard,
commands::import_contacts_vcard,
commands::export_message_eml,
commands::export_folder_mbox,
commands::export_full_backup,
commands::import_eml_file,
commands::import_mbox_file,
commands::save_draft,
commands::load_draft,
commands::google_oauth_add_account,
commands::list_rules,
commands::create_rule,
commands::set_rule_enabled,
commands::delete_rule,
commands::list_events,
commands::create_event,
commands::update_event,
commands::delete_event,
commands::import_ics_file,
commands::import_ics_text,
commands::reply_to_invite,
commands::preview_invite,
commands::get_vacation_settings,
commands::save_vacation_settings,
commands::snooze_message,
commands::unsnooze_message,
commands::list_snoozed_messages,
commands::list_templates,
commands::create_template,
commands::update_template,
commands::delete_template,
commands::search_messages_advanced,
commands::update_event_occurrence,
commands::delete_event_occurrence,
commands::list_caldav_accounts,
commands::add_caldav_account,
commands::delete_caldav_account,
commands::sync_caldav_calendar,
commands::export_events_ics,
commands::get_event,
commands::list_upcoming_reminders,
commands::sync_google_calendar,
commands::sync_google_contacts,
commands::reconnect_google_account,
commands::set_desktop_notifications_enabled,
commands::list_tasks,
commands::create_task,
commands::update_task,
commands::delete_task,
commands::set_task_completed,
commands::create_task_from_message,
commands::list_contact_groups,
commands::create_contact_group,
commands::update_contact_group,
commands::delete_contact_group,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+162
View File
@@ -0,0 +1,162 @@
use crate::error::{AppError, AppResult};
use crate::models::{AttachmentMeta, MessageFull, MessageSummary};
use base64::Engine;
use mailparse::{parse_mail, MailHeaderMap, ParsedMail};
/// Derives (message_id, thread_id) from a parsed message's headers.
/// thread_id is the root of the References chain, falling back to
/// In-Reply-To, then to the message's own id — so every message always
/// lands in a thread, even a singleton one.
pub fn thread_ids(parsed: Option<&ParsedMail>, seed: &str) -> (String, String) {
let message_id = parsed
.and_then(|p| p.headers.get_first_value("Message-ID"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("<local-{seed}>"));
let references = parsed.and_then(|p| p.headers.get_first_value("References")).unwrap_or_default();
let in_reply_to = parsed.and_then(|p| p.headers.get_first_value("In-Reply-To")).unwrap_or_default();
let thread_id = references
.split_whitespace()
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
.or_else(|| {
let t = in_reply_to.trim().to_string();
if t.is_empty() { None } else { Some(t) }
})
.unwrap_or_else(|| message_id.clone());
(message_id, thread_id)
}
pub fn split_from(raw: &str) -> (String, String) {
if let Some(idx) = raw.rfind('<') {
let name = raw[..idx].trim().trim_matches('"').to_string();
let addr = raw[idx + 1..].trim_end_matches('>').trim().to_string();
if name.is_empty() {
(addr.clone(), addr)
} else {
(name, addr)
}
} else {
(raw.trim().to_string(), raw.trim().to_string())
}
}
fn walk_parts(
part: &ParsedMail,
text: &mut Option<String>,
html: &mut Option<String>,
attachments: &mut Vec<AttachmentMeta>,
) {
if !part.subparts.is_empty() {
for sp in &part.subparts {
walk_parts(sp, text, html, attachments);
}
return;
}
let mimetype = part.ctype.mimetype.to_lowercase();
let disposition = part.get_content_disposition();
let filename = disposition
.params
.get("filename")
.cloned()
.or_else(|| part.ctype.params.get("name").cloned());
let is_attachment = disposition.disposition == mailparse::DispositionType::Attachment
|| (filename.is_some() && !mimetype.starts_with("text/"));
if is_attachment {
if let Ok(raw) = part.get_body_raw() {
attachments.push(AttachmentMeta {
filename: filename.unwrap_or_else(|| "attachment".to_string()),
mime_type: mimetype,
size: raw.len(),
content_base64: base64::engine::general_purpose::STANDARD.encode(&raw),
});
}
return;
}
if mimetype == "text/html" && html.is_none() {
*html = part.get_body().ok();
} else if mimetype == "text/plain" && text.is_none() {
*text = part.get_body().ok();
}
}
/// Parses full RFC822 bytes into a `MessageFull`, extracting headers, body and attachments.
pub fn parse_message_full(account_id: &str, folder: &str, uid: u32, raw: &[u8]) -> AppResult<MessageFull> {
let parsed = parse_mail(raw).map_err(|e| AppError::Parse(e.to_string()))?;
let from_raw = parsed.headers.get_first_value("From").unwrap_or_default();
let (from_name, from_addr) = split_from(&from_raw);
let to = parsed.headers.get_first_value("To").unwrap_or_default();
let subject = parsed
.headers
.get_first_value("Subject")
.unwrap_or_else(|| "(kein Betreff)".to_string());
let date = parsed.headers.get_first_value("Date").unwrap_or_default();
let mut text = None;
let mut html = None;
let mut attachments = Vec::new();
walk_parts(&parsed, &mut text, &mut html, &mut attachments);
Ok(MessageFull {
uid,
account_id: account_id.to_string(),
folder: folder.to_string(),
from_name,
from_addr,
to,
subject,
date,
body_html: html,
body_text: text,
attachments,
})
}
/// Builds a `MessageSummary` from full RFC822 bytes (used when importing local/appended mail).
pub fn parse_message_summary(account_id: &str, folder: &str, uid: u32, raw: &[u8]) -> MessageSummary {
let parsed = parse_mail(raw).ok();
let from_raw = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("From"))
.unwrap_or_default();
let (from_name, from_addr) = split_from(&from_raw);
let subject = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("Subject"))
.unwrap_or_else(|| "(kein Betreff)".to_string());
let date_raw = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("Date"))
.unwrap_or_default();
let timestamp = mailparse::dateparse(&date_raw).unwrap_or(0);
let has_attachments = parsed
.as_ref()
.map(|p| p.parts().any(|part| part.get_content_disposition().disposition == mailparse::DispositionType::Attachment))
.unwrap_or(false);
let (message_id, thread_id) = thread_ids(parsed.as_ref(), &format!("{account_id}-{folder}-{uid}"));
MessageSummary {
uid,
account_id: account_id.to_string(),
folder: folder.to_string(),
from_name,
from_addr,
subject,
date: date_raw,
timestamp,
snippet: String::new(),
is_read: true,
has_attachments,
flagged: false,
message_id,
thread_id,
category: None,
}
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+91
View File
@@ -0,0 +1,91 @@
/// Minimal mboxrd reader/writer: https://en.wikipedia.org/wiki/Mbox#Modifications
/// Lines matching `^>*From ` inside a message body are escaped with a leading `>`
/// on write and un-escaped by stripping one leading `>` on read.
fn mboxrd_escape(raw: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(raw.len() + 16);
let mut first = true;
for line in raw.split(|&b| b == b'\n') {
if !first {
out.push(b'\n');
}
first = false;
let content = line.strip_suffix(b"\r").unwrap_or(line);
let mut rest = content;
while let Some(r) = rest.strip_prefix(b">") {
rest = r;
}
if rest.starts_with(b"From ") {
out.push(b'>');
}
out.extend_from_slice(line);
}
out
}
fn unescape_line(line: &[u8]) -> &[u8] {
if let Some(rest) = line.strip_prefix(b">") {
let mut check = rest;
loop {
if check.starts_with(b"From ") {
return rest;
}
match check.strip_prefix(b">") {
Some(r2) => check = r2,
None => break,
}
}
}
line
}
pub fn append_entry(out: &mut Vec<u8>, from_addr: &str, timestamp: i64, raw: &[u8]) {
let date = chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
let date_str = date.format("%a %b %e %H:%M:%S %Y");
let sender = if from_addr.is_empty() { "MAILER-DAEMON" } else { from_addr };
out.extend_from_slice(format!("From {sender} {date_str}\n").as_bytes());
out.extend_from_slice(&mboxrd_escape(raw));
if !out.ends_with(b"\n") {
out.push(b'\n');
}
out.push(b'\n');
}
/// Splits a full mbox file's bytes into individual raw RFC822 messages (CRLF line endings).
pub fn split_entries(data: &[u8]) -> Vec<Vec<u8>> {
let mut messages: Vec<Vec<&[u8]>> = Vec::new();
let mut current: Vec<&[u8]> = Vec::new();
let mut prev_blank = true;
for line in data.split(|&b| b == b'\n') {
let content = line.strip_suffix(b"\r").unwrap_or(line);
let is_from_line = prev_blank && content.starts_with(b"From ");
if is_from_line {
if !current.is_empty() {
messages.push(std::mem::take(&mut current));
}
prev_blank = false;
continue;
}
prev_blank = content.is_empty();
current.push(line);
}
if !current.is_empty() {
messages.push(current);
}
messages
.into_iter()
.map(|lines| {
let mut out = Vec::new();
for (i, l) in lines.iter().enumerate() {
if i > 0 {
out.extend_from_slice(b"\r\n");
}
let content = l.strip_suffix(b"\r").unwrap_or(l);
out.extend_from_slice(unescape_line(content));
}
out
})
.filter(|m: &Vec<u8>| !m.is_empty())
.collect()
}
+359
View File
@@ -0,0 +1,359 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Imap,
Pop3,
Local,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthMethod {
#[default]
Password,
GoogleOauth,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub label: String,
pub email: String,
pub username: String,
pub protocol: Protocol,
pub incoming_host: String,
pub incoming_port: u16,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_starttls: bool,
#[serde(default)]
pub auth_method: AuthMethod,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewAccount {
pub label: String,
pub email: String,
pub username: String,
pub password: String,
pub protocol: Protocol,
pub incoming_host: String,
pub incoming_port: u16,
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_starttls: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct Folder {
pub name: String,
pub display_name: String,
pub unread: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageSummary {
pub uid: u32,
pub account_id: String,
pub folder: String,
pub from_name: String,
pub from_addr: String,
pub subject: String,
pub date: String,
pub timestamp: i64,
pub snippet: String,
pub is_read: bool,
pub has_attachments: bool,
pub flagged: bool,
#[serde(default)]
pub message_id: String,
#[serde(default)]
pub thread_id: String,
#[serde(default)]
pub category: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AttachmentMeta {
pub filename: String,
pub mime_type: String,
pub size: usize,
pub content_base64: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct MessageFull {
pub uid: u32,
pub account_id: String,
pub folder: String,
pub from_name: String,
pub from_addr: String,
pub to: String,
pub subject: String,
pub date: String,
pub body_html: Option<String>,
pub body_text: Option<String>,
pub attachments: Vec<AttachmentMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingAttachment {
pub filename: String,
pub mime_type: String,
pub content_base64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
pub id: String,
pub name: String,
pub email: Option<String>,
#[serde(default)]
pub phone: Option<String>,
#[serde(default)]
pub address: Option<String>,
pub company: Option<String>,
pub notes: Option<String>,
pub favorite: bool,
#[serde(default)]
pub google_account_id: Option<String>,
#[serde(default)]
pub google_resource_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactGroup {
pub id: String,
pub name: String,
pub member_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewContactGroup {
pub name: String,
pub member_ids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewContact {
pub name: String,
pub email: Option<String>,
#[serde(default)]
pub phone: Option<String>,
#[serde(default)]
pub address: Option<String>,
pub company: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DraftPayload {
pub uid: u32,
pub account_id: Option<String>,
pub to: String,
pub cc: String,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuleAction {
MoveTo,
MarkRead,
Flag,
Delete,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MatchType {
All,
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleCondition {
pub field: String,
pub contains: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: String,
pub account_id: Option<String>,
pub conditions: Vec<RuleCondition>,
pub match_type: MatchType,
pub action: RuleAction,
pub action_value: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewRule {
pub account_id: Option<String>,
pub conditions: Vec<RuleCondition>,
pub match_type: MatchType,
pub action: RuleAction,
pub action_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: String,
/// The real DB row id. Equals `id` for normal events; for an expanded
/// recurring occurrence, `id` is synthetic (`{base_id}#{occurrence_start}`)
/// while `base_id` points at the actual stored series row.
#[serde(default)]
pub base_id: String,
pub title: String,
pub location: Option<String>,
pub description: Option<String>,
pub start_ts: i64,
pub end_ts: i64,
pub all_day: bool,
pub account_id: Option<String>,
pub google_event_id: Option<String>,
pub rrule: Option<String>,
pub reminder_minutes: Option<i32>,
pub category: Option<String>,
/// Comma-separated occurrence start timestamps excluded from this series'
/// expansion (RFC5545 EXDATE equivalent) — only meaningful when `rrule` is set.
#[serde(default)]
pub exdates: Option<String>,
/// Set only on a single-occurrence override row: the id of the recurring
/// series it replaces one instance of. Such rows always have `rrule: None`.
#[serde(default)]
pub series_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewEvent {
pub title: String,
pub location: Option<String>,
pub description: Option<String>,
pub start_ts: i64,
pub end_ts: i64,
pub all_day: bool,
pub account_id: Option<String>,
pub rrule: Option<String>,
pub reminder_minutes: Option<i32>,
pub category: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeMessage {
pub account_id: String,
pub to: String,
pub cc: Option<String>,
pub bcc: Option<String>,
pub subject: String,
pub body_html: String,
pub attachments: Vec<OutgoingAttachment>,
pub in_reply_to: Option<String>,
#[serde(default)]
pub reply_to: Option<String>,
#[serde(default)]
pub request_read_receipt: bool,
/// "high" | "low" | None (normal)
#[serde(default)]
pub importance: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TaskPriority {
Low,
Normal,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub title: String,
pub description: Option<String>,
pub due_ts: Option<i64>,
pub priority: TaskPriority,
pub completed: bool,
pub created_ts: i64,
pub source_account_id: Option<String>,
pub source_folder: Option<String>,
pub source_uid: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewTask {
pub title: String,
pub description: Option<String>,
pub due_ts: Option<i64>,
pub priority: TaskPriority,
pub source_account_id: Option<String>,
pub source_folder: Option<String>,
pub source_uid: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VacationSettings {
pub account_id: String,
pub enabled: bool,
pub start_ts: Option<i64>,
pub end_ts: Option<i64>,
pub subject: String,
pub body: String,
}
/// A generic CalDAV calendar connection (Nextcloud, iCloud, Radicale, ...) —
/// independent of the mail `Account` table, since a CalDAV calendar isn't
/// necessarily tied to a mail account the way Google Calendar is bolted onto a
/// Google-OAuth mail account. `url` is the direct calendar collection URL (no
/// principal/home-set auto-discovery); password is stored via the same OS
/// keyring helper as mail account passwords, keyed by this struct's `id`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaldavAccount {
pub id: String,
pub label: String,
pub url: String,
pub username: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewCaldavAccount {
pub label: String,
pub url: String,
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SnoozedMessage {
#[serde(flatten)]
pub message: MessageSummary,
pub until_ts: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Template {
pub id: String,
pub name: String,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewTemplate {
pub name: String,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NewVacationSettings {
pub enabled: bool,
pub start_ts: Option<i64>,
pub end_ts: Option<i64>,
pub subject: String,
pub body: String,
}
+217
View File
@@ -0,0 +1,217 @@
use crate::error::{AppError, AppResult};
use base64::Engine;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::time::{Duration, Instant};
use uuid::Uuid;
// Google's "Desktop app" OAuth client type is a public client per RFC 8252 — this
// client_secret is not treated as confidential by Google for this client type
// (same model used by e.g. `gcloud`), so embedding it here is expected practice.
const CLIENT_ID: &str = "838805598613-65t2qpqp0njk7ftn37ousqksqjpvi0ju.apps.googleusercontent.com";
const CLIENT_SECRET: &str = "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";
const AUTH_URL: &str = "https://accounts.google.com/o/oauth2/auth";
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
const USERINFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo";
const SCOPE: &str = "https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/contacts.other.readonly";
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16) {
out.push(byte);
i += 3;
continue;
}
}
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn code_verifier() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
fn code_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize())
}
struct RedirectResult {
code: String,
}
fn wait_for_redirect(listener: TcpListener) -> AppResult<RedirectResult> {
listener.set_nonblocking(true).ok();
let deadline = Instant::now() + Duration::from_secs(300);
let (mut stream, _) = loop {
match listener.accept() {
Ok(pair) => break pair,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() > deadline {
return Err(AppError::Other("Zeitüberschreitung beim Google-Login".into()));
}
std::thread::sleep(Duration::from_millis(200));
}
Err(e) => return Err(AppError::Other(e.to_string())),
}
};
stream.set_nonblocking(false).ok();
let mut reader = BufReader::new(stream.try_clone().map_err(|e| AppError::Other(e.to_string()))?);
let mut request_line = String::new();
reader
.read_line(&mut request_line)
.map_err(|e| AppError::Other(e.to_string()))?;
let path = request_line.split_whitespace().nth(1).unwrap_or("").to_string();
let body = "<html><body style=\"font-family:sans-serif\"><h2>Fertig! Du kannst dieses Fenster jetzt schließen.</h2></body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
let query = path.splitn(2, '?').nth(1).unwrap_or("");
let mut code = None;
let mut error = None;
for pair in query.split('&') {
let mut it = pair.splitn(2, '=');
let key = it.next().unwrap_or("");
let value = percent_decode(it.next().unwrap_or(""));
match key {
"code" => code = Some(value),
"error" => error = Some(value),
_ => {}
}
}
if let Some(err) = error {
return Err(AppError::Other(format!("Google hat den Zugriff verweigert: {err}")));
}
let code = code.ok_or_else(|| AppError::Other("Kein Autorisierungscode erhalten".into()))?;
Ok(RedirectResult { code })
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
}
fn http_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
fn exchange_code(code: &str, verifier: &str, redirect_uri: &str) -> AppResult<TokenResponse> {
let resp = http_client()
.post(TOKEN_URL)
.form(&[
("code", code),
("client_id", CLIENT_ID),
("client_secret", CLIENT_SECRET),
("redirect_uri", redirect_uri),
("grant_type", "authorization_code"),
("code_verifier", verifier),
])
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
if !resp.status().is_success() {
return Err(AppError::Other(format!(
"Token-Austausch fehlgeschlagen: {}",
resp.text().unwrap_or_default()
)));
}
resp.json().map_err(|e| AppError::Other(e.to_string()))
}
pub fn refresh_access_token(refresh_token: &str) -> AppResult<String> {
let resp = http_client()
.post(TOKEN_URL)
.form(&[
("client_id", CLIENT_ID),
("client_secret", CLIENT_SECRET),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
])
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
if !resp.status().is_success() {
return Err(AppError::Other(format!(
"Token-Erneuerung fehlgeschlagen: {}",
resp.text().unwrap_or_default()
)));
}
let token: TokenResponse = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
Ok(token.access_token)
}
#[derive(Deserialize)]
struct UserInfo {
email: String,
}
fn fetch_email(access_token: &str) -> AppResult<String> {
let resp = http_client()
.get(USERINFO_URL)
.bearer_auth(access_token)
.send()
.map_err(|e| AppError::Other(e.to_string()))?;
let info: UserInfo = resp.json().map_err(|e| AppError::Other(e.to_string()))?;
Ok(info.email)
}
/// Runs the full desktop OAuth2 + PKCE login flow for Google: opens the system browser,
/// waits for the local loopback redirect, exchanges the code for tokens.
/// Returns (email, refresh_token).
pub fn run_google_login() -> AppResult<(String, String)> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| AppError::Other(e.to_string()))?;
let port = listener
.local_addr()
.map_err(|e| AppError::Other(e.to_string()))?
.port();
let redirect_uri = format!("http://localhost:{port}");
let verifier = code_verifier();
let challenge = code_challenge(&verifier);
let auth_url = format!(
"{AUTH_URL}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&access_type=offline&prompt=consent",
urlencode(CLIENT_ID),
urlencode(&redirect_uri),
urlencode(SCOPE),
urlencode(&challenge),
);
webbrowser::open(&auth_url).map_err(|e| AppError::Other(e.to_string()))?;
let result = wait_for_redirect(listener)?;
let token = exchange_code(&result.code, &verifier, &redirect_uri)?;
let refresh_token = token.refresh_token.ok_or_else(|| {
AppError::Other(
"Kein Refresh-Token erhalten. Widerrufe den Zugriff in deinem Google-Konto (myaccount.google.com/permissions) und versuche es erneut.".into(),
)
})?;
let email = fetch_email(&token.access_token)?;
Ok((email, refresh_token))
}
+176
View File
@@ -0,0 +1,176 @@
use crate::error::{AppError, AppResult};
use crate::mailutil::{parse_message_full, split_from, thread_ids};
use crate::models::{MessageFull, MessageSummary};
use mailparse::{parse_mail, MailHeaderMap};
use native_tls::TlsStream;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
pub struct Pop3Session {
stream: BufReader<TlsStream<TcpStream>>,
}
fn read_line(stream: &mut BufReader<TlsStream<TcpStream>>) -> AppResult<String> {
let mut line = String::new();
stream
.read_line(&mut line)
.map_err(|e| AppError::Pop3(e.to_string()))?;
Ok(line)
}
fn expect_ok(stream: &mut BufReader<TlsStream<TcpStream>>) -> AppResult<String> {
let line = read_line(stream)?;
if !line.starts_with("+OK") {
return Err(AppError::Pop3(format!("server said: {}", line.trim())));
}
Ok(line)
}
fn send_cmd(stream: &mut BufReader<TlsStream<TcpStream>>, cmd: &str) -> AppResult<()> {
stream
.get_mut()
.write_all(format!("{cmd}\r\n").as_bytes())
.map_err(|e| AppError::Pop3(e.to_string()))
}
fn read_multiline(stream: &mut BufReader<TlsStream<TcpStream>>) -> AppResult<Vec<u8>> {
let mut out = Vec::new();
loop {
let mut line = Vec::new();
let n = stream
.read_until(b'\n', &mut line)
.map_err(|e| AppError::Pop3(e.to_string()))?;
if n == 0 {
break;
}
if line == b".\r\n" || line == b".\n" {
break;
}
if line.starts_with(b"..") {
out.extend_from_slice(&line[1..]);
} else {
out.extend_from_slice(&line);
}
}
Ok(out)
}
pub fn connect(host: &str, port: u16, username: &str, password: &str) -> AppResult<Pop3Session> {
let connector = native_tls::TlsConnector::new().map_err(|e| AppError::Pop3(e.to_string()))?;
let tcp = TcpStream::connect((host, port)).map_err(|e| AppError::Pop3(e.to_string()))?;
let tls = connector
.connect(host, tcp)
.map_err(|e| AppError::Pop3(e.to_string()))?;
let mut stream = BufReader::new(tls);
expect_ok(&mut stream)?;
send_cmd(&mut stream, &format!("USER {username}"))?;
expect_ok(&mut stream)?;
send_cmd(&mut stream, &format!("PASS {password}"))?;
expect_ok(&mut stream)?;
Ok(Pop3Session { stream })
}
impl Pop3Session {
fn list_ids(&mut self) -> AppResult<Vec<u32>> {
send_cmd(&mut self.stream, "LIST")?;
expect_ok(&mut self.stream)?;
let mut ids = Vec::new();
loop {
let line = read_line(&mut self.stream)?;
if line.trim() == "." {
break;
}
if let Some(num_str) = line.split_whitespace().next() {
if let Ok(num) = num_str.parse::<u32>() {
ids.push(num);
}
}
}
Ok(ids)
}
pub fn list_messages(&mut self, account_id: &str) -> AppResult<Vec<MessageSummary>> {
let mut ids = self.list_ids()?;
ids.sort_unstable();
ids.reverse();
ids.truncate(60);
let mut out = Vec::new();
for id in ids {
send_cmd(&mut self.stream, &format!("TOP {id} 0"))?;
let ok = expect_ok(&mut self.stream);
if ok.is_err() {
continue;
}
let header_bytes = read_multiline(&mut self.stream)?;
let parsed = parse_mail(&header_bytes).ok();
let from_raw = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("From"))
.unwrap_or_default();
let (from_name, from_addr) = split_from(&from_raw);
let subject = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("Subject"))
.unwrap_or_else(|| "(kein Betreff)".to_string());
let date_raw = parsed
.as_ref()
.and_then(|p| p.headers.get_first_value("Date"))
.unwrap_or_default();
let timestamp = mailparse::dateparse(&date_raw).unwrap_or(0);
let (message_id, thread_id) = thread_ids(parsed.as_ref(), &format!("{account_id}-INBOX-{id}"));
out.push(MessageSummary {
uid: id,
account_id: account_id.to_string(),
folder: "INBOX".to_string(),
from_name,
from_addr,
subject,
date: date_raw,
timestamp,
snippet: String::new(),
is_read: true,
has_attachments: false,
flagged: false,
message_id,
thread_id,
category: None,
});
}
Ok(out)
}
pub fn retr_raw(&mut self, id: u32) -> AppResult<Vec<u8>> {
send_cmd(&mut self.stream, &format!("RETR {id}"))?;
expect_ok(&mut self.stream)?;
read_multiline(&mut self.stream)
}
pub fn fetch_full(&mut self, account_id: &str, id: u32) -> AppResult<MessageFull> {
let raw = self.retr_raw(id)?;
parse_message_full(account_id, "INBOX", id, &raw)
}
pub fn export_all(&mut self, account_id: &str) -> AppResult<Vec<Vec<u8>>> {
let summaries = self.list_messages(account_id)?;
let mut out = Vec::new();
for s in summaries {
if let Ok(raw) = self.retr_raw(s.uid) {
out.push(raw);
}
}
Ok(out)
}
pub fn delete(&mut self, id: u32) -> AppResult<()> {
send_cmd(&mut self.stream, &format!("DELE {id}"))?;
expect_ok(&mut self.stream)?;
Ok(())
}
pub fn quit(&mut self) {
let _ = send_cmd(&mut self.stream, "QUIT");
let _ = expect_ok(&mut self.stream);
}
}
+125
View File
@@ -0,0 +1,125 @@
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc};
/// Safety cap on generated occurrences per expansion call, so an unbounded
/// (no UNTIL) daily/weekly series can never spin forever.
const MAX_OCCURRENCES: usize = 730;
enum Freq {
Daily,
Weekly,
Monthly,
Yearly,
}
struct Rule {
freq: Freq,
interval: i64,
until: Option<i64>,
}
/// Parses a minimal RFC5545-flavored RRULE string as produced by our own UI:
/// `FREQ=DAILY|WEEKLY|MONTHLY|YEARLY[;INTERVAL=n][;UNTIL=YYYYMMDD]`.
///
/// Deliberately returns `None` (falling back to a single occurrence) for any
/// recognized-but-unsupported RRULE part (BYDAY, BYMONTHDAY, COUNT, ...) instead of
/// silently ignoring it — this matters once CalDAV brings in RRULEs from other
/// clients (a phone calendar app, Nextcloud's web UI) that this hand-rolled expander
/// was never built to interpret correctly. Showing just the first occurrence is a
/// far safer wrong answer than generating occurrences at the wrong times.
fn parse(rrule: &str) -> Option<Rule> {
let mut freq = None;
let mut interval = 1i64;
let mut until = None;
for part in rrule.split(';') {
let mut it = part.splitn(2, '=');
let key = it.next()?.trim();
let val = it.next().unwrap_or("").trim();
match key {
"FREQ" => {
freq = Some(match val {
"DAILY" => Freq::Daily,
"WEEKLY" => Freq::Weekly,
"MONTHLY" => Freq::Monthly,
"YEARLY" => Freq::Yearly,
_ => return None,
})
}
"INTERVAL" => interval = val.parse().unwrap_or(1),
"UNTIL" => {
let date_part = &val[..val.len().min(8)];
if let Ok(d) = NaiveDate::parse_from_str(date_part, "%Y%m%d") {
if let Some(dt) = d.and_hms_opt(23, 59, 59) {
until = Some(Utc.from_utc_datetime(&dt).timestamp());
}
}
}
"BYDAY" | "BYMONTHDAY" | "BYMONTH" | "BYSETPOS" | "BYYEARDAY" | "BYWEEKNO" | "COUNT" | "WKST" => {
return None;
}
_ => {}
}
}
Some(Rule {
freq: freq?,
interval: interval.max(1),
until,
})
}
fn last_day_of_month(year: i32, month: u32) -> u32 {
let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
NaiveDate::from_ymd_opt(ny, nm, 1)
.and_then(|d| d.pred_opt())
.map(|d| d.day())
.unwrap_or(28)
}
fn add_months(dt: DateTime<Utc>, months: i64) -> DateTime<Utc> {
let naive = dt.naive_utc();
let date = naive.date();
let total_months = i64::from(date.year()) * 12 + i64::from(date.month0()) + months;
let new_year = total_months.div_euclid(12) as i32;
let new_month = total_months.rem_euclid(12) as u32 + 1;
let new_day = date.day().min(last_day_of_month(new_year, new_month));
let Some(new_date) = NaiveDate::from_ymd_opt(new_year, new_month, new_day) else {
return dt;
};
Utc.from_utc_datetime(&new_date.and_time(naive.time()))
}
/// Expands a recurring event's occurrences overlapping `[range_start, range_end)`,
/// skipping any occurrence whose start matches an entry in `exdates` (single
/// occurrences deleted or overridden out of the series). Falls back to the single
/// base occurrence if `rrule` is unparseable.
pub fn expand(base_start: i64, base_end: i64, rrule: &str, range_start: i64, range_end: i64, exdates: &[i64]) -> Vec<(i64, i64)> {
let Some(rule) = parse(rrule) else {
return vec![(base_start, base_end)];
};
let duration = base_end - base_start;
let Some(mut current) = Utc.timestamp_opt(base_start, 0).single() else {
return vec![(base_start, base_end)];
};
let mut out = Vec::new();
let mut n = 0;
while current.timestamp() < range_end && n < MAX_OCCURRENCES {
if let Some(until) = rule.until {
if current.timestamp() > until {
break;
}
}
let occ_start = current.timestamp();
let occ_end = occ_start + duration;
if occ_end > range_start && occ_start < range_end && !exdates.contains(&occ_start) {
out.push((occ_start, occ_end));
}
current = match rule.freq {
Freq::Daily => current + Duration::days(rule.interval),
Freq::Weekly => current + Duration::weeks(rule.interval),
Freq::Monthly => add_months(current, rule.interval),
Freq::Yearly => add_months(current, rule.interval * 12),
};
n += 1;
}
out
}
+70
View File
@@ -0,0 +1,70 @@
use crate::db::Db;
use chrono::TimeZone;
use std::collections::HashSet;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tauri_plugin_notification::NotificationExt;
const POLL_INTERVAL_SECS: u64 = 30;
/// Only fire a reminder whose trigger moment is this recent — avoids a flood of
/// stale notifications for reminders that elapsed while the app was closed.
const FRESHNESS_WINDOW_SECS: i64 = 300;
const LOOKAHEAD_SECS: i64 = 2 * 86400;
pub fn spawn_poller(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let notified: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS));
loop {
interval.tick().await;
poll_once(&app, &notified);
}
});
}
fn poll_once(app: &AppHandle, notified: &Mutex<HashSet<String>>) {
let db = app.state::<Db>();
let Ok(events) = db.list_events_with_reminders() else { return };
let now = chrono::Utc::now().timestamp();
for base in events {
let Some(reminder_min) = base.reminder_minutes else { continue };
let occurrences = if let Some(rule) = &base.rrule {
let exdates: Vec<i64> = base.exdates.as_deref().unwrap_or("").split(',').filter_map(|s| s.trim().parse().ok()).collect();
crate::recurrence::expand(base.start_ts, base.end_ts, rule, now - 3600, now + LOOKAHEAD_SECS, &exdates)
} else {
vec![(base.start_ts, base.end_ts)]
};
for (occ_start, _) in occurrences {
let trigger_ts = occ_start - i64::from(reminder_min) * 60;
if trigger_ts > now || now - trigger_ts >= FRESHNESS_WINDOW_SECS {
continue;
}
let key = format!("{}#{}", base.id, occ_start);
{
let mut set = notified.lock().unwrap();
if !set.insert(key) {
continue;
}
}
let when = chrono::Local
.timestamp_opt(occ_start, 0)
.single()
.map(|d| d.format("um %H:%M").to_string())
.unwrap_or_default();
let body = if base.all_day {
"Heute".to_string()
} else {
format!("Beginnt {when}")
};
let _ = app
.notification()
.builder()
.title(format!("Erinnerung: {}", base.title))
.body(body)
.show();
}
}
}
+121
View File
@@ -0,0 +1,121 @@
use crate::accounts;
use crate::error::{AppError, AppResult};
use crate::models::{Account, AuthMethod, ComposeMessage};
use crate::oauth;
use base64::Engine;
use lettre::message::header::{HeaderName, HeaderValue};
use lettre::message::{Attachment, MultiPart};
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
fn strip_html(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
for c in html.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
out
}
fn resolve_credentials(account: &Account) -> AppResult<(Credentials, Option<Vec<Mechanism>>)> {
match account.auth_method {
AuthMethod::Password => {
let password = accounts::get_password(&account.id)?;
Ok((Credentials::new(account.username.clone(), password), None))
}
AuthMethod::GoogleOauth => {
let refresh_token = accounts::get_password(&account.id)?;
let access_token = oauth::refresh_access_token(&refresh_token)?;
Ok((
Credentials::new(account.email.clone(), access_token),
Some(vec![Mechanism::Xoauth2]),
))
}
}
}
pub async fn send(account: &Account, msg: ComposeMessage) -> AppResult<()> {
let account_owned = account.clone();
let (creds, mechanisms) = tauri::async_runtime::spawn_blocking(move || resolve_credentials(&account_owned))
.await
.map_err(|e| AppError::Other(e.to_string()))??;
let mut builder = if account.smtp_starttls {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&account.smtp_host)
.map_err(|e| AppError::Smtp(e.to_string()))?
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&account.smtp_host)
.map_err(|e| AppError::Smtp(e.to_string()))?
}
.port(account.smtp_port)
.credentials(creds);
if let Some(mechanisms) = mechanisms {
builder = builder.authentication(mechanisms);
}
let mailer: AsyncSmtpTransport<Tokio1Executor> = builder.build();
let mut builder = Message::builder()
.from(account.email.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?)
.to(msg.to.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?)
.subject(msg.subject.clone());
if let Some(cc) = msg.cc.filter(|s| !s.trim().is_empty()) {
builder = builder.cc(cc.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?);
}
if let Some(bcc) = msg.bcc.filter(|s| !s.trim().is_empty()) {
builder = builder.bcc(bcc.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?);
}
if let Some(ref_id) = msg.in_reply_to.filter(|s| !s.trim().is_empty()) {
builder = builder.in_reply_to(ref_id);
}
if let Some(reply_to) = msg.reply_to.filter(|s| !s.trim().is_empty()) {
builder = builder.reply_to(reply_to.parse().map_err(|e: lettre::address::AddressError| AppError::Smtp(e.to_string()))?);
}
if msg.request_read_receipt {
if let Ok(name) = HeaderName::new_from_ascii("Disposition-Notification-To".to_string()) {
builder = builder.raw_header(HeaderValue::new(name, account.email.clone()));
}
}
if let Some(importance) = msg.importance.as_deref() {
let (priority, importance_word) = match importance {
"high" => ("1", "high"),
"low" => ("5", "low"),
_ => ("3", "normal"),
};
if let Ok(name) = HeaderName::new_from_ascii("X-Priority".to_string()) {
builder = builder.raw_header(HeaderValue::new(name, priority.to_string()));
}
if let Ok(name) = HeaderName::new_from_ascii("Importance".to_string()) {
builder = builder.raw_header(HeaderValue::new(name, importance_word.to_string()));
}
}
let plain = strip_html(&msg.body_html);
let mut mixed = MultiPart::mixed().multipart(MultiPart::alternative_plain_html(plain, msg.body_html.clone()));
for att in msg.attachments {
let bytes = base64::engine::general_purpose::STANDARD
.decode(att.content_base64.as_bytes())
.map_err(|e| AppError::Smtp(format!("invalid attachment data: {e}")))?;
let content_type = att
.mime_type
.parse()
.map_err(|_| AppError::Smtp("invalid attachment mime type".into()))?;
mixed = mixed.singlepart(Attachment::new(att.filename).body(bytes, content_type));
}
let email = builder
.multipart(mixed)
.map_err(|e| AppError::Smtp(e.to_string()))?;
mailer
.send(email)
.await
.map_err(|e| AppError::Smtp(e.to_string()))?;
Ok(())
}
+85
View File
@@ -0,0 +1,85 @@
use crate::models::Contact;
fn escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(',', "\\,")
.replace(';', "\\;")
.replace('\n', "\\n")
}
pub fn export(contacts: &[Contact]) -> String {
let mut out = String::new();
for c in contacts {
out.push_str("BEGIN:VCARD\r\nVERSION:3.0\r\n");
out.push_str(&format!("FN:{}\r\n", escape(&c.name)));
if let Some(email) = c.email.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("EMAIL;TYPE=INTERNET:{}\r\n", escape(email)));
}
if let Some(phone) = c.phone.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("TEL:{}\r\n", escape(phone)));
}
if let Some(address) = c.address.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("ADR:;;{};;;;\r\n", escape(address)));
}
if let Some(company) = c.company.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("ORG:{}\r\n", escape(company)));
}
if let Some(notes) = c.notes.as_ref().filter(|s| !s.is_empty()) {
out.push_str(&format!("NOTE:{}\r\n", escape(notes)));
}
out.push_str("END:VCARD\r\n");
}
out
}
pub struct ParsedCard {
pub name: String,
pub email: String,
pub company: Option<String>,
pub notes: Option<String>,
}
fn unescape(s: &str) -> String {
s.replace("\\n", "\n")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
}
pub fn parse(data: &str) -> Vec<ParsedCard> {
let mut out = Vec::new();
let mut name = String::new();
let mut email = String::new();
let mut company: Option<String> = None;
let mut notes: Option<String> = None;
for raw_line in data.lines() {
let line = raw_line.trim_end_matches('\r');
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
name.clear();
email.clear();
company = None;
notes = None;
} else if let Some(v) = line.strip_prefix("FN:") {
name = unescape(v);
} else if line.to_uppercase().starts_with("EMAIL") {
if let Some(idx) = line.find(':') {
email = unescape(&line[idx + 1..]);
}
} else if let Some(v) = line.strip_prefix("ORG:") {
company = Some(unescape(v));
} else if let Some(v) = line.strip_prefix("NOTE:") {
notes = Some(unescape(v));
} else if line.eq_ignore_ascii_case("END:VCARD") {
if !email.is_empty() {
out.push(ParsedCard {
name: if name.is_empty() { email.clone() } else { name.clone() },
email: email.clone(),
company: company.clone(),
notes: notes.clone(),
});
}
}
}
out
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "mail-client",
"version": "0.1.0",
"identifier": "com.mailclient.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "Mail Client",
"width": 1400,
"height": 900,
"minWidth": 960,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"debugApplicationIdSuffix": ".debug"
}
}
}