Skip to main content

pidge_core/
account.rs

1//! Account types — represents a single Microsoft account signed into pidge.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// Where pidge stores OAuth tokens for an account.
7///
8/// `Keychain` is the OS-native credential store (macOS Keychain, Windows
9/// Credential Manager, Linux libsecret) — encrypted, OS-managed access control.
10/// `File` is a JSON file at `~/.config/pidge/tokens/<email>.json` with mode 0600
11/// (user-only read/write). The file backend is useful for headless or dev
12/// scenarios where the keychain prompts are friction, but it stores refresh
13/// tokens in plaintext on disk and is less safe than the keychain.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum TokenStorage {
17    #[default]
18    Keychain,
19    File,
20}
21
22/// A signed-in Microsoft account.
23///
24/// This is metadata only — no tokens. Tokens live in the OS keychain
25/// or in a per-account file at `~/.config/pidge/tokens/<email>.json`,
26/// keyed by `email`. The `storage` field tells pidge which backend to look in.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Account {
29    pub email: String,
30    pub tenant_id: String,
31    pub home_account_id: String,
32    pub added_at: DateTime<Utc>,
33    #[serde(default)]
34    pub storage: TokenStorage,
35}
36
37impl Account {
38    /// Well-known tenant ID for personal Microsoft accounts (outlook.com, live.com, hotmail.com).
39    /// Microsoft documents this as the "MSA" tenant.
40    pub const PERSONAL_MSA_TENANT: &'static str = "9188040d-6c67-4c5b-b112-36a304b66dad";
41
42    /// True if this account is a personal Microsoft account.
43    pub fn is_personal(&self) -> bool {
44        self.tenant_id == Self::PERSONAL_MSA_TENANT
45    }
46
47    /// A short human label for the tenant — "personal MSA" for MSA, GUID prefix
48    /// otherwise. Returns "(unknown)" when the tenant_id is empty (e.g. an
49    /// account added before pidge requested the `openid` scope; gets
50    /// auto-backfilled on the next Graph call).
51    pub fn tenant_label(&self) -> String {
52        if self.is_personal() {
53            "personal MSA".to_string()
54        } else if self.tenant_id.is_empty() {
55            "(unknown)".to_string()
56        } else {
57            let prefix: String = self.tenant_id.chars().take(8).collect();
58            format!("{prefix}…")
59        }
60    }
61
62    /// Short human-readable label for the account's provider / kind. Today
63    /// we only support Microsoft, so this distinguishes personal MSA (Outlook
64    /// / Live / Hotmail) from organizational M365 tenants. As we add Gmail,
65    /// IMAP, etc., this becomes a stored field rather than a derived one.
66    pub fn provider_label(&self) -> &'static str {
67        if self.is_personal() {
68            "Outlook"
69        } else {
70            "M365"
71        }
72    }
73
74    /// Machine-readable provider identifier — used in `--json` output and any
75    /// future config-file lookups. Stays stable as labels change.
76    pub fn provider_id(&self) -> &'static str {
77        if self.is_personal() {
78            "outlook"
79        } else {
80            "m365"
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn make_account(tenant_id: &str) -> Account {
90        Account {
91            email: "x@example.com".into(),
92            tenant_id: tenant_id.into(),
93            home_account_id: "home".into(),
94            added_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
95                .unwrap()
96                .to_utc(),
97            storage: TokenStorage::default(),
98        }
99    }
100
101    #[test]
102    fn token_storage_default_is_keychain() {
103        assert!(matches!(TokenStorage::default(), TokenStorage::Keychain));
104    }
105
106    #[test]
107    fn token_storage_serializes_lowercase() {
108        assert_eq!(
109            serde_json::to_string(&TokenStorage::Keychain).unwrap(),
110            "\"keychain\""
111        );
112        assert_eq!(
113            serde_json::to_string(&TokenStorage::File).unwrap(),
114            "\"file\""
115        );
116    }
117
118    #[test]
119    fn account_without_storage_field_deserializes_as_keychain() {
120        let yaml = r#"
121email: a@b.com
122tenant_id: tid
123home_account_id: hid
124added_at: "2026-05-13T22:00:00Z"
125"#;
126        let a: Account = serde_yaml::from_str(yaml).unwrap();
127        assert!(matches!(a.storage, TokenStorage::Keychain));
128    }
129
130    #[test]
131    fn personal_msa_tenant_is_recognised() {
132        assert!(make_account(Account::PERSONAL_MSA_TENANT).is_personal());
133    }
134
135    #[test]
136    fn org_tenant_is_not_personal() {
137        assert!(!make_account("11111111-2222-3333-4444-555555555555").is_personal());
138    }
139
140    #[test]
141    fn tenant_label_for_msa() {
142        assert_eq!(
143            make_account(Account::PERSONAL_MSA_TENANT).tenant_label(),
144            "personal MSA"
145        );
146    }
147
148    #[test]
149    fn tenant_label_for_org_truncates_to_8_chars() {
150        assert_eq!(
151            make_account("11111111-2222-3333-4444-555555555555").tenant_label(),
152            "11111111…"
153        );
154    }
155
156    #[test]
157    fn provider_label_for_personal_msa_is_outlook() {
158        let a = make_account(Account::PERSONAL_MSA_TENANT);
159        assert_eq!(a.provider_label(), "Outlook");
160        assert_eq!(a.provider_id(), "outlook");
161    }
162
163    #[test]
164    fn provider_label_for_org_tenant_is_m365() {
165        let a = make_account("11111111-2222-3333-4444-555555555555");
166        assert_eq!(a.provider_label(), "M365");
167        assert_eq!(a.provider_id(), "m365");
168    }
169}