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.
229 lines
7.6 KiB
Rust
229 lines
7.6 KiB
Rust
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(())
|
|
}
|