diff --git a/rust/src/email/check_eligibility.rs b/rust/src/email/check_eligibility.rs index f2c822b9..d2275005 100644 --- a/rust/src/email/check_eligibility.rs +++ b/rust/src/email/check_eligibility.rs @@ -34,34 +34,49 @@ pub(super) fn command() -> RuntimeCommandSpec { ) } -/// Always points at `email create` and prefills `--account-id` when the -/// response names an eligible account, plus a pointer at the `email-mailboxes` -/// guide for what an account ID actually is. +/// Points at `email create` (prefilling `--account-id` and any required +/// `--consent` flags) only when the response names an eligible account — +/// with no eligible account there is nothing to create. Always includes a +/// pointer at the `email` guide for what an account ID actually is. 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())); + let mut actions = Vec::new(); - if let Some(account_id) = first_eligible_account_id(data) { - create = create.with_param("account-id", NextActionParam::value(account_id)); + if let Some(account) = first_eligible_account(data) + && let Some(account_id) = account.get("accountId").and_then(Value::as_str) + { + let mut command = "email create --email --account-id ".to_owned(); + for requirement_type in requirement_types(account) { + command.push_str(&format!(" --consent {requirement_type}")); + } + actions.push( + next_action(command, "Create a mailbox for this address") + .with_param("email", NextActionParam::value(email.to_owned())) + .with_param("account-id", NextActionParam::value(account_id.to_owned())), + ); } - vec![ - create, - next_action("guide email-mailboxes", "Learn about email accounts"), - ] + actions.push(next_action("guide email", "Learn about email accounts")); + actions } // Every field here is read out of an untyped `serde_json::Value`, as is every // other `EmailClient` response in this module — panel-v3-api has no published // OpenAPI spec yet. Once it does, a generated typed client (mirroring // `domains_client`) would remove this class of bug; not actionable today. -fn first_eligible_account_id(data: &Value) -> Option { - data.get("eligibleAccounts")? - .as_array()? - .first()? - .get("accountId")? - .as_str() - .map(str::to_owned) +fn first_eligible_account(data: &Value) -> Option<&Value> { + data.get("eligibleAccounts")?.as_array()?.first() +} + +fn requirement_types(account: &Value) -> Vec<&str> { + account + .get("requirements") + .and_then(Value::as_array) + .map(|reqs| { + reqs.iter() + .filter_map(|r| r.get("type").and_then(Value::as_str)) + .collect() + }) + .unwrap_or_default() } #[cfg(test)] @@ -85,16 +100,41 @@ mod tests { }); let actions = eligibility_next_actions("someone@example.com", &data); assert_eq!(actions.len(), 2); - assert_eq!(actions[0].command, "gddy email create"); - assert_eq!(actions[1].command, "gddy guide email-mailboxes"); + assert_eq!( + actions[0].command, + "gddy email create --email --account-id " + ); + assert_eq!(actions[1].command, "gddy guide email"); } #[test] - fn next_actions_still_point_at_create_when_no_eligible_accounts() { - let data = json!({ "isEligible": false, "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"] }); + fn next_actions_omit_create_when_no_eligible_accounts() { + let data = json!({ + "isEligible": false, + "ineligibilityReasons": [ + { "type": "NO_ELIGIBLE_ACCOUNT", "message": "No eligible account was found." } + ] + }); + let actions = eligibility_next_actions("someone@example.com", &data); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].command, "gddy guide email"); + } + + #[test] + fn next_actions_include_consent_flags_for_outstanding_requirements() { + let data = json!({ + "isEligible": true, + "eligibleAccounts": [{ + "accountId": "acct-1", + "requirements": [{ "type": "FREETRIAL_AUTORENEW" }] + }] + }); let actions = eligibility_next_actions("someone@example.com", &data); assert_eq!(actions.len(), 2); - assert_eq!(actions[0].command, "gddy email create"); - assert_eq!(actions[1].command, "gddy guide email-mailboxes"); + assert_eq!( + actions[0].command, + "gddy email create --email --account-id --consent FREETRIAL_AUTORENEW" + ); + assert_eq!(actions[1].command, "gddy guide email"); } } diff --git a/rust/src/email/client.rs b/rust/src/email/client.rs index 77c0353e..e75ad7d7 100644 --- a/rust/src/email/client.rs +++ b/rust/src/email/client.rs @@ -52,6 +52,7 @@ impl EmailClient { method: Method, path: &str, query: &[(&str, String)], + headers: &[(&str, String)], body: Option, ) -> Result { let mut req = self @@ -62,6 +63,9 @@ impl EmailClient { for (key, value) in query { req = req.query(&[(key, value)]); } + for (key, value) in headers { + req = req.header(*key, value); + } if let Some(body) = body { req = req.json(&body); } @@ -97,24 +101,39 @@ impl EmailClient { } pub async fn list_mailboxes(&self, query: &[(&str, String)]) -> Result { - self.send_json(Method::GET, "/mailboxes", query, None).await + 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 + self.send_json( + Method::GET, + &format!("/mailboxes/{mailbox_id}"), + &[], + &[], + None, + ) + .await } pub async fn create_mailbox(&self, body: Value) -> Result { - self.send_json(Method::POST, "/mailboxes", &[], Some(body)) - .await + let idempotency_key = uuid::Uuid::new_v4().to_string(); + self.send_json( + Method::POST, + "/mailboxes", + &[], + &[("Idempotency-Key", idempotency_key)], + Some(body), + ) + .await } pub async fn check_eligibility(&self, email: &str) -> Result { self.send_json( Method::GET, - "/check-eligibility", + "/check-mailbox-eligibility", &[("email", email.to_owned())], + &[], None, ) .await @@ -161,10 +180,10 @@ mod tests { let mock = server .mock_async(|when, then| { when.method(GET) - .path("/v1/email/mailbox/mbx-456") + .path("/v1/email/mailboxes/mbx-456") .header("authorization", "Bearer test-token"); then.status(200) - .json_body(json!({ "mailboxId": "mbx-456", "status": "ACTIVE" })); + .json_body(json!({ "mailboxId": "mbx-456", "status": "CONFIRMED" })); }) .await; @@ -185,19 +204,19 @@ mod tests { when.method(POST) .path("/v1/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" })); + .json_body(json!({ "emailAddress": "someone@example.com" })); + then.status(202) + .json_body(json!({ "mailboxId": "mbx-456", "status": "EXECUTING" })); }) .await; let body = client(&server.base_url()) - .create_mailbox(json!({ "email": "someone@example.com" })) + .create_mailbox(json!({ "emailAddress": "someone@example.com" })) .await .expect("create mailbox"); mock.assert_async().await; - assert_eq!(body["status"], "PROVISIONING"); + assert_eq!(body["status"], "EXECUTING"); } #[tokio::test] @@ -206,7 +225,7 @@ mod tests { let mock = server .mock_async(|when, then| { when.method(GET) - .path("/v1/email/check-eligibility") + .path("/v1/email/check-mailbox-eligibility") .header("authorization", "Bearer test-token") .query_param("email", "someone@example.com"); then.status(200).json_body(json!({ "isEligible": true })); @@ -222,6 +241,27 @@ mod tests { assert_eq!(body["isEligible"], true); } + #[tokio::test] + async fn create_mailbox_sends_an_idempotency_key_header() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/email/mailboxes") + .header_exists("idempotency-key"); + then.status(202) + .json_body(json!({ "mailboxId": "mbx-456", "status": "EXECUTING" })); + }) + .await; + + client(&server.base_url()) + .create_mailbox(json!({ "emailAddress": "someone@example.com" })) + .await + .expect("create mailbox"); + + mock.assert_async().await; + } + #[tokio::test] async fn create_mailbox_surfaces_business_rule_error_body() { let server = MockServer::start_async().await; @@ -238,7 +278,7 @@ mod tests { .await; let err = client(&server.base_url()) - .create_mailbox(json!({ "email": "someone@example.com" })) + .create_mailbox(json!({ "emailAddress": "someone@example.com" })) .await .expect_err("business-rule failure should surface as an error"); diff --git a/rust/src/email/create.rs b/rust/src/email/create.rs index 8af67030..cb257a4e 100644 --- a/rust/src/email/create.rs +++ b/rust/src/email/create.rs @@ -1,8 +1,11 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use cli_engine::{ + CommandResult, CommandSpec, NextAction, NextActionParam, 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::next_action::next_action; use crate::scopes::EMAIL_CREATE; #[derive(Debug, Clone, clap::Args)] @@ -12,7 +15,7 @@ struct CreateArgs { email: String, /// ID of an existing eligible account to provision this mailbox under, /// from `check-eligibility`'s `eligibleAccounts[].accountId` (see - /// `gddy guide email-mailboxes`). Not a shopper/customer ID. + /// `gddy guide email`). Not a shopper/customer ID. #[arg(long = "account-id", value_name = "ACCOUNT_ID")] account_id: Option, /// First name of the mailbox owner. @@ -21,15 +24,16 @@ struct CreateArgs { /// Last name of the mailbox owner. #[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")] + /// Requirement types the caller has obtained consent for (from + /// `check-eligibility`'s `eligibleAccounts[].requirements[].type`), e.g. + /// `FREETRIAL_AUTORENEW`. Repeatable: `--consent FREETRIAL_AUTORENEW`. + #[arg(long, value_name = "REQUIREMENT_TYPE")] consent: Vec, } fn request_body(args: &CreateArgs) -> Value { let mut body = serde_json::Map::new(); - body.insert("email".to_owned(), json!(args.email)); + body.insert("emailAddress".to_owned(), json!(args.email)); if let Some(account_id) = &args.account_id { body.insert("accountId".to_owned(), json!(account_id)); } @@ -40,11 +44,25 @@ fn request_body(args: &CreateArgs) -> Value { body.insert("lastName".to_owned(), json!(last_name)); } if !args.consent.is_empty() { - body.insert("consents".to_owned(), json!(args.consent)); + let consents: Vec = args.consent.iter().map(|t| json!({ "type": t })).collect(); + body.insert("consents".to_owned(), json!(consents)); } Value::Object(body) } +fn create_next_actions(data: &Value) -> Vec { + let Some(mailbox_id) = data.get("mailboxId").and_then(Value::as_str) else { + return Vec::new(); + }; + vec![ + next_action( + "email get ", + "Poll the mailbox until its status reaches COMPLETED", + ) + .with_param("mailbox-id", NextActionParam::value(mailbox_id.to_owned())), + ] +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("create", "Create a new Email mailbox") @@ -68,7 +86,8 @@ pub(super) fn command() -> RuntimeCommandSpec { } _ => client_err(e), })?; - Ok(CommandResult::new(data)) + let next_actions = create_next_actions(&data); + Ok(CommandResult::new(data).with_next_actions(next_actions)) }, ) } @@ -95,9 +114,27 @@ mod tests { consent: vec!["EMAIL_TOS".to_owned()], }; let body = request_body(&args); - assert_eq!(body["email"], "someone@example.com"); + assert_eq!(body["emailAddress"], "someone@example.com"); assert_eq!(body["accountId"], "acct-1"); assert!(body.get("firstName").is_none()); - assert_eq!(body["consents"], json!(["EMAIL_TOS"])); + assert_eq!(body["consents"], json!([{ "type": "EMAIL_TOS" }])); + } + + #[test] + fn create_next_actions_points_at_get_when_mailbox_id_present() { + let data = json!({ "mailboxId": "mb-1", "status": "EXECUTING" }); + let actions = create_next_actions(&data); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].command, "gddy email get "); + assert_eq!( + actions[0].params["mailbox-id"].value, + Some("mb-1".to_owned()) + ); + } + + #[test] + fn create_next_actions_empty_when_mailbox_id_absent() { + let data = json!({ "status": "EXECUTING" }); + assert!(create_next_actions(&data).is_empty()); } } diff --git a/rust/src/email/guides/email-mailboxes.md b/rust/src/email/guides/email-create.md similarity index 70% rename from rust/src/email/guides/email-mailboxes.md rename to rust/src/email/guides/email-create.md index f7681569..97729fae 100644 --- a/rust/src/email/guides/email-mailboxes.md +++ b/rust/src/email/guides/email-create.md @@ -2,9 +2,9 @@ summary: How GoDaddy Email mailboxes, accounts, and consent fit together --- -# GoDaddy Email mailboxes +# GoDaddy Email -This guide explains the GoDaddy email system and how to use the `gddy email` commands to manage it. +This guide explains the GoDaddy email system and how to use the `gddy email` commands to check eligibility for, create, and manage mailboxes. ## What an "account" is here @@ -32,11 +32,19 @@ accepted first): ```json { "isEligible": false, - "ineligibleReasons": ["NO_ELIGIBLE_ACCOUNT"], + "ineligibilityReasons": [ + { "type": "NO_ELIGIBLE_ACCOUNT", "message": "No eligible account was found." } + ], "eligibleAccounts": [ { "accountId": "acct-123", - "requirements": [{ "agreementType": "EMAIL_TOS", "url": "https://..." }] + "requirements": [ + { + "type": "FREETRIAL_AUTORENEW", + "title": "Email auto renew", + "reference": "By continuing, you agree this mailbox will auto-renew..." + } + ] } ] } @@ -48,11 +56,12 @@ into `create`: ``` gddy email create --email someone@example.com \ --account-id acct-123 \ - --consent EMAIL_TOS + --consent FREETRIAL_AUTORENEW ``` -`--consent` is repeatable — pass one per required `agreementType`. If -`create` fails with a `400`/`422` about missing agreements or no eligible +`--consent` is repeatable — pass one per required requirement `type`. +`FREETRIAL_AUTORENEW` is currently the only requirement type the API issues. +If `create` fails with a `400`/`422` about missing agreements or no eligible account, re-run `check-eligibility` to see the current requirements. ## Command reference @@ -61,7 +70,7 @@ account, re-run `check-eligibility` to see the current requirements. any) can receive a new mailbox for this address, and what consent is outstanding. - `gddy email create --email [--account-id] [--first-name] - [--last-name] [--consent ]...` — provision a mailbox. + [--last-name] [--consent ]...` — provision a mailbox. - `gddy email list [--status] [--fields] [--limit] [--offset]` — list your mailboxes. - `gddy email get ` — look up one mailbox by ID. diff --git a/rust/src/email/list.rs b/rust/src/email/list.rs index 63a1581b..452f93f8 100644 --- a/rust/src/email/list.rs +++ b/rust/src/email/list.rs @@ -90,12 +90,12 @@ async fn fetch_mailboxes( query.push(("status", status.to_owned())); } if let Some(fields) = fields { - query.push(("fields", fields.to_owned())); + query.push(("field", fields.to_owned())); } let data = client.list_mailboxes(&query).await?; let page_items = data - .get("mailboxes") + .get("items") .and_then(Value::as_array) .cloned() .unwrap_or_default(); @@ -156,7 +156,7 @@ mod tests { .path("/v1/email/mailboxes") .query_param("page", "1"); then.status(200) - .json_body(json!({ "mailboxes": page_of(3, 0) })); + .json_body(json!({ "items": page_of(3, 0) })); }) .await; @@ -178,7 +178,7 @@ mod tests { .path("/v1/email/mailboxes") .query_param("page", "1"); then.status(200) - .json_body(json!({ "mailboxes": page_of(SERVER_PAGE_SIZE_CAP, 0) })); + .json_body(json!({ "items": page_of(SERVER_PAGE_SIZE_CAP, 0) })); }) .await; let page2 = server @@ -187,7 +187,7 @@ mod tests { .path("/v1/email/mailboxes") .query_param("page", "2"); then.status(200) - .json_body(json!({ "mailboxes": page_of(10, SERVER_PAGE_SIZE_CAP) })); + .json_body(json!({ "items": page_of(10, SERVER_PAGE_SIZE_CAP) })); }) .await; @@ -210,7 +210,7 @@ mod tests { .path("/v1/email/mailboxes") .query_param("page", "1"); then.status(200) - .json_body(json!({ "mailboxes": page_of(SERVER_PAGE_SIZE_CAP, 0) })); + .json_body(json!({ "items": page_of(SERVER_PAGE_SIZE_CAP, 0) })); }) .await; let page2 = server @@ -219,7 +219,7 @@ mod tests { .path("/v1/email/mailboxes") .query_param("page", "2"); then.status(200) - .json_body(json!({ "mailboxes": page_of(5, SERVER_PAGE_SIZE_CAP) })); + .json_body(json!({ "items": page_of(5, SERVER_PAGE_SIZE_CAP) })); }) .await; @@ -241,9 +241,9 @@ mod tests { when.method(GET) .path("/v1/email/mailboxes") .query_param("status", "ACTIVE") - .query_param("fields", "mailboxId,status"); + .query_param("field", "mailboxId,status"); then.status(200) - .json_body(json!({ "mailboxes": page_of(1, 0) })); + .json_body(json!({ "items": page_of(1, 0) })); }) .await; diff --git a/rust/src/email/mod.rs b/rust/src/email/mod.rs index fb5a49bb..b0c3d182 100644 --- a/rust/src/email/mod.rs +++ b/rust/src/email/mod.rs @@ -14,14 +14,18 @@ pub fn module() -> Module { Module::new("Email", |_ctx| { RuntimeGroupSpec::new( GroupSpec::new("email", "Create, list, and inspect GoDaddy Email mailboxes").with_long( - "Manage GoDaddy Email mailboxes over panel-v3.\n\ + "Manage GoDaddy Business Emails.\n\ \n\ • check-eligibility — see which account(s) an address can be created\n\ \x20 under, and what consent is outstanding\n\ • create — provision a mailbox\n\ • list / get — your existing mailboxes and their details\n\ \n\ - See `gddy guide email-mailboxes` for what an account ID is and how the\n\ + check-eligibility/list/get need the `email.mailbox:read` scope; create needs\n\ + `email.mailbox:create`. Run `gddy auth scopes --command \"email \"`\n\ + for details, or `gddy auth login --scope ` to step up.\n\ + \n\ + See `gddy guide email` for what an account ID is and how the\n\ check-eligibility → create flow works.", ), ) @@ -32,8 +36,8 @@ pub fn module() -> Module { }) .with_feature_flag("email", Stage::Beta) .with_guides_from_markdown([( - "email-mailboxes.md", - include_bytes!("guides/email-mailboxes.md").as_slice(), + "email.md", + include_bytes!("guides/email-create.md").as_slice(), )]) } diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 3aca2895..847bd3f2 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -125,7 +125,7 @@ declare_scopes! { /// Read mailboxes and check mailbox-creation eligibility (`email list`). EMAIL_READ => "email.mailbox:read", /// Create a mailbox (`email create`). - EMAIL_CREATE => "email.mailbox:write", + EMAIL_CREATE => "email.mailbox:create", } /// A requestable scope, its human description, and whether it is requested at