Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-gmail-display-name-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions crates/google-workspace-cli/src/auth_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 66 additions & 52 deletions crates/google-workspace-cli/src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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())
);
}
Expand All @@ -639,43 +654,40 @@ 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<Option<String>, 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();
let body = resp
.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<String> {
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<String> {
body.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(sanitize_control_chars)
Expand Down Expand Up @@ -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")
);
}
Expand Down Expand Up @@ -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 ---
Expand Down
Loading