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:
@@ -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(())
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 = ¶ms[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
|
||||
}
|
||||
@@ -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" }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬ified);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user