Skip to content
Merged
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
88 changes: 64 additions & 24 deletions rust/src/email/check_eligibility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextAction> {
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest something on your backlog (or ours?) to migrate to strong types through generating a client from the OpenAPI spec; that can help to avoid easy-to-detect type mismatches.

{
let mut command = "email create --email <email> --account-id <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<String> {
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)]
Expand All @@ -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 <email> --account-id <account-id>"
);
assert_eq!(actions[1].command, "gddy guide email");
Comment thread
sgimpel-godaddy marked this conversation as resolved.
}

#[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 <email> --account-id <account-id> --consent FREETRIAL_AUTORENEW"
);
assert_eq!(actions[1].command, "gddy guide email");
}
}
70 changes: 55 additions & 15 deletions rust/src/email/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ impl EmailClient {
method: Method,
path: &str,
query: &[(&str, String)],
headers: &[(&str, String)],
body: Option<Value>,
) -> Result<Value, ClientError> {
let mut req = self
Expand All @@ -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);
}
Expand Down Expand Up @@ -97,24 +101,39 @@ impl EmailClient {
}

pub async fn list_mailboxes(&self, query: &[(&str, String)]) -> Result<Value, ClientError> {
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<Value, ClientError> {
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<Value, ClientError> {
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<Value, ClientError> {
self.send_json(
Method::GET,
"/check-eligibility",
"/check-mailbox-eligibility",
&[("email", email.to_owned())],
&[],
None,
)
.await
Expand Down Expand Up @@ -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;

Expand All @@ -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]
Expand All @@ -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 }));
Expand All @@ -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;
Expand All @@ -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");

Expand Down
57 changes: 47 additions & 10 deletions rust/src/email/create.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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.
Comment thread
sgimpel-godaddy marked this conversation as resolved.
#[arg(long = "account-id", value_name = "ACCOUNT_ID")]
account_id: Option<String>,
/// First name of the mailbox owner.
Expand All @@ -21,15 +24,16 @@ struct CreateArgs {
/// Last name of the mailbox owner.
#[arg(long = "last-name", value_name = "LAST_NAME")]
last_name: Option<String>,
/// 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<String>,
}

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));
}
Expand All @@ -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<Value> = 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<NextAction> {
let Some(mailbox_id) = data.get("mailboxId").and_then(Value::as_str) else {
return Vec::new();
};
vec![
next_action(
"email get <mailbox-id>",
"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::<CreateArgs, _, _, _>(
CommandSpec::from_args::<CreateArgs>("create", "Create a new Email mailbox")
Expand All @@ -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))
},
)
}
Expand All @@ -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");
Comment thread
sgimpel-godaddy marked this conversation as resolved.
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 <mailbox-id>");
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());
}
}
Loading
Loading