From dbba810778dabac3279a760e45cacd5980d3ae67 Mon Sep 17 00:00:00 2001 From: Malo Bourgon Date: Tue, 7 Apr 2026 01:34:29 -0400 Subject: [PATCH] fix(gmail): use OAuth userinfo endpoint for From display name fallback The People API fallback for resolving the From display name on Workspace accounts required people.googleapis.com to be enabled in the GCP project. When disabled, the 403 was misreported as "grant profile scope" even when the scope was already present (#644). Switch to the OAuth userinfo endpoint (oauth2/v2/userinfo), which only requires the userinfo.profile scope and no separately-enabled API. Log a note when the endpoint succeeds but returns no display name, making the silent degradation visible. Give GOOGLE_WORKSPACE_CLI_TOKEN users a context-appropriate tip instead of telling them to run gws auth login. Fixes #644 --- .changeset/fix-gmail-display-name-fallback.md | 5 + .../google-workspace-cli/src/auth_commands.rs | 5 +- .../src/helpers/gmail/mod.rs | 118 ++++++++++-------- 3 files changed, 74 insertions(+), 54 deletions(-) create mode 100644 .changeset/fix-gmail-display-name-fallback.md diff --git a/.changeset/fix-gmail-display-name-fallback.md b/.changeset/fix-gmail-display-name-fallback.md new file mode 100644 index 00000000..1d53de4b --- /dev/null +++ b/.changeset/fix-gmail-display-name-fallback.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Use the OAuth userinfo endpoint for Gmail sender display-name fallback instead of the People API, which required the API to be separately enabled in the GCP project. diff --git a/crates/google-workspace-cli/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs index d7571e74..0bd71213 100644 --- a/crates/google-workspace-cli/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -600,8 +600,9 @@ async fn handle_login_inner( // Ensure openid + email + profile scopes are always present so we can // identify the user via the userinfo endpoint after login, and so the - // Gmail helpers can fall back to the People API to populate the From - // display name when the send-as identity lacks one (Workspace accounts). + // Gmail helpers can fall back to the OAuth userinfo endpoint to populate + // the From display name when the send-as identity lacks one (Workspace + // accounts). let identity_scopes = [ "openid", "https://www.googleapis.com/auth/userinfo.email", diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index caeb8b6b..62d5e231 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -552,6 +552,18 @@ fn resolve_sender_from_identities( } } +fn uses_env_access_token() -> bool { + std::env::var_os("GOOGLE_WORKSPACE_CLI_TOKEN").is_some_and(|token| !token.is_empty()) +} + +fn profile_scope_tip(using_env_token: bool) -> &'static str { + if using_env_token { + "Tip: set `GOOGLE_WORKSPACE_CLI_TOKEN` to an access token that includes the \"profile\" scope to include your display name in the From header" + } else { + "Tip: run `gws auth login` and grant the \"profile\" scope to include your display name in the From header" + } +} + /// Resolve the `From` address using Gmail send-as identities. /// /// Fetches send-as settings and enriches the From address with the display name. @@ -595,39 +607,42 @@ pub(super) async fn resolve_sender( // When the resolved identity has no display name (common for Workspace accounts // where the primary address inherits its name from the organization directory), - // try the People API as a fallback. This requires the `profile` scope, which - // may not be granted — if so, degrade gracefully with a hint. + // try Google's OAuth userinfo endpoint as a fallback. This requires the + // `profile` scope, which may not be granted — if so, degrade gracefully + // with a hint. if let Some(ref addrs) = result { - // Only attempt People API for a single address — the API returns one - // profile name, so it can't meaningfully enrich multiple From addresses. + // Only attempt the userinfo endpoint for a single address — it returns + // one profile name, so it can't meaningfully enrich multiple From addresses. if addrs.len() == 1 && addrs[0].name.is_none() { + let using_env_token = uses_env_access_token(); let profile_token = auth::get_token(&["https://www.googleapis.com/auth/userinfo.profile"]).await; match profile_token { Err(e) => { // Token acquisition failed — scope likely not granted. eprintln!( - "Tip: run `gws auth login` and grant the \"profile\" scope \ - to include your display name in the From header ({})", + "{} ({})", + profile_scope_tip(using_env_token), sanitize_for_terminal(&e.to_string()) ); } - Ok(t) => match fetch_profile_display_name(client, &t).await { + Ok(t) => match fetch_userinfo_display_name(client, &t).await { Ok(Some(name)) => { let raw = format!("{name} <{}>", addrs[0].email); result = Some(vec![Mailbox::parse(&raw)]); } - Ok(None) => {} - Err(e) if matches!(&e, GwsError::Api { code: 403, .. }) => { - // Token exists but doesn't carry the scope. + Ok(None) => { eprintln!( - "Tip: run `gws auth login` and grant the \"profile\" scope \ - to include your display name in the From header" + "Note: the userinfo endpoint returned no display name for this account" ); } + Err(e) if matches!(&e, GwsError::Api { code: 403, .. }) => { + // Token exists but doesn't carry the scope. + eprintln!("{}", profile_scope_tip(using_env_token)); + } Err(e) => { eprintln!( - "Note: could not fetch display name from People API ({})", + "Note: could not fetch display name from the userinfo endpoint ({})", sanitize_for_terminal(&e.to_string()) ); } @@ -639,20 +654,19 @@ pub(super) async fn resolve_sender( Ok(result) } -/// Fetch the authenticated user's display name from the People API. +/// Fetch the authenticated user's display name from Google's OAuth userinfo endpoint. /// Requires a token with the `profile` scope. -async fn fetch_profile_display_name( +async fn fetch_userinfo_display_name( client: &reqwest::Client, token: &str, ) -> Result, GwsError> { let resp = crate::client::send_with_retry(|| { client - .get("https://people.googleapis.com/v1/people/me") - .query(&[("personFields", "names")]) + .get("https://www.googleapis.com/oauth2/v2/userinfo") .bearer_auth(token) }) .await - .map_err(|e| GwsError::Other(anyhow::anyhow!("People API request failed: {e}")))?; + .map_err(|e| GwsError::Other(anyhow::anyhow!("userinfo request failed: {e}")))?; if !resp.status().is_success() { let status = resp.status().as_u16(); @@ -660,22 +674,20 @@ async fn fetch_profile_display_name( .text() .await .unwrap_or_else(|_| "(error body unreadable)".to_string()); - return Err(build_api_error(status, &body, "People API request failed")); + return Err(build_api_error(status, &body, "userinfo request failed")); } - let body: Value = resp.json().await.map_err(|e| { - GwsError::Other(anyhow::anyhow!("Failed to parse People API response: {e}")) - })?; + let body: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse userinfo response: {e}")))?; - Ok(parse_profile_display_name(&body)) + Ok(parse_userinfo_display_name(&body)) } -/// Extract the display name from a People API `people.get` response. -fn parse_profile_display_name(body: &Value) -> Option { - body.get("names") - .and_then(|v| v.as_array()) - .and_then(|names| names.first()) - .and_then(|n| n.get("displayName")) +/// Extract the display name from a Google OAuth userinfo response. +fn parse_userinfo_display_name(body: &Value) -> Option { + body.get("name") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(sanitize_control_chars) @@ -3393,26 +3405,18 @@ mod tests { assert!(addrs[0].name.is_none()); } - // --- parse_profile_display_name tests --- + // --- parse_userinfo_display_name tests --- #[test] - fn test_parse_profile_display_name() { + fn test_parse_userinfo_display_name() { let body = serde_json::json!({ - "resourceName": "people/112118466613566642951", - "etag": "%EgUBAi43PRoEAQIFByIMR0xCc0FMcVBJQmc9", - "names": [{ - "metadata": { - "primary": true, - "source": { "type": "DOMAIN_PROFILE", "id": "112118466613566642951" } - }, - "displayName": "Malo Bourgon", - "familyName": "Bourgon", - "givenName": "Malo", - "displayNameLastFirst": "Bourgon, Malo" - }] + "id": "112118466613566642951", + "name": "Malo Bourgon", + "given_name": "Malo", + "family_name": "Bourgon" }); assert_eq!( - parse_profile_display_name(&body).as_deref(), + parse_userinfo_display_name(&body).as_deref(), Some("Malo Bourgon") ); } @@ -3583,23 +3587,33 @@ mod tests { } #[test] - fn test_parse_profile_display_name_empty() { + fn test_parse_userinfo_display_name_empty() { let body = serde_json::json!({}); - assert!(parse_profile_display_name(&body).is_none()); + assert!(parse_userinfo_display_name(&body).is_none()); } #[test] - fn test_parse_profile_display_name_empty_name() { + fn test_parse_userinfo_display_name_empty_name() { let body = serde_json::json!({ - "names": [{ "displayName": "" }] + "name": "" }); - assert!(parse_profile_display_name(&body).is_none()); + assert!(parse_userinfo_display_name(&body).is_none()); + } + + #[test] + fn test_parse_userinfo_display_name_non_string_name() { + let body = serde_json::json!({ "name": ["not-a-string"] }); + assert!(parse_userinfo_display_name(&body).is_none()); + } + + #[test] + fn test_profile_scope_tip_for_saved_credentials() { + assert!(profile_scope_tip(false).contains("gws auth login")); } #[test] - fn test_parse_profile_display_name_no_names_array() { - let body = serde_json::json!({ "names": "not-an-array" }); - assert!(parse_profile_display_name(&body).is_none()); + fn test_profile_scope_tip_for_env_token() { + assert!(profile_scope_tip(true).contains("GOOGLE_WORKSPACE_CLI_TOKEN")); } // --- build_api_error tests ---