Files
mail-client/src-tauri/src/gcontacts.rs
T
KanjiG 20bfd2cf6b 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.
2026-07-28 20:56:05 +02:00

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(())
}