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}