From 7a653036546fe51fb278afaf45a285eb140dafaa Mon Sep 17 00:00:00 2001 From: sgimpel Date: Thu, 13 Aug 2026 13:09:25 -0700 Subject: [PATCH] WPA-14407: Added CLI support for panel based email services --- docs/proposals/email-management-cli.md | 176 +++++++++++++++++ rust/src/email/check_eligibility.rs | 88 +++++++++ rust/src/email/client.rs | 254 +++++++++++++++++++++++++ rust/src/email/common.rs | 147 ++++++++++++++ rust/src/email/create.rs | 97 ++++++++++ rust/src/email/get.rs | 39 ++++ rust/src/email/list.rs | 63 ++++++ rust/src/email/mod.rs | 75 ++++++++ rust/src/environments/mod.rs | 95 +++++++++ rust/src/error.rs | 11 ++ rust/src/main.rs | 16 ++ rust/src/scopes.rs | 15 ++ 12 files changed, 1076 insertions(+) create mode 100644 docs/proposals/email-management-cli.md create mode 100644 rust/src/email/check_eligibility.rs create mode 100644 rust/src/email/client.rs create mode 100644 rust/src/email/common.rs create mode 100644 rust/src/email/create.rs create mode 100644 rust/src/email/get.rs create mode 100644 rust/src/email/list.rs create mode 100644 rust/src/email/mod.rs diff --git a/docs/proposals/email-management-cli.md b/docs/proposals/email-management-cli.md new file mode 100644 index 00000000..b9f8da74 --- /dev/null +++ b/docs/proposals/email-management-cli.md @@ -0,0 +1,176 @@ +# Proposal: `gddy email` — mailbox management commands + +Status: draft, seeking CLI-team consensus on the open questions below. + +## Motivation + +The panel team built a new public-facing API +(`productivity-panel-api/src/server/panel-v3-api/`) that lets customers manage +GoDaddy Email mailboxes over an OAuth bearer token instead of the legacy +shopper-session `panel-api`. We want CLI parity so customers and agents can +create, list, get, and check eligibility for mailboxes directly from `gddy`, +following the same conventions as `domain`/`hosting`. The command surface is +`gddy email` — matching the `email.mailbox:*` OAuth scope family the API is +moving toward — even though the individual resources it manages are called +"mailboxes." + +The underlying API doesn't have update/delete yet: only check-eligibility, +create, list, and get are implemented server-side. The scope-authorization +middleware defines `email:update`/`email:delete`/`email:admin` scopes, but no +route currently uses them, so this proposal only covers what's actually +callable today. + +## Proposed command tree + +``` +gddy email list # GET /v3/email/mailboxes +gddy email get # GET /v3/email/mailbox/:mailboxId +gddy email create # POST /v3/email/mailboxes +gddy email check-eligibility # GET /v3/email/check-eligibility +``` + +## Per-command spec + +### `gddy email check-eligibility` + +| | | +|---|---| +| Flags | `--email ` (required) | +| Tier | `Read` | +| Scopes | `EMAIL_READ` (`email.mailbox:read`) | + +Example output: + +```json +{ + "isEligible": false, + "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"], + "eligibleAccounts": [ + { + "accountId": "acct-123", + "requirements": [{ "agreementType": "EMAIL_TOS", "url": "https://..." }] + } + ] +} +``` + +When the response is ineligible, or an eligible account carries outstanding +`requirements`, attach a `next_actions` entry pointing at `email create` with +the relevant `--account-id`/`--consent` pre-filled from the response. + +### `gddy email create` + +| | | +|---|---| +| Flags | `--email ` (required), `--account-id`, `--first-name`, `--last-name`, repeatable `--consent ` | +| Tier | `Mutate` (`.mutates(true)`) | +| Scopes | `EMAIL_CREATE` (`email.mailbox:create`) | + +Example output: + +```json +{ + "mailboxId": "mbx-456", + "email": "someone@example.com", + "status": "PROVISIONING" +} +``` + +On a `400`/`422` business-rule failure (missing agreements, no eligible +account), surface a `fix` hint pointing at +`gddy email check-eligibility --email ` instead of a generic HTTP +error. + +### `gddy email get ` + +| | | +|---|---| +| Args | positional `mailbox-id` | +| Tier | `Read` | +| Scopes | `EMAIL_READ` | + +Example output: + +```json +{ "mailboxId": "mbx-456", "email": "someone@example.com", "status": "ACTIVE" } +``` + +### `gddy email list` + +| | | +|---|---| +| Flags | `--status`, `--page`, `--page-size`, `--fields` | +| Tier | `Read` | +| Scopes | `EMAIL_READ` | + +Example output: + +```json +[ + { "mailboxId": "mbx-456", "email": "someone@example.com", "status": "ACTIVE" } +] +``` + +`get`/`list`/`create` all attach a `next_actions` entry toward `email get +` where a mailbox ID is available in the response. + +## Open questions for the CLI team + +### 1. Feature stage: `Beta` vs `Experimental` + +`Stage::Beta` matches `hosting`'s precedent (a real spec, real tests, just +needs field usage before graduating to GA). `Stage::Experimental` matches +`platform`'s precedent (still early, likely to reshape). + +**Recommendation: `Beta`.** The API surface is small and stable relative to +what it does support; the open items below are about coordination, not about +the shape of the API changing further. + +### 2. Update/delete: omit or stub? + +The API doesn't implement `email:update`/`email:delete` routes yet, even +though the scope middleware reserves the scope names. Options: omit those +commands entirely until the API ships them, or scaffold stub commands now +that return a clear "not yet supported by the API" error. + +**Recommendation: omit entirely.** Stub commands would need their own dead +scopes (tripping the `scope_registry_non_default_entries_are_wired_to_a_command` +test, or requiring a carve-out from it) and can't be meaningfully tested +against a real API. Adding them once the routes exist is a small, low-risk +follow-up PR. + +### 3. List pagination UX: native `--page`/`--page-size` vs generic `--limit`/`--offset` + +The server paginates natively (`page`/`pageSize`, capped at 100, with +HATEOAS `links`), which doesn't fit cli-engine's `PaginationConfig` model — +that model expects the handler to return the *complete* list and lets the +engine slice it via generic `--limit`/`--offset`, the way `domain list` does. + +**Recommendation: native `--page`/`--page-size` flags, forwarded 1:1 to the +API's query params**, rather than adopting `--limit`/`--offset` via +`PaginationConfig`. Translating page-based server pagination into offset math +would be lossy and would hide the server's own `links`. This is a deliberate +deviation from the `domain list` precedent, called out explicitly here so +it's a conscious choice rather than an inconsistency someone trips over later. + +## Cross-team coordination needed before this ships + +These aren't CLI-team decisions, but they block a working end-to-end command +and are worth surfacing here so they're tracked alongside the UX questions: + +- **Scope naming.** The CLI will request the forward-looking dotted scopes + (`email.mailbox:read`/`email.mailbox:create`), but the currently deployed + API still enforces the older flat names (`email:read`/`email:create`). + Either the panel team updates enforcement to accept the dotted names before + the CLI goes live, or the OAuth authorization server needs to be configured + to grant whichever scopes the deployed API actually checks. +- **Path prefix.** The CLI targets `/v3/email/...` (matching the OpenAPI + spec's server template), but the deployed Express app currently mounts + these routes at `/v3` directly (no `/email` segment). Until the panel team + aligns the deployed routing with the spec (or adds a `/v3/email` alias), + CLI requests will 404 against the current deployment. + +## Non-goals + +- `gddy email update` / `gddy email delete` (see open question #2). +- Any admin-scoped mailbox operations (`email:admin`) — no route exists yet. diff --git a/rust/src/email/check_eligibility.rs b/rust/src/email/check_eligibility.rs new file mode 100644 index 00000000..e74374ba --- /dev/null +++ b/rust/src/email/check_eligibility.rs @@ -0,0 +1,88 @@ +use cli_engine::{ + CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, +}; +use serde_json::Value; + +use crate::email::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::EMAIL_READ; + +#[derive(Debug, Clone, clap::Args)] +struct CheckEligibilityArgs { + #[arg(long, value_name = "EMAIL")] + email: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "check-eligibility", + "Check whether an email address is eligible for a new mailbox", + ) + .with_system("email") + .with_tier(Tier::Read) + .with_scopes(&[EMAIL_READ]), + |ctx, args: CheckEligibilityArgs| async move { + let client = make_client(&ctx, &[EMAIL_READ]).await?; + let data = client + .check_eligibility(&args.email) + .await + .map_err(client_err)?; + let next_actions = eligibility_next_actions(&args.email, &data); + Ok(CommandResult::new(data).with_next_actions(next_actions)) + }, + ) +} + +/// Always points at `email create` and prefills `--account-id` when the +/// response names an eligible account. +fn eligibility_next_actions(email: &str, data: &Value) -> Vec { + let mut create = next_action("email create", "Create a mailbox for this address") + .with_param("email", NextActionParam::value(email.to_owned())); + + if let Some(account_id) = first_eligible_account_id(data) { + create = create.with_param("account-id", NextActionParam::value(account_id)); + } + + vec![create] +} + +fn first_eligible_account_id(data: &Value) -> Option { + data.get("eligibleAccounts")? + .as_array()? + .first()? + .get("accountId")? + .as_str() + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn is_a_read_tier_command_scoped_to_email_read() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Read)); + assert_eq!(spec.metadata().scopes, vec![EMAIL_READ.to_string()]); + } + + #[test] + fn next_actions_prefill_email_and_account_id_when_present() { + let data = json!({ + "isEligible": true, + "eligibleAccounts": [{ "accountId": "acct-1", "requirements": [] }] + }); + let actions = eligibility_next_actions("someone@example.com", &data); + assert_eq!(actions.len(), 1); + } + + #[test] + fn next_actions_still_point_at_create_when_no_eligible_accounts() { + let data = json!({ "isEligible": false, "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"] }); + let actions = eligibility_next_actions("someone@example.com", &data); + assert_eq!(actions.len(), 1); + } +} diff --git a/rust/src/email/client.rs b/rust/src/email/client.rs new file mode 100644 index 00000000..342deecd --- /dev/null +++ b/rust/src/email/client.rs @@ -0,0 +1,254 @@ +use reqwest::{Client, Method}; +use serde_json::{Value, json}; + +use crate::application::client::make_http_client; + +const BASE_PATH: &str = "/v3/email"; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("HTTP error {status}: {body}")] + Http { status: u16, body: String }, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), +} + +impl From for crate::error::GddyError { + fn from(value: ClientError) -> Self { + match value { + ClientError::Http { status, body } => Self::from_http(status, body, "email"), + ClientError::Network(e) => { + Self::network(format!("network error: {e}")).with_system("email") + } + } + } +} + +pub struct EmailClient { + client: Client, + base_url: String, + token: String, +} + +impl EmailClient { + pub fn new(base_url: impl Into, token: impl Into) -> Self { + Self { + client: make_http_client(), + base_url: base_url.into(), + token: token.into(), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{BASE_PATH}{path}", self.base_url) + } + + fn new_request_id() -> String { + uuid::Uuid::new_v4().to_string() + } + + async fn send_json( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + body: Option, + ) -> Result { + let mut req = self + .client + .request(method, self.url(path)) + .bearer_auth(&self.token) + .header("x-request-id", Self::new_request_id()); + for (key, value) in query { + req = req.query(&[(key, value)]); + } + if let Some(body) = body { + req = req.json(&body); + } + let request = req.build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let resp = self.client.execute(request).await?; + + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + let status = status.as_u16(); + if status == 204 { + return Ok(json!(null)); + } + if !(200..300).contains(&status) { + return Err(ClientError::Http { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + }); + } + if bytes.is_empty() { + return Ok(json!(null)); + } + serde_json::from_slice(&bytes).map_err(|e| ClientError::Http { + status, + body: format!( + "invalid JSON response: {e} (body: {})", + String::from_utf8_lossy(&bytes) + ), + }) + } + + pub async fn list_mailboxes(&self, query: &[(&str, String)]) -> Result { + self.send_json(Method::GET, "/mailboxes", query, None).await + } + + pub async fn get_mailbox(&self, mailbox_id: &str) -> Result { + self.send_json(Method::GET, &format!("/mailbox/{mailbox_id}"), &[], None) + .await + } + + pub async fn create_mailbox(&self, body: Value) -> Result { + self.send_json(Method::POST, "/mailboxes", &[], Some(body)) + .await + } + + pub async fn check_eligibility(&self, email: &str) -> Result { + self.send_json( + Method::GET, + "/check-eligibility", + &[("email", email.to_owned())], + None, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + use super::*; + + fn client(base_url: &str) -> EmailClient { + EmailClient::new(base_url, "test-token") + } + + #[tokio::test] + async fn list_mailboxes_sends_bearer_auth_and_query_params() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailboxes") + .header("authorization", "Bearer test-token") + .query_param("status", "ACTIVE") + .query_param("page", "1"); + then.status(200).json_body(json!({ "mailboxes": [] })); + }) + .await; + + let body = client(&server.base_url()) + .list_mailboxes(&[("status", "ACTIVE".to_owned()), ("page", "1".to_owned())]) + .await + .expect("list mailboxes"); + + mock.assert_async().await; + assert_eq!(body["mailboxes"], json!([])); + } + + #[tokio::test] + async fn get_mailbox_sends_bearer_auth() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/mailbox/mbx-456") + .header("authorization", "Bearer test-token"); + then.status(200) + .json_body(json!({ "mailboxId": "mbx-456", "status": "ACTIVE" })); + }) + .await; + + let body = client(&server.base_url()) + .get_mailbox("mbx-456") + .await + .expect("get mailbox"); + + mock.assert_async().await; + assert_eq!(body["mailboxId"], "mbx-456"); + } + + #[tokio::test] + async fn create_mailbox_posts_json_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v3/email/mailboxes") + .header("authorization", "Bearer test-token") + .json_body(json!({ "email": "someone@example.com" })); + then.status(200) + .json_body(json!({ "mailboxId": "mbx-456", "status": "PROVISIONING" })); + }) + .await; + + let body = client(&server.base_url()) + .create_mailbox(json!({ "email": "someone@example.com" })) + .await + .expect("create mailbox"); + + mock.assert_async().await; + assert_eq!(body["status"], "PROVISIONING"); + } + + #[tokio::test] + async fn check_eligibility_sends_email_query_param() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/email/check-eligibility") + .header("authorization", "Bearer test-token") + .query_param("email", "someone@example.com"); + then.status(200).json_body(json!({ "isEligible": true })); + }) + .await; + + let body = client(&server.base_url()) + .check_eligibility("someone@example.com") + .await + .expect("check eligibility"); + + mock.assert_async().await; + assert_eq!(body["isEligible"], true); + } + + #[tokio::test] + async fn create_mailbox_surfaces_business_rule_error_body() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v3/email/mailboxes"); + then.status(422).json_body(json!({ + "name": "UnprocessableEntity", + "message": "missing required agreements", + "correlationId": "corr-1", + "details": [{ "issue": "MISSING_AGREEMENT", "description": "EMAIL_TOS not accepted" }] + })); + }) + .await; + + let err = client(&server.base_url()) + .create_mailbox(json!({ "email": "someone@example.com" })) + .await + .expect_err("business-rule failure should surface as an error"); + + mock.assert_async().await; + match err { + ClientError::Http { status, body } => { + assert_eq!(status, 422); + assert!(body.contains("MISSING_AGREEMENT"), "{body}"); + } + other => panic!("unexpected: {other}"), + } + } +} diff --git a/rust/src/email/common.rs b/rust/src/email/common.rs new file mode 100644 index 00000000..62ea2038 --- /dev/null +++ b/rust/src/email/common.rs @@ -0,0 +1,147 @@ +//! Shared helpers for the `email` command group: the authenticated Email +//! (panel-v3) API client and API-error rendering. + +use cli_engine::{CliCoreError, CommandContext, Result}; +use serde::Deserialize; + +use crate::email::client::{ClientError, EmailClient}; +use crate::error::GddyError; + +pub(crate) async fn make_client(ctx: &CommandContext, scopes: &[&str]) -> Result { + let required: Vec = scopes.iter().map(|s| (*s).to_owned()).collect(); + let token = ctx.credential_with_scopes(&required).await?.token; + let base_url = crate::environments::resolve(&ctx.middleware.env)?.email_api_url; + Ok(EmailClient::new(base_url, token)) +} + +/// Maps a [`ClientError`] to a [`CliCoreError`], rendering the panel API's +/// `{message, details: [{issue, description}]}` error-body shape into a +/// human-readable message. Distinct from `domain::common::format_api_error`: +/// the panel API's error envelope doesn't carry the `fields`/402-payment +/// shape that helper is built around, so this is its own (simpler) renderer. +pub(crate) fn client_err(e: ClientError) -> CliCoreError { + match e { + ClientError::Http { status, body } => { + GddyError::from_http(status, format_api_error_body(&body), "email").into_cli_error() + } + ClientError::Network(_) => GddyError::from(e).into_cli_error(), + } +} + +/// Like [`client_err`], but overrides the fix hint. `create` uses this to +/// point business-rule failures (missing agreements, no eligible account) at +/// `email check-eligibility` instead of the generic "check request body" hint. +pub(crate) fn client_err_with_fix(e: ClientError, fix: impl Into) -> CliCoreError { + match e { + ClientError::Http { status, body } => { + GddyError::from_http(status, format_api_error_body(&body), "email") + .with_fix(fix) + .into_cli_error() + } + ClientError::Network(_) => GddyError::from(e).into_cli_error(), + } +} + +#[derive(Debug, Deserialize)] +struct ApiErrorBody { + message: Option, + #[serde(default)] + details: Vec, +} + +#[derive(Debug, Deserialize)] +struct ApiErrorDetail { + issue: Option, + description: Option, +} + +fn format_api_error_body(body: &str) -> String { + let Ok(parsed) = serde_json::from_str::(body) else { + return body.to_owned(); + }; + let message = parsed.message.unwrap_or_else(|| body.to_owned()); + let details: Vec = parsed + .details + .iter() + .filter_map(|d| d.description.clone().or_else(|| d.issue.clone())) + .filter(|s| !s.is_empty()) + .collect(); + if details.is_empty() { + message + } else { + format!("{message} ({})", details.join("; ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_api_error_body_renders_detail_descriptions() { + let body = r#"{ + "name": "UnprocessableEntity", + "message": "missing required agreements", + "correlationId": "corr-1", + "details": [{ "issue": "MISSING_AGREEMENT", "description": "EMAIL_TOS not accepted" }] + }"#; + let rendered = format_api_error_body(body); + assert_eq!( + rendered, + "missing required agreements (EMAIL_TOS not accepted)" + ); + } + + #[test] + fn format_api_error_body_falls_back_to_issue_without_description() { + let body = r#"{"message": "bad request", "details": [{"issue": "NO_ELIGIBLE_ACCOUNT"}]}"#; + assert_eq!( + format_api_error_body(body), + "bad request (NO_ELIGIBLE_ACCOUNT)" + ); + } + + #[test] + fn format_api_error_body_passes_through_unparseable_bodies() { + assert_eq!(format_api_error_body("not json"), "not json"); + } + + #[test] + fn client_err_with_fix_overrides_the_default_fix() { + let err = client_err_with_fix( + ClientError::Http { + status: 422, + body: "{\"message\": \"missing required agreements\"}".to_owned(), + }, + "Run: gddy email check-eligibility --email someone@example.com", + ); + let envelope = cli_engine::build_error_envelope(&err, "email"); + assert!( + envelope + .fix + .as_deref() + .is_some_and(|f| f.contains("check-eligibility")), + "{envelope:?}" + ); + } + + #[test] + fn client_err_maps_http_status_to_email_system() { + let err = client_err(ClientError::Http { + status: 404, + body: "{\"message\": \"mailbox not found\"}".to_owned(), + }); + let envelope = cli_engine::build_error_envelope(&err, "email"); + assert_eq!( + envelope.error.as_ref().map(|e| e.message.as_str()), + Some("HTTP error 404: mailbox not found") + ); + assert!( + envelope + .fix + .as_deref() + .is_some_and(|f| f.contains("email list")), + "{envelope:?}" + ); + } +} diff --git a/rust/src/email/create.rs b/rust/src/email/create.rs new file mode 100644 index 00000000..a59b1041 --- /dev/null +++ b/rust/src/email/create.rs @@ -0,0 +1,97 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; + +use crate::email::client::ClientError; +use crate::email::{client_err, client_err_with_fix, make_client}; +use crate::scopes::EMAIL_CREATE; + +#[derive(Debug, Clone, clap::Args)] +struct CreateArgs { + #[arg(long, value_name = "EMAIL")] + email: String, + #[arg(long = "account-id", value_name = "ACCOUNT_ID")] + account_id: Option, + #[arg(long = "first-name", value_name = "FIRST_NAME")] + first_name: Option, + #[arg(long = "last-name", value_name = "LAST_NAME")] + last_name: Option, + /// Agreement types the caller has obtained consent for, e.g. `EMAIL_TOS`. + /// Repeatable: `--consent EMAIL_TOS --consent PRIVACY_POLICY`. + #[arg(long, value_name = "AGREEMENT_TYPE")] + consent: Vec, +} + +fn request_body(args: &CreateArgs) -> Value { + let mut body = serde_json::Map::new(); + body.insert("email".to_owned(), json!(args.email)); + if let Some(account_id) = &args.account_id { + body.insert("accountId".to_owned(), json!(account_id)); + } + if let Some(first_name) = &args.first_name { + body.insert("firstName".to_owned(), json!(first_name)); + } + if let Some(last_name) = &args.last_name { + body.insert("lastName".to_owned(), json!(last_name)); + } + if !args.consent.is_empty() { + body.insert("consents".to_owned(), json!(args.consent)); + } + Value::Object(body) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("create", "Create a new Email mailbox") + .with_system("email") + .with_tier(Tier::Mutate) + .mutates(true) + .with_scopes(&[EMAIL_CREATE]), + |ctx, args: CreateArgs| async move { + let client = make_client(&ctx, &[EMAIL_CREATE]).await?; + let body = request_body(&args); + let data = client.create_mailbox(body).await.map_err(|e| match &e { + ClientError::Http { status, .. } if *status == 400 || *status == 422 => { + client_err_with_fix( + e, + format!( + "This looks like a business-rule failure (missing agreements or no \ + eligible account). Run: gddy email check-eligibility --email {}", + args.email + ), + ) + } + _ => client_err(e), + })?; + Ok(CommandResult::new(data)) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_a_mutate_tier_command_scoped_to_email_create() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Mutate)); + assert!(spec.mutates); + assert_eq!(spec.metadata().scopes, vec![EMAIL_CREATE.to_string()]); + } + + #[test] + fn request_body_includes_optional_fields_only_when_present() { + let args = CreateArgs { + email: "someone@example.com".to_owned(), + account_id: Some("acct-1".to_owned()), + first_name: None, + last_name: None, + consent: vec!["EMAIL_TOS".to_owned()], + }; + let body = request_body(&args); + assert_eq!(body["email"], "someone@example.com"); + assert_eq!(body["accountId"], "acct-1"); + assert!(body.get("firstName").is_none()); + assert_eq!(body["consents"], json!(["EMAIL_TOS"])); + } +} diff --git a/rust/src/email/get.rs b/rust/src/email/get.rs new file mode 100644 index 00000000..25ca4a1e --- /dev/null +++ b/rust/src/email/get.rs @@ -0,0 +1,39 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::email::{client_err, make_client}; +use crate::scopes::EMAIL_READ; + +#[derive(Debug, Clone, clap::Args)] +struct GetArgs { + /// The mailbox ID to fetch. + mailbox_id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get a mailbox by ID") + .with_system("email") + .with_tier(Tier::Read) + .with_scopes(&[EMAIL_READ]), + |ctx, args: GetArgs| async move { + let client = make_client(&ctx, &[EMAIL_READ]).await?; + let data = client + .get_mailbox(&args.mailbox_id) + .await + .map_err(client_err)?; + Ok(CommandResult::new(data)) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_a_read_tier_command_scoped_to_email_read() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Read)); + assert_eq!(spec.metadata().scopes, vec![EMAIL_READ.to_string()]); + } +} diff --git a/rust/src/email/list.rs b/rust/src/email/list.rs new file mode 100644 index 00000000..173611dd --- /dev/null +++ b/rust/src/email/list.rs @@ -0,0 +1,63 @@ +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; + +use crate::email::{client_err, make_client}; +use crate::next_action::next_action; +use crate::scopes::EMAIL_READ; + +#[derive(Debug, Clone, clap::Args)] +struct ListArgs { + #[arg(long, value_name = "STATUS")] + status: Option, + #[arg(long, value_name = "PAGE")] + page: Option, + #[arg(long = "page-size", value_name = "PAGE_SIZE")] + page_size: Option, + #[arg(long, value_name = "FIELDS")] + fields: Option, +} + +/// Query params are forwarded 1:1 to the panel API rather than translated +/// through `PaginationConfig`'s generic `--limit`/`--offset` model — the +/// server paginates natively (`page`/`pageSize`) and returns HATEOAS `links` +/// that offset math would hide. See `docs/proposals/email-management-cli.md`. +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("list", "List your Email mailboxes") + .with_system("email") + .with_tier(Tier::Read) + .with_scopes(&[EMAIL_READ]), + |ctx, args: ListArgs| async move { + let client = make_client(&ctx, &[EMAIL_READ]).await?; + let mut query: Vec<(&str, String)> = Vec::new(); + if let Some(status) = args.status { + query.push(("status", status)); + } + if let Some(page) = args.page { + query.push(("page", page.to_string())); + } + if let Some(page_size) = args.page_size { + query.push(("pageSize", page_size.to_string())); + } + if let Some(fields) = args.fields { + query.push(("fields", fields)); + } + let data = client.list_mailboxes(&query).await.map_err(client_err)?; + Ok(CommandResult::new(data).with_next_actions(vec![ + next_action("email get ", "Get a mailbox by ID") + .with_param("mailbox-id", NextActionParam::required()), + ])) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_a_read_tier_command_scoped_to_email_read() { + let spec = command().spec; + assert_eq!(spec.tier, Some(Tier::Read)); + assert_eq!(spec.metadata().scopes, vec![EMAIL_READ.to_string()]); + } +} diff --git a/rust/src/email/mod.rs b/rust/src/email/mod.rs new file mode 100644 index 00000000..b8fa83a0 --- /dev/null +++ b/rust/src/email/mod.rs @@ -0,0 +1,75 @@ +pub mod client; +mod common; + +mod check_eligibility; +mod create; +mod get; +mod list; + +pub(crate) use common::{client_err, client_err_with_fix, make_client}; + +use cli_engine::{GroupSpec, Module, RuntimeGroupSpec, Stage}; + +pub fn module() -> Module { + Module::new("Email", |_ctx| { + RuntimeGroupSpec::new(GroupSpec::new( + "email", + "Create, list, and inspect GoDaddy Email mailboxes", + )) + .with_command(list::command()) + .with_command(get::command()) + .with_command(create::command()) + .with_command(check_eligibility::command()) + }) + .with_feature_flag("email", Stage::Beta) +} + +#[cfg(test)] +mod tests { + use cli_engine::{Cli, CliConfig, Stage}; + + #[tokio::test] + async fn email_commands_require_auth() { + const AUTH_FAILURE_EXIT: i32 = 2; + let cases: [&[&str]; 4] = [ + &["gddy", "email", "list", "--output", "json"], + &["gddy", "email", "get", "mbx-456", "--output", "json"], + &[ + "gddy", + "email", + "create", + "--email", + "someone@example.com", + "--output", + "json", + ], + &[ + "gddy", + "email", + "check-eligibility", + "--email", + "someone@example.com", + "--output", + "json", + ], + ]; + + for args in cases { + let cli = Cli::new( + CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") + .with_min_stage(Stage::Beta) + .with_default_auth_provider("godaddy") + .with_module(super::module()), + ); + let output = cli.run(args.iter().copied()).await; + assert_eq!( + output.exit_code, AUTH_FAILURE_EXIT, + "args {args:?} -> {output:?}" + ); + let json: serde_json::Value = + serde_json::from_str(&output.rendered).expect("valid json output"); + let message = json["error"]["message"].as_str().unwrap_or_default(); + assert!(message.contains("provider"), "args {args:?} -> {message:?}"); + } + } +} diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 0e3e0313..36823545 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -127,6 +127,21 @@ pub struct GddyEnvConfig { default_fn = default_account_url )] pub account_url: String, + + /// Base URL for the email (panel-v3) API. Defaults to + /// `productivity.api.godaddy.com` for prod, `productivity.api.test-godaddy.com` + /// for test, and `productivity.api.stg-godaddy.com` for stage — not the + /// generic `{env}-godaddy.com` convention, since `stage`'s real host uses + /// `stg-` and there's no dedicated OTE deployment (`ote` aliases to + /// `test`). Any other environment falls back to `api_url`. Overridable at + /// runtime via `GDDY_EMAIL_API_URL` or local config. + #[env_config( + from_toml = parse_url_from_toml, + env = "EMAIL_API_URL", + from_env = parse_url, + default_fn = default_email_api_url + )] + pub email_api_url: String, } pub fn env_prefix(name: &str) -> String { @@ -172,6 +187,26 @@ fn default_account_url(sources: &SourceChain<'_>) -> String { derive_account_url(sources.env_name().unwrap_or_default()) } +/// Compiled-in per-environment hosts for the panel-v3 email API — see the +/// doc comment on [`GddyEnvConfig::email_api_url`] for why this can't be +/// derived from `api_url` via the generic host-substitution convention. +const BUILTIN_EMAIL_API_URLS: &[(&str, &str)] = &[ + ("prod", "https://productivity.api.godaddy.com"), + ("test", "https://productivity.api.test-godaddy.com"), + ("stage", "https://productivity.api.stg-godaddy.com"), + // No dedicated OTE deployment of this API; alias to `test`. + ("ote", "https://productivity.api.test-godaddy.com"), +]; + +fn default_email_api_url(sources: &SourceChain<'_>) -> String { + let env_name = sources.env_name().unwrap_or_default(); + BUILTIN_EMAIL_API_URLS + .iter() + .find(|(name, _)| *name == env_name) + .map(|(_, url)| (*url).to_owned()) + .unwrap_or_else(|| current_api_url(sources)) +} + fn derive_account_url(env_name: &str) -> String { if env_name == "prod" { return "https://account.godaddy.com".to_owned(); @@ -653,6 +688,66 @@ auth_url = "not-a-url" assert_eq!(resolved.account_url, "https://account.override.test"); } + #[test] + fn email_api_url_resolves_known_environments() { + for (name, url) in [ + ("prod", "https://productivity.api.godaddy.com"), + ("test", "https://productivity.api.test-godaddy.com"), + ("stage", "https://productivity.api.stg-godaddy.com"), + ] { + let resolved = test_environment(name, |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.email_api_url, url, "environment {name:?}"); + } + } + + #[test] + fn email_api_url_aliases_ote_to_test() { + let resolved = test_environment("ote", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.ote-godaddy.com") + }); + assert_eq!( + resolved.email_api_url, + "https://productivity.api.test-godaddy.com" + ); + } + + #[test] + fn email_api_url_falls_back_to_api_url_for_an_unknown_environment() { + let resolved = test_environment("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.email_api_url, "https://api.example.test"); + } + + #[test] + fn email_api_url_override_is_respected() { + let resolved = test_environment("prod", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + .with("email_api_url", "https://email.override.test") + }); + assert_eq!(resolved.email_api_url, "https://email.override.test"); + } + + #[test] + fn env_var_overrides_email_api_url() { + let _g = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvGuard::set("GDDY_EMAIL_API_URL", "https://email.override.test"); + + let resolved = test_environment_with_app_id("dev", |t| { + t.with("client_id", "cid") + .with("api_url", "https://api.example.test") + }); + assert_eq!(resolved.email_api_url, "https://email.override.test"); + } + fn test_environment_with_app_id( name: &str, extend: impl FnOnce(EnvTable) -> EnvTable, diff --git a/rust/src/error.rs b/rust/src/error.rs index 7b881ab5..288c7891 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -46,12 +46,14 @@ mod fixes { /// Live `api call` 404: the requested URL/resource was not found (not a catalog miss). pub(super) const NOT_FOUND_API: &str = "Check the request path and parameters. Inspect the response body for details."; + pub(super) const NOT_FOUND_EMAIL: &str = "Use: gddy email list"; } fn not_found_fix_for(system: &str) -> &'static str { match system { "hosting" => fixes::NOT_FOUND_HOSTING, "api" => fixes::NOT_FOUND_API, + "email" => fixes::NOT_FOUND_EMAIL, // applications / unknown → platform discovery (default NOT_FOUND fix) _ => fixes::NOT_FOUND, } @@ -332,6 +334,15 @@ mod tests { api_missing.error_fix() ); + let email_missing = GddyError::from_http(404, "gone", "email"); + assert!( + email_missing + .error_fix() + .is_some_and(|f| f.contains("email list")), + "{:?}", + email_missing.error_fix() + ); + let client = GddyError::from_http(422, "bad", "applications"); assert_eq!(client.error_code(), codes::NETWORK_ERROR); assert!( diff --git a/rust/src/main.rs b/rust/src/main.rs index df23c744..f5954588 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -6,6 +6,7 @@ mod config; mod contacts; mod dns; mod domain; +mod email; mod env; mod environments; mod error; @@ -39,6 +40,7 @@ pub(crate) fn all_modules() -> Vec { api_explorer::module(), dns::module(), domain::module(), + email::module(), env::module(), hosting::module(), pat::module(), @@ -124,6 +126,13 @@ mod tests { "hosting should stay hidden at the Ga default: {}", output.rendered ); + + let output = cli.run(["gddy", "email", "--help"]).await; + assert_ne!( + output.exit_code, 0, + "email should stay hidden at the Ga default: {}", + output.rendered + ); } #[tokio::test] @@ -166,6 +175,13 @@ mod tests { "hosting should be revealed under an Experimental-min_stage environment: {}", output.rendered ); + + let output = cli.run(["gddy", "email", "--help"]).await; + assert_eq!( + output.exit_code, 0, + "email should be revealed under an Experimental-min_stage environment: {}", + output.rendered + ); } #[tokio::test] diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 6b851e03..847bd3f2 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -121,6 +121,11 @@ declare_scopes! { HOSTING_SECRETS_WRITE => "hosting.paas.secrets:write", /// Read Node.js Hosting app logs (`hosting nodejs app logs`). HOSTING_LOGS_READ => "hosting.paas.logs:read", + + /// Read mailboxes and check mailbox-creation eligibility (`email list`). + EMAIL_READ => "email.mailbox:read", + /// Create a mailbox (`email create`). + EMAIL_CREATE => "email.mailbox:create", } /// A requestable scope, its human description, and whether it is requested at @@ -220,6 +225,16 @@ pub const SCOPE_REGISTRY: &[ScopeInfo] = &[ description: "Connect GitHub and import code for your Node.js Hosting apps", default: false, }, + ScopeInfo { + scope: EMAIL_READ, + description: "Read mailboxes and check mailbox-creation eligibility", + default: false, + }, + ScopeInfo { + scope: EMAIL_CREATE, + description: "Create a mailbox", + default: false, + }, ScopeInfo { scope: OFFLINE_ACCESS, description: "Request a refresh token",