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,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(())
|
||||
}
|
||||
Reference in New Issue
Block a user