1use anyhow::{bail, ensure, Context, Result};
12use serde::Deserialize;
13
14use crate::gmail::client::GmailClient;
15use crate::gmail::compose::Mailbox;
16
17const PENDING: &str = "pending";
20
21#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
23pub struct SendAs {
24 #[serde(rename = "sendAsEmail")]
26 pub send_as_email: String,
27 #[serde(rename = "displayName", default)]
31 pub display_name: Option<String>,
32 #[serde(rename = "isPrimary", default)]
34 pub is_primary: bool,
35 #[serde(rename = "verificationStatus", default)]
38 pub verification_status: Option<String>,
39}
40
41impl SendAs {
42 fn is_usable(&self) -> bool {
45 self.verification_status.as_deref() != Some(PENDING)
46 }
47
48 fn name(&self) -> Option<&str> {
50 self.display_name
51 .as_deref()
52 .filter(|name| !name.trim().is_empty())
53 }
54}
55
56pub fn resolve_from(send_as: &[SendAs], from: &Mailbox) -> Result<Option<Mailbox>> {
71 let Some(alias) = send_as
72 .iter()
73 .find(|alias| alias.send_as_email.eq_ignore_ascii_case(&from.email))
74 else {
75 let usable: Vec<&str> = send_as
76 .iter()
77 .filter(|alias| alias.is_usable())
78 .map(|alias| alias.send_as_email.as_str())
79 .collect();
80 bail!(
81 "{} is not one of this account's send-as addresses (usable: {}); add it under \
82 Gmail's Settings > Accounts > Send mail as, or use one of those",
83 from.email,
84 if usable.is_empty() {
85 "none listed".to_string()
86 } else {
87 usable.join(", ")
88 }
89 );
90 };
91 ensure!(
92 alias.is_usable(),
93 "send-as address {} is awaiting verification; confirm it from the verification email \
94 Gmail sent, then retry",
95 alias.send_as_email
96 );
97 if let Some(name) = from.name.as_deref() {
98 return Mailbox::from_parts(Some(name), &alias.send_as_email).map(Some);
99 }
100 let primary_name = send_as
101 .iter()
102 .find(|alias| alias.is_primary)
103 .and_then(SendAs::name);
104 match alias.name().or(primary_name) {
105 Some(name) => Mailbox::from_parts(Some(name), &alias.send_as_email)
106 .with_context(|| {
107 format!(
108 "Gmail's display name for {} can't be used in a header; pass the name \
109 yourself, as --from 'Name <{}>'",
110 alias.send_as_email, alias.send_as_email
111 )
112 })
113 .map(Some),
114 None if alias.is_primary => Ok(None),
115 None => Mailbox::from_parts(None, &alias.send_as_email).map(Some),
116 }
117}
118
119#[derive(Debug, Deserialize)]
121struct SendAsList {
122 #[serde(rename = "sendAs", default)]
123 send_as: Vec<SendAs>,
124}
125
126#[derive(Debug)]
128pub struct SendAsApi<'a> {
129 client: &'a GmailClient,
130}
131
132impl<'a> SendAsApi<'a> {
133 #[must_use]
135 pub fn new(client: &'a GmailClient) -> Self {
136 Self { client }
137 }
138
139 pub async fn list(&self) -> Result<Vec<SendAs>> {
141 let url =
142 GmailClient::api_url(self.client.base_url(), "/gmail/v1/users/me/settings/sendAs")?;
143 let list: SendAsList = self
144 .client
145 .get_parsed(
146 url.as_str(),
147 "Failed to parse users.settings.sendAs.list response",
148 )
149 .await?;
150 Ok(list.send_as)
151 }
152}
153
154#[cfg(test)]
155#[allow(clippy::unwrap_used, clippy::expect_used)]
156mod tests {
157 use super::*;
158 use crate::gmail::auth::{GmailCredentials, GmailScope};
159 use crate::utils::secret::Secret;
160
161 const SEND_AS_PATH: &str = "/gmail/v1/users/me/settings/sendAs";
162
163 fn test_credentials() -> GmailCredentials {
164 GmailCredentials {
165 client_id: "client-1".to_string(),
166 client_secret: Secret::new("secret-1"),
167 refresh_token: Secret::new("refresh-1"),
168 scope: GmailScope::ReadOnly,
169 }
170 }
171
172 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> GmailClient {
173 wiremock::Mock::given(wiremock::matchers::method("POST"))
174 .and(wiremock::matchers::path("/token"))
175 .respond_with(
176 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
177 "access_token": "test-token",
178 "expires_in": 3600,
179 })),
180 )
181 .mount(server)
182 .await;
183
184 let mut client = GmailClient::new(&server.uri(), &test_credentials()).unwrap();
185 crate::gmail::client::test_support::replace_session(
186 &mut client,
187 &test_credentials(),
188 &format!("{}/token", server.uri()),
189 );
190 client
191 }
192
193 #[tokio::test]
194 async fn list_parses_the_primary_address_and_aliases() {
195 let server = wiremock::MockServer::start().await;
196 let client = client_with_bootstrapped_token(&server).await;
197 wiremock::Mock::given(wiremock::matchers::method("GET"))
198 .and(wiremock::matchers::path(SEND_AS_PATH))
199 .respond_with(
200 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
201 "sendAs": [
202 {
203 "sendAsEmail": "me@example.com",
204 "displayName": "Me",
205 "isPrimary": true,
206 "isDefault": true,
207 },
208 {
209 "sendAsEmail": "alias@example.org",
210 "verificationStatus": "accepted",
211 "treatAsAlias": true,
212 "smtpMsa": { "host": "smtp.example.org" },
213 },
214 ],
215 })),
216 )
217 .expect(1)
218 .mount(&server)
219 .await;
220
221 let list = SendAsApi::new(&client).list().await.unwrap();
222 assert_eq!(
223 list,
224 [
225 SendAs {
226 send_as_email: "me@example.com".to_string(),
227 display_name: Some("Me".to_string()),
228 is_primary: true,
229 verification_status: None,
230 },
231 SendAs {
232 send_as_email: "alias@example.org".to_string(),
233 display_name: None,
234 is_primary: false,
235 verification_status: Some("accepted".to_string()),
236 },
237 ]
238 );
239 }
240
241 #[tokio::test]
242 async fn list_treats_a_missing_array_as_empty() {
243 let server = wiremock::MockServer::start().await;
244 let client = client_with_bootstrapped_token(&server).await;
245 wiremock::Mock::given(wiremock::matchers::method("GET"))
246 .and(wiremock::matchers::path(SEND_AS_PATH))
247 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
248 .mount(&server)
249 .await;
250
251 assert!(SendAsApi::new(&client).list().await.unwrap().is_empty());
252 }
253
254 #[tokio::test]
255 async fn list_propagates_api_errors() {
256 let server = wiremock::MockServer::start().await;
257 let client = client_with_bootstrapped_token(&server).await;
258 wiremock::Mock::given(wiremock::matchers::method("GET"))
259 .and(wiremock::matchers::path(SEND_AS_PATH))
260 .respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
261 .mount(&server)
262 .await;
263
264 let err = SendAsApi::new(&client).list().await.unwrap_err();
265 assert!(err.to_string().contains("403"), "{err}");
266 }
267
268 fn alias(email: &str, name: Option<&str>, status: Option<&str>) -> SendAs {
271 SendAs {
272 send_as_email: email.to_string(),
273 display_name: name.map(str::to_string),
274 is_primary: false,
275 verification_status: status.map(str::to_string),
276 }
277 }
278
279 fn primary(name: Option<&str>) -> SendAs {
280 SendAs {
281 is_primary: true,
282 ..alias("me@example.com", name, None)
283 }
284 }
285
286 fn account(primary_name: Option<&str>) -> Vec<SendAs> {
287 vec![
288 primary(primary_name),
289 alias("Sales@Example.org", Some("Sales Team"), Some("accepted")),
290 alias("bare@example.org", Some(" "), Some("accepted")),
291 alias("new@example.org", None, Some("pending")),
292 alias(
293 "managed@example.org",
294 None,
295 Some("verificationStatusUnspecified"),
296 ),
297 ]
298 }
299
300 fn resolve_in(send_as: &[SendAs], input: &str) -> Result<Option<Mailbox>> {
301 resolve_from(send_as, &Mailbox::parse(input).unwrap())
302 }
303
304 fn resolved(primary_name: Option<&str>, input: &str) -> Option<Mailbox> {
305 resolve_in(&account(primary_name), input).unwrap()
306 }
307
308 fn mailbox(input: &str) -> Mailbox {
309 Mailbox::parse(input).unwrap()
310 }
311
312 #[test]
313 fn resolve_from_matches_case_insensitively_and_writes_the_stored_spelling() {
314 assert_eq!(
315 resolved(None, "sales@example.ORG"),
316 Some(mailbox("Sales Team <Sales@Example.org>"))
317 );
318 }
319
320 #[test]
321 fn resolve_from_keeps_an_explicit_name() {
322 assert_eq!(
323 resolved(Some("Me"), "Zoë <sales@example.org>"),
324 Some(mailbox("Zoë <Sales@Example.org>"))
325 );
326 assert_eq!(
327 resolved(None, "Me Myself <me@example.com>"),
328 Some(mailbox("Me Myself <me@example.com>"))
329 );
330 }
331
332 #[test]
333 fn resolve_from_gives_a_nameless_alias_the_primary_name_as_gmail_does() {
334 assert_eq!(
335 resolved(Some("Me Primary"), "bare@example.org"),
336 Some(mailbox("Me Primary <bare@example.org>"))
337 );
338 assert_eq!(
339 resolved(None, "bare@example.org"),
340 Some(mailbox("bare@example.org"))
341 );
342 }
343
344 #[test]
345 fn resolve_from_names_the_primary_address_or_leaves_it_to_gmail() {
346 assert_eq!(
347 resolved(Some("Me Primary"), "me@example.com"),
348 Some(mailbox("Me Primary <me@example.com>"))
349 );
350 assert_eq!(resolved(None, "me@example.com"), None);
353 assert_eq!(resolved(Some(" "), "ME@example.com"), None);
354 }
355
356 #[test]
357 fn resolve_from_refuses_an_unknown_address_and_lists_the_usable_ones() {
358 let err = resolve_in(&account(None), "stranger@example.net")
359 .unwrap_err()
360 .to_string();
361 assert!(err.contains("stranger@example.net"), "{err}");
362 assert!(
363 err.contains(
364 "usable: me@example.com, Sales@Example.org, bare@example.org, managed@example.org)"
365 ),
366 "{err}"
367 );
368 let err = resolve_in(&[], "a@example.com").unwrap_err().to_string();
369 assert!(err.contains("none listed"), "{err}");
370 }
371
372 #[test]
373 fn resolve_from_refuses_only_an_alias_awaiting_verification() {
374 let err = resolve_in(&account(None), "new@example.org")
375 .unwrap_err()
376 .to_string();
377 assert!(err.contains("awaiting verification"), "{err}");
378 assert_eq!(
379 resolved(None, "managed@example.org"),
380 Some(mailbox("managed@example.org"))
381 );
382 }
383
384 #[test]
385 fn resolve_from_blames_a_bad_display_name_from_the_api() {
386 let send_as = [alias(
387 "x@example.org",
388 Some("Evil\r\nBcc: e@example.com"),
389 None,
390 )];
391 let err = format!("{:#}", resolve_in(&send_as, "x@example.org").unwrap_err());
392 assert!(
393 err.contains("Gmail's display name for x@example.org"),
394 "{err}"
395 );
396 assert!(err.contains("control characters"), "{err}");
397 assert_eq!(
399 resolve_in(&send_as, "X <x@example.org>").unwrap(),
400 Some(mailbox("X <x@example.org>"))
401 );
402 }
403}