Skip to main content

mailrs_mta_sts/
cache.rs

1//! Policy cache trait + in-memory reference implementation.
2//!
3//! A real STS-aware MTA caches the parsed policy per `(domain, id)`
4//! pair and re-fetches only when:
5//!
6//! - the TXT record's `id` field changes (caller compares before
7//!   calling `Cache::get`), OR
8//! - the cached policy's `max_age` has elapsed (caller's clock).
9//!
10//! This crate defines the [`Cache`] async trait; we ship
11//! [`InMemoryCache`] as a `tokio::sync::RwLock<HashMap>`-backed ref
12//! impl suitable for single-process deployments. Distributed setups
13//! plug their own Redis / Memcached store into the trait.
14
15use std::collections::HashMap;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use tokio::sync::RwLock;
20
21use crate::policy::Policy;
22
23/// One entry's worth of cached policy state.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct CachedPolicy {
26    /// `id` field from the most recent TXT record. Cache key for
27    /// freshness comparison.
28    ///
29    /// **v2 change**: `CompactString` — matches `StsRecord.id`.
30    pub id: compact_str::CompactString,
31    /// Parsed policy body.
32    pub policy: Policy,
33    /// Unix-seconds at which the caller fetched the policy file.
34    /// Combined with `policy.max_age` this gives an expiry the
35    /// caller checks against `now()`.
36    pub fetched_at_unix_secs: u64,
37}
38
39/// Pluggable cache for parsed STS policies.
40///
41/// Implementors decide storage (memory, Kevy, etc.). The trait is
42/// intentionally small — `get`, `put`, `delete` — so it composes with
43/// any KV store.
44#[async_trait]
45pub trait Cache: Send + Sync {
46    /// Read the cached policy for `recipient_domain`. Returns `None`
47    /// if there's no entry (caller must do the full TXT + HTTPS
48    /// dance).
49    async fn get(&self, recipient_domain: &str) -> Option<CachedPolicy>;
50    /// Insert or update the cache entry for `recipient_domain`.
51    async fn put(&self, recipient_domain: &str, entry: CachedPolicy);
52    /// Drop the cache entry (e.g. on `mode: none` policy or a
53    /// confirmed expiry).
54    async fn delete(&self, recipient_domain: &str);
55}
56
57/// In-memory `RwLock<HashMap>` cache for single-process deployments.
58///
59/// Lookups are non-blocking; writes take the write lock briefly.
60/// For high-cardinality deployments swap for a sharded or
61/// out-of-process store.
62#[derive(Debug, Clone, Default)]
63pub struct InMemoryCache {
64    inner: Arc<RwLock<HashMap<String, CachedPolicy>>>,
65}
66
67impl InMemoryCache {
68    /// Construct an empty cache.
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Read the current size — useful for ops dashboards.
74    pub async fn len(&self) -> usize {
75        self.inner.read().await.len()
76    }
77
78    /// True iff no entries.
79    pub async fn is_empty(&self) -> bool {
80        self.inner.read().await.is_empty()
81    }
82}
83
84#[async_trait]
85impl Cache for InMemoryCache {
86    async fn get(&self, recipient_domain: &str) -> Option<CachedPolicy> {
87        let key = recipient_domain.trim().to_ascii_lowercase();
88        self.inner.read().await.get(&key).cloned()
89    }
90    async fn put(&self, recipient_domain: &str, entry: CachedPolicy) {
91        let key = recipient_domain.trim().to_ascii_lowercase();
92        self.inner.write().await.insert(key, entry);
93    }
94    async fn delete(&self, recipient_domain: &str) {
95        let key = recipient_domain.trim().to_ascii_lowercase();
96        self.inner.write().await.remove(&key);
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::policy::{Policy, PolicyMode};
104
105    fn sample_policy() -> Policy {
106        Policy {
107            mode: PolicyMode::Enforce,
108            mx: vec!["mail.example.com".into()],
109            max_age: 86400,
110        }
111    }
112
113    #[tokio::test]
114    async fn in_memory_cache_round_trips() {
115        let cache = InMemoryCache::new();
116        let entry = CachedPolicy {
117            id: "20200101".into(),
118            policy: sample_policy(),
119            fetched_at_unix_secs: 1_700_000_000,
120        };
121        cache.put("example.com", entry.clone()).await;
122        let got = cache.get("example.com").await.unwrap();
123        assert_eq!(got.id, "20200101");
124        assert_eq!(got.policy.max_age, 86400);
125    }
126
127    #[tokio::test]
128    async fn in_memory_cache_key_is_case_insensitive() {
129        let cache = InMemoryCache::new();
130        let entry = CachedPolicy {
131            id: "abc".into(),
132            policy: sample_policy(),
133            fetched_at_unix_secs: 0,
134        };
135        cache.put("Example.COM", entry.clone()).await;
136        assert_eq!(cache.get("example.com").await.unwrap().id, "abc");
137        assert_eq!(cache.get("EXAMPLE.COM").await.unwrap().id, "abc");
138    }
139
140    #[tokio::test]
141    async fn in_memory_cache_delete_removes_entry() {
142        let cache = InMemoryCache::new();
143        cache
144            .put(
145                "example.com",
146                CachedPolicy {
147                    id: "x".into(),
148                    policy: sample_policy(),
149                    fetched_at_unix_secs: 0,
150                },
151            )
152            .await;
153        assert!(cache.get("example.com").await.is_some());
154        cache.delete("example.com").await;
155        assert!(cache.get("example.com").await.is_none());
156    }
157
158    #[tokio::test]
159    async fn in_memory_cache_put_overwrites() {
160        let cache = InMemoryCache::new();
161        cache
162            .put(
163                "x.com",
164                CachedPolicy {
165                    id: "v1".into(),
166                    policy: sample_policy(),
167                    fetched_at_unix_secs: 100,
168                },
169            )
170            .await;
171        cache
172            .put(
173                "x.com",
174                CachedPolicy {
175                    id: "v2".into(),
176                    policy: sample_policy(),
177                    fetched_at_unix_secs: 200,
178                },
179            )
180            .await;
181        let got = cache.get("x.com").await.unwrap();
182        assert_eq!(got.id, "v2");
183        assert_eq!(got.fetched_at_unix_secs, 200);
184    }
185
186    #[tokio::test]
187    async fn in_memory_cache_len_and_is_empty() {
188        let cache = InMemoryCache::new();
189        assert!(cache.is_empty().await);
190        assert_eq!(cache.len().await, 0);
191        cache
192            .put(
193                "x.com",
194                CachedPolicy {
195                    id: "v".into(),
196                    policy: sample_policy(),
197                    fetched_at_unix_secs: 0,
198                },
199            )
200            .await;
201        assert!(!cache.is_empty().await);
202        assert_eq!(cache.len().await, 1);
203    }
204}