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, } #[derive(Debug, Deserialize)] struct GEmailAddress { value: Option, } #[derive(Debug, Deserialize)] struct GOrganization { name: Option, } #[derive(Debug, Deserialize)] struct GPhoneNumber { value: Option, } #[derive(Debug, Deserialize)] struct GAddress { #[serde(rename = "formattedValue")] formatted_value: Option, } #[derive(Debug, Deserialize)] struct GPerson { #[serde(rename = "resourceName", default)] resource_name: String, names: Option>, #[serde(rename = "emailAddresses")] email_addresses: Option>, #[serde(default)] organizations: Option>, #[serde(rename = "phoneNumbers", default)] phone_numbers: Option>, #[serde(default)] addresses: Option>, } #[derive(Debug, Deserialize)] struct ConnectionsResponse { #[serde(default)] connections: Vec, #[serde(rename = "nextPageToken")] next_page_token: Option, } #[derive(Debug, Deserialize)] struct OtherContactsResponse { #[serde(default, rename = "otherContacts")] other_contacts: Vec, #[serde(rename = "nextPageToken")] next_page_token: Option, } pub struct ParsedContact { pub name: String, pub email: Option, pub phone: Option, pub address: Option, pub company: Option, pub resource_name: String, } fn check_status(resp: reqwest::blocking::Response) -> AppResult { 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 { 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> { let mut out = Vec::new(); let mut page_token: Option = 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> { let mut out = Vec::new(); let mut page_token: Option = 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> { 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(()) }