use anyhow::{bail, ensure, Context, Result};
use serde::Deserialize;
use crate::gmail::client::GmailClient;
use crate::gmail::compose::Mailbox;
const PENDING: &str = "pending";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct SendAs {
#[serde(rename = "sendAsEmail")]
pub send_as_email: String,
#[serde(rename = "displayName", default)]
pub display_name: Option<String>,
#[serde(rename = "isPrimary", default)]
pub is_primary: bool,
#[serde(rename = "verificationStatus", default)]
pub verification_status: Option<String>,
}
impl SendAs {
fn is_usable(&self) -> bool {
self.verification_status.as_deref() != Some(PENDING)
}
fn name(&self) -> Option<&str> {
self.display_name
.as_deref()
.filter(|name| !name.trim().is_empty())
}
}
pub fn resolve_from(send_as: &[SendAs], from: &Mailbox) -> Result<Option<Mailbox>> {
let Some(alias) = send_as
.iter()
.find(|alias| alias.send_as_email.eq_ignore_ascii_case(&from.email))
else {
let usable: Vec<&str> = send_as
.iter()
.filter(|alias| alias.is_usable())
.map(|alias| alias.send_as_email.as_str())
.collect();
bail!(
"{} is not one of this account's send-as addresses (usable: {}); add it under \
Gmail's Settings > Accounts > Send mail as, or use one of those",
from.email,
if usable.is_empty() {
"none listed".to_string()
} else {
usable.join(", ")
}
);
};
ensure!(
alias.is_usable(),
"send-as address {} is awaiting verification; confirm it from the verification email \
Gmail sent, then retry",
alias.send_as_email
);
if let Some(name) = from.name.as_deref() {
return Mailbox::from_parts(Some(name), &alias.send_as_email).map(Some);
}
let primary_name = send_as
.iter()
.find(|alias| alias.is_primary)
.and_then(SendAs::name);
match alias.name().or(primary_name) {
Some(name) => Mailbox::from_parts(Some(name), &alias.send_as_email)
.with_context(|| {
format!(
"Gmail's display name for {} can't be used in a header; pass the name \
yourself, as --from 'Name <{}>'",
alias.send_as_email, alias.send_as_email
)
})
.map(Some),
None if alias.is_primary => Ok(None),
None => Mailbox::from_parts(None, &alias.send_as_email).map(Some),
}
}
#[derive(Debug, Deserialize)]
struct SendAsList {
#[serde(rename = "sendAs", default)]
send_as: Vec<SendAs>,
}
#[derive(Debug)]
pub struct SendAsApi<'a> {
client: &'a GmailClient,
}
impl<'a> SendAsApi<'a> {
#[must_use]
pub fn new(client: &'a GmailClient) -> Self {
Self { client }
}
pub async fn list(&self) -> Result<Vec<SendAs>> {
let url =
GmailClient::api_url(self.client.base_url(), "/gmail/v1/users/me/settings/sendAs")?;
let list: SendAsList = self
.client
.get_parsed(
url.as_str(),
"Failed to parse users.settings.sendAs.list response",
)
.await?;
Ok(list.send_as)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::gmail::auth::{GmailCredentials, GmailScope};
use crate::utils::secret::Secret;
const SEND_AS_PATH: &str = "/gmail/v1/users/me/settings/sendAs";
fn test_credentials() -> GmailCredentials {
GmailCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: GmailScope::ReadOnly,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
crate::gmail::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
#[tokio::test]
async fn list_parses_the_primary_address_and_aliases() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SEND_AS_PATH))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"sendAs": [
{
"sendAsEmail": "me@example.com",
"displayName": "Me",
"isPrimary": true,
"isDefault": true,
},
{
"sendAsEmail": "alias@example.org",
"verificationStatus": "accepted",
"treatAsAlias": true,
"smtpMsa": { "host": "smtp.example.org" },
},
],
})),
)
.expect(1)
.mount(&server)
.await;
let list = SendAsApi::new(&client).list().await.unwrap();
assert_eq!(
list,
[
SendAs {
send_as_email: "me@example.com".to_string(),
display_name: Some("Me".to_string()),
is_primary: true,
verification_status: None,
},
SendAs {
send_as_email: "alias@example.org".to_string(),
display_name: None,
is_primary: false,
verification_status: Some("accepted".to_string()),
},
]
);
}
#[tokio::test]
async fn list_treats_a_missing_array_as_empty() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SEND_AS_PATH))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
assert!(SendAsApi::new(&client).list().await.unwrap().is_empty());
}
#[tokio::test]
async fn list_propagates_api_errors() {
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(SEND_AS_PATH))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let err = SendAsApi::new(&client).list().await.unwrap_err();
assert!(err.to_string().contains("403"), "{err}");
}
fn alias(email: &str, name: Option<&str>, status: Option<&str>) -> SendAs {
SendAs {
send_as_email: email.to_string(),
display_name: name.map(str::to_string),
is_primary: false,
verification_status: status.map(str::to_string),
}
}
fn primary(name: Option<&str>) -> SendAs {
SendAs {
is_primary: true,
..alias("me@example.com", name, None)
}
}
fn account(primary_name: Option<&str>) -> Vec<SendAs> {
vec![
primary(primary_name),
alias("Sales@Example.org", Some("Sales Team"), Some("accepted")),
alias("bare@example.org", Some(" "), Some("accepted")),
alias("new@example.org", None, Some("pending")),
alias(
"managed@example.org",
None,
Some("verificationStatusUnspecified"),
),
]
}
fn resolve_in(send_as: &[SendAs], input: &str) -> Result<Option<Mailbox>> {
resolve_from(send_as, &Mailbox::parse(input).unwrap())
}
fn resolved(primary_name: Option<&str>, input: &str) -> Option<Mailbox> {
resolve_in(&account(primary_name), input).unwrap()
}
fn mailbox(input: &str) -> Mailbox {
Mailbox::parse(input).unwrap()
}
#[test]
fn resolve_from_matches_case_insensitively_and_writes_the_stored_spelling() {
assert_eq!(
resolved(None, "sales@example.ORG"),
Some(mailbox("Sales Team <Sales@Example.org>"))
);
}
#[test]
fn resolve_from_keeps_an_explicit_name() {
assert_eq!(
resolved(Some("Me"), "Zoë <sales@example.org>"),
Some(mailbox("Zoë <Sales@Example.org>"))
);
assert_eq!(
resolved(None, "Me Myself <me@example.com>"),
Some(mailbox("Me Myself <me@example.com>"))
);
}
#[test]
fn resolve_from_gives_a_nameless_alias_the_primary_name_as_gmail_does() {
assert_eq!(
resolved(Some("Me Primary"), "bare@example.org"),
Some(mailbox("Me Primary <bare@example.org>"))
);
assert_eq!(
resolved(None, "bare@example.org"),
Some(mailbox("bare@example.org"))
);
}
#[test]
fn resolve_from_names_the_primary_address_or_leaves_it_to_gmail() {
assert_eq!(
resolved(Some("Me Primary"), "me@example.com"),
Some(mailbox("Me Primary <me@example.com>"))
);
assert_eq!(resolved(None, "me@example.com"), None);
assert_eq!(resolved(Some(" "), "ME@example.com"), None);
}
#[test]
fn resolve_from_refuses_an_unknown_address_and_lists_the_usable_ones() {
let err = resolve_in(&account(None), "stranger@example.net")
.unwrap_err()
.to_string();
assert!(err.contains("stranger@example.net"), "{err}");
assert!(
err.contains(
"usable: me@example.com, Sales@Example.org, bare@example.org, managed@example.org)"
),
"{err}"
);
let err = resolve_in(&[], "a@example.com").unwrap_err().to_string();
assert!(err.contains("none listed"), "{err}");
}
#[test]
fn resolve_from_refuses_only_an_alias_awaiting_verification() {
let err = resolve_in(&account(None), "new@example.org")
.unwrap_err()
.to_string();
assert!(err.contains("awaiting verification"), "{err}");
assert_eq!(
resolved(None, "managed@example.org"),
Some(mailbox("managed@example.org"))
);
}
#[test]
fn resolve_from_blames_a_bad_display_name_from_the_api() {
let send_as = [alias(
"x@example.org",
Some("Evil\r\nBcc: e@example.com"),
None,
)];
let err = format!("{:#}", resolve_in(&send_as, "x@example.org").unwrap_err());
assert!(
err.contains("Gmail's display name for x@example.org"),
"{err}"
);
assert!(err.contains("control characters"), "{err}");
assert_eq!(
resolve_in(&send_as, "X <x@example.org>").unwrap(),
Some(mailbox("X <x@example.org>"))
);
}
}