Skip to main content

jmap_mail_client/methods/
vacation.rs

1//! JMAP Mail — VacationResponse/get and VacationResponse/set implementations
2//! on SessionClient.
3//!
4//! VacationResponse is a singleton object per account (RFC 8621 §8). Its `id`
5//! is always `"singleton"`. `VacationResponse/get` ignores the `ids` argument
6//! and always returns the single object; `VacationResponse/set` does not
7//! support `create` or `destroy` — only `update`.
8//!
9//! Each method follows the standard five-step pattern:
10//!   1. Validate arguments (empty-string guards).
11//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
12//!   3. Build args JSON with `serde_json::json!({…})`.
13//!   4. Call `build_request(method_name, args, USING_VACATION)`.
14//!   5. Call `self.call_internal(api_url, &req).await?`.
15//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
16
17use std::collections::HashMap;
18
19use jmap_types::{Id, PatchObject};
20
21use super::{GetResponse, SetResponse};
22
23impl super::SessionClient {
24    /// Fetch the VacationResponse singleton for the account (RFC 8621 §8).
25    ///
26    /// RFC 8621 §8 declares `VacationResponse` a per-account singleton
27    /// whose `id` is always `"singleton"`; one always exists per
28    /// account, defaulting to `isEnabled: false`. This method returns
29    /// the singleton value directly, unwrapping the standard /get
30    /// envelope (`accountId`, `state`, `list`, `notFound`). Callers
31    /// that need the envelope fields (e.g. the `state` token for a
32    /// subsequent `VacationResponse/set` `ifInState` guard) should call
33    /// [`Self::vacation_response_get_envelope`] instead.
34    ///
35    /// # Errors
36    ///
37    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
38    ///   if the bound session has no primary account for
39    ///   `urn:ietf:params:jmap:mail`. (VacationResponse/* uses
40    ///   `urn:ietf:params:jmap:vacationresponse` in its `using` array
41    ///   but is keyed on the mail primary account.)
42    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
43    ///   if the server returns an empty `list` — a protocol violation
44    ///   per RFC 8621 §8 (the singleton always exists).
45    /// - Any transport / protocol variant returned by
46    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
47    ///   [`Http`](jmap_base_client::ClientError::Http),
48    ///   [`Parse`](jmap_base_client::ClientError::Parse),
49    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
50    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
51    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
52    ///   `accountNotFound`, `invalidArguments`, `serverFail`),
53    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
54    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
55    ///   or
56    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
57    pub async fn vacation_response_get(
58        &self,
59    ) -> Result<jmap_mail_types::VacationResponse, jmap_base_client::ClientError> {
60        let envelope = self.vacation_response_get_envelope().await?;
61        envelope.list.into_iter().next().ok_or_else(|| {
62            jmap_base_client::ClientError::InvalidSession(
63                "VacationResponse/get returned an empty list; RFC 8621 §8 requires \
64                 the singleton to exist for every account"
65                    .into(),
66            )
67        })
68    }
69
70    /// Fetch the VacationResponse singleton including the standard
71    /// /get envelope (`accountId`, `state`, `list`, `notFound`).
72    ///
73    /// Use this instead of [`Self::vacation_response_get`] when you
74    /// need access to the response `state` token (e.g. for a
75    /// subsequent `VacationResponse/set` `ifInState` guard) or to
76    /// distinguish the empty-`list` protocol-violation case from a
77    /// genuine missing-account error.
78    ///
79    /// `list` always contains exactly one element on a spec-conformant
80    /// server; an empty `list` indicates a server bug. See
81    /// [`Self::vacation_response_get`] for the unwrap-and-validate
82    /// shape that callers typically want.
83    ///
84    /// # Errors
85    ///
86    /// Same set as [`Self::vacation_response_get`], except this method
87    /// does NOT translate an empty `list` into
88    /// [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession).
89    pub async fn vacation_response_get_envelope(
90        &self,
91    ) -> Result<GetResponse<jmap_mail_types::VacationResponse>, jmap_base_client::ClientError> {
92        let (api_url, account_id) = self.session_parts()?;
93        let args = serde_json::json!({
94            "accountId": account_id,
95            "ids": ["singleton"],
96        });
97        let req = super::build_request("VacationResponse/get", args, super::USING_VACATION);
98        let resp = self.call_internal(api_url, &req).await?;
99        jmap_base_client::extract_response(&resp, super::CALL_ID)
100    }
101
102    /// Update the VacationResponse singleton (RFC 8621 §8).
103    ///
104    /// `update` should be a JSON object of the form:
105    /// ```json
106    /// { "singleton": { "isEnabled": true, "subject": "Out of office" } }
107    /// ```
108    ///
109    /// `create` and `destroy` are not supported by `VacationResponse/set`.
110    ///
111    /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). The
112    /// usual shape is `{"singleton": <patch>}`. Wire format is unchanged
113    /// from a plain JSON object because [`PatchObject`] is
114    /// `#[serde(transparent)]`.
115    ///
116    /// # Errors
117    ///
118    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
119    ///   if the bound session has no primary account for
120    ///   `urn:ietf:params:jmap:mail`.
121    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
122    ///   if `update` is `Some` and `serde_json::to_value` fails on the
123    ///   patch map (pathological conditions only; see
124    ///   [`Self::email_set`] for the memory-cost discussion that
125    ///   applies identically here).
126    /// - Any transport / protocol variant returned by
127    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
128    ///   the matching error list on [`Self::vacation_response_get`].
129    pub async fn vacation_response_set(
130        &self,
131        update: Option<HashMap<Id, PatchObject>>,
132    ) -> Result<SetResponse<jmap_mail_types::VacationResponse>, jmap_base_client::ClientError> {
133        let (api_url, account_id) = self.session_parts()?;
134        let mut args = serde_json::json!({
135            "accountId": account_id,
136        });
137        if let Some(u) = update {
138            args["update"] = serde_json::to_value(&u).map_err(|e| {
139                jmap_base_client::ClientError::InvalidArgument(format!(
140                    "vacation_response_set: serializing update map failed: {e}"
141                ))
142            })?;
143        }
144        let req = super::build_request("VacationResponse/set", args, super::USING_VACATION);
145        let resp = self.call_internal(api_url, &req).await?;
146        jmap_base_client::extract_response(&resp, super::CALL_ID)
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Tests
152// ---------------------------------------------------------------------------
153
154#[cfg(test)]
155mod tests {
156    use serde_json::json;
157
158    // Deleted in JMAP-tco1.5 as Pattern E (vacuous inline tests):
159    //   - vacation_response_get_request_shape
160    //   - vacation_response_set_request_shape
161    //   - vacation_response_set_no_update_sends_account_id_only
162    // Each hand-built `args = json!({...})` and fed it to `build_request`,
163    // never invoking the `vacation_response_get` / `vacation_response_set`
164    // production builders. The third was a "None field is absent" tautology
165    // on hand-built args. Real production-path coverage for these methods
166    // is tracked as a wiremock-smoke gap under JMAP-uuoi (no
167    // `tests/vacation_*.rs` smoke file exists yet). The singleton-id-passing
168    // and no-create/no-destroy invariants of `VacationResponse/{get,set}`
169    // (RFC 8621 §8) are tracked under JMAP-uuoi for a follow-up wiremock
170    // smoke test.
171    //
172    // `build_request`, `CALL_ID`, and `USING_VACATION` themselves have their
173    // own focused tests in `methods/mod.rs`.
174
175    /// Oracle: VacationResponse deserialization from RFC 8621 §8 shape.
176    #[test]
177    fn vacation_response_get_response_deserializes() {
178        let json = json!({
179            "accountId": "acc1",
180            "state": "s1",
181            "list": [
182                {
183                    "id": "singleton",
184                    "isEnabled": false
185                }
186            ],
187            "notFound": []
188        });
189        use super::super::GetResponse;
190        let resp: GetResponse<jmap_mail_types::VacationResponse> =
191            serde_json::from_value(json).expect("must deserialize VacationResponse GetResponse");
192        assert_eq!(resp.list.len(), 1);
193        assert_eq!(resp.list[0].id.as_ref(), "singleton");
194        assert!(!resp.list[0].is_enabled);
195    }
196}