things_cloud/
account.rs

1use reqwest::{
2    header::{self, HeaderMap, HeaderValue},
3    StatusCode,
4};
5
6use crate::{tasks, History};
7
8/// User-Agent header set by Things for macOS v3.13.8 (31308504)
9const THINGS_USER_AGENT: &str = "ThingsMac/31516502";
10
11#[derive(Debug)]
12pub struct Account {
13    pub email: String,
14    pub maildrop_email: String,
15    pub(crate) history_key: String,
16    pub(crate) client: reqwest::Client,
17}
18
19#[derive(Debug, thiserror::Error)]
20pub enum Error {
21    /// The provided credentials are invalid
22    #[error("The provided credentials are invalid")]
23    InvalidCredentials,
24
25    /// The account cannot be used with Things Cloud
26    #[error("The account has issues: {0:?}")]
27    AccountHasIssues(Vec<serde_json::Value>),
28
29    /// The account has an unknown status
30    #[error("The account has an unknown status: {0}")]
31    UnknownAccountStatus(String),
32
33    /// An error occurred while communicating with the Things Cloud API
34    #[error("An error occurred while communicating with the Things Cloud API: {0}")]
35    Reqwest(#[from] reqwest::Error),
36}
37
38impl Account {
39    /// Log in to Things Cloud API with the provided credentials.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`Error::AccountHasIssues`] if the account has outstanding issues.
44    /// Returns [`Error::UnknownAccountStatus`] if the account has an unknown status.
45    /// Returns [`Error::InvalidCredentials`] if the provided credentials are invalid.
46    /// Returns [`Error::Reqwest`] if an error occurred while communicating with the Things Cloud API.
47    pub async fn login(email: &str, password: &str) -> Result<Self, Error> {
48        let mut headers = HeaderMap::new();
49        headers.insert(
50            header::USER_AGENT,
51            HeaderValue::from_static(THINGS_USER_AGENT),
52        );
53        headers.insert(
54            header::AUTHORIZATION,
55            HeaderValue::from_str(&format!("Password {password}"))
56                .map_err(|_| Error::InvalidCredentials)?,
57        );
58
59        let client = reqwest::Client::builder()
60            .default_headers(headers)
61            .build()?;
62
63        let response = client
64            .get(format!(
65                "https://cloud.culturedcode.com/version/1/account/{email}"
66            ))
67            .header("Authorization", format!("Password {password}"))
68            .send()
69            .await?;
70
71        if response.status() == StatusCode::UNAUTHORIZED {
72            return Err(Error::InvalidCredentials);
73        }
74
75        let response = response.json::<AccountAPIResponse>().await?;
76
77        if !response.issues.is_empty() {
78            return Err(Error::AccountHasIssues(response.issues));
79        }
80
81        if response.status != Status::Active {
82            return Err(Error::InvalidCredentials);
83        }
84
85        Ok(Self {
86            client,
87            email: response.email,
88            history_key: response.history_key,
89            maildrop_email: response.maildrop_email,
90        })
91    }
92
93    /// Fetch the list of tasks from the Things Cloud API.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`tasks::Error`] if an error occurred while fetching the tasks.
98    pub async fn history(&self) -> Result<History, tasks::Error> {
99        History::from_account(self).await
100    }
101}
102
103#[derive(Debug, serde::Deserialize)]
104#[serde(rename_all = "kebab-case")]
105struct AccountAPIResponse {
106    status: Status,
107    #[serde(rename = "SLA-version-accepted")]
108    sla_version: String,
109    email: String,
110    history_key: String,
111    maildrop_email: String,
112    issues: Vec<serde_json::Value>,
113}
114
115#[derive(Debug, serde::Deserialize, PartialEq, Eq)]
116enum Status {
117    #[serde(rename = "SYAccountStatusActive")]
118    Active,
119    Other(String),
120}