Skip to main content

omni_dev/gmail/
send_as_api.rs

1//! Gmail send-as settings wrapper.
2//!
3//! `users.settings.sendAs.list` returns the account's primary address and
4//! every send-as alias in one unpaginated call (1 quota unit, allowed by
5//! `gmail.readonly`). `gmail draft create --reply-all` uses it to leave the
6//! account's own addresses out of a reply (#1954), and `gmail draft
7//! create`/`update --from` to check that a `From` is one Gmail will send as
8//! ([`resolve_from`], #1956). Read-only: nothing here creates, edits or
9//! verifies an alias.
10
11use anyhow::{bail, ensure, Context, Result};
12use serde::Deserialize;
13
14use crate::gmail::client::GmailClient;
15use crate::gmail::compose::Mailbox;
16
17/// The `verificationStatus` of an alias still awaiting verification, which
18/// Gmail won't send as.
19const PENDING: &str = "pending";
20
21/// One address the account can send as.
22#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
23pub struct SendAs {
24    /// The address that appears in `From`.
25    #[serde(rename = "sendAsEmail")]
26    pub send_as_email: String,
27    /// The name Gmail puts beside the address in `From`. Empty or absent
28    /// means Gmail uses the primary address's name (for the primary
29    /// address itself, the Google account's name).
30    #[serde(rename = "displayName", default)]
31    pub display_name: Option<String>,
32    /// Whether this is the account's primary address.
33    #[serde(rename = "isPrimary", default)]
34    pub is_primary: bool,
35    /// `accepted` or `pending` for a custom alias; absent for the primary
36    /// address, which needs no verification.
37    #[serde(rename = "verificationStatus", default)]
38    pub verification_status: Option<String>,
39}
40
41impl SendAs {
42    /// Whether Gmail will send as this address: anything but an alias
43    /// still awaiting verification.
44    fn is_usable(&self) -> bool {
45        self.verification_status.as_deref() != Some(PENDING)
46    }
47
48    /// The non-blank `displayName`, if any.
49    fn name(&self) -> Option<&str> {
50        self.display_name
51            .as_deref()
52            .filter(|name| !name.trim().is_empty())
53    }
54}
55
56/// Checks `from` against the account's send-as addresses and returns the
57/// mailbox to write as the draft's `From`, or `None` to leave `From` to
58/// Gmail.
59///
60/// The address matches `sendAsEmail` case-insensitively and is written in
61/// the stored spelling. An alias still awaiting verification is refused,
62/// as is an address that isn't one of `send_as`: Gmail would send from the
63/// primary address instead, or not at all.
64///
65/// Without a name in `from`, the name is the alias's non-blank
66/// `displayName`, else the primary address's, which is the name Gmail
67/// itself sends a nameless alias under. When that leaves the primary address
68/// with no name, the result is `None`: Gmail fills in the primary address
69/// and the Google account's name, which a nameless `From` would lose.
70pub 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/// The `users.settings.sendAs.list` response body.
120#[derive(Debug, Deserialize)]
121struct SendAsList {
122    #[serde(rename = "sendAs", default)]
123    send_as: Vec<SendAs>,
124}
125
126/// Send-as settings API façade.
127#[derive(Debug)]
128pub struct SendAsApi<'a> {
129    client: &'a GmailClient,
130}
131
132impl<'a> SendAsApi<'a> {
133    /// Wraps an existing [`GmailClient`] for send-as settings.
134    #[must_use]
135    pub fn new(client: &'a GmailClient) -> Self {
136        Self { client }
137    }
138
139    /// Lists the primary address and every send-as alias.
140    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    // ── resolve_from ─────────────────────────────────────────────────
269
270    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        // A nameless `From` would drop the account's name, which Gmail
351        // fills in when there is no `From` at all.
352        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        // An explicit name sidesteps it.
398        assert_eq!(
399            resolve_in(&send_as, "X <x@example.org>").unwrap(),
400            Some(mailbox("X <x@example.org>"))
401        );
402    }
403}