1use std::convert::Infallible;
6use std::net::{IpAddr, SocketAddr};
7
8use axum::extract::{ConnectInfo, FromRequestParts};
9use axum::http::HeaderMap;
10use axum::http::request::Parts;
11use sha2::{Digest, Sha256};
12use time::Date;
13
14use crate::config::{Config, Mode};
15use crate::state::AppState;
16
17#[derive(Debug, Clone, Copy)]
21pub struct ClientIp(pub Option<IpAddr>);
22
23impl FromRequestParts<AppState> for ClientIp {
24 type Rejection = Infallible;
25
26 async fn from_request_parts(
27 parts: &mut Parts,
28 state: &AppState,
29 ) -> Result<ClientIp, Infallible> {
30 let peer = parts
31 .extensions
32 .get::<ConnectInfo<SocketAddr>>()
33 .map(|c| c.0.ip());
34 Ok(ClientIp(client_ip(&parts.headers, peer, &state.config)))
35 }
36}
37
38pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, config: &Config) -> Option<IpAddr> {
44 if config.mode == Mode::Cloud
45 && let Some(ip) = headers
46 .get(config.client_ip_header.as_str())
47 .and_then(|v| v.to_str().ok())
48 .and_then(|v| v.trim().parse::<IpAddr>().ok())
49 {
50 return Some(ip);
51 }
52 peer
53}
54
55pub fn ip_hash(secret: &str, ip: IpAddr, day: Date) -> Vec<u8> {
60 let salt = Sha256::digest(format!("codoseo.ip-salt:{secret}:{day}").as_bytes());
61 let mut hash = Sha256::new();
62 hash.update(salt);
63 hash.update(normalise(ip).as_bytes());
64 hash.finalize().to_vec()
65}
66
67fn normalise(ip: IpAddr) -> String {
68 match ip {
69 IpAddr::V4(v4) => v4.to_string(),
70 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
71 Some(v4) => v4.to_string(),
72 None => {
73 let s = v6.segments();
74 format!("{:x}:{:x}:{:x}:{:x}::/64", s[0], s[1], s[2], s[3])
75 }
76 },
77 }
78}
79
80const DISPOSABLE: &[&str] = &[
83 "10minutemail.co.uk",
84 "10minutemail.com",
85 "10minutemail.net",
86 "20minutemail.com",
87 "anonbox.net",
88 "burnermail.io",
89 "byom.de",
90 "crazymailing.com",
91 "discard.email",
92 "discardmail.com",
93 "dispostable.com",
94 "emailfake.com",
95 "emailondeck.com",
96 "fakeinbox.com",
97 "fakemail.net",
98 "fexpost.com",
99 "getairmail.com",
100 "getnada.com",
101 "grr.la",
102 "guerrillamail.biz",
103 "guerrillamail.com",
104 "guerrillamail.de",
105 "guerrillamail.net",
106 "guerrillamail.org",
107 "guerrillamailblock.com",
108 "harakirimail.com",
109 "inboxkitten.com",
110 "jetable.org",
111 "mail.tm",
112 "mailcatch.com",
113 "maildrop.cc",
114 "mailforspam.com",
115 "mailinator.com",
116 "mailnesia.com",
117 "mailto.plus",
118 "minuteinbox.com",
119 "mintemail.com",
120 "moakt.com",
121 "mohmal.com",
122 "mytemp.email",
123 "nada.email",
124 "owlymail.com",
125 "sharklasers.com",
126 "spam4.me",
127 "spamgourmet.com",
128 "temp-mail.io",
129 "temp-mail.org",
130 "tempinbox.com",
131 "tempmail.com",
132 "tempmail.net",
133 "tempmailo.com",
134 "tempr.email",
135 "throwawaymail.com",
136 "tmail.ws",
137 "trash-mail.com",
138 "trashmail.com",
139 "trashmail.de",
140 "trashmail.net",
141 "trbvm.com",
142 "yopmail.com",
143 "yopmail.fr",
144 "yopmail.net",
145];
146
147pub fn is_disposable(address: &str) -> bool {
148 let domain = address
149 .rsplit_once('@')
150 .map_or(address, |(_, d)| d)
151 .trim()
152 .trim_end_matches('.')
153 .to_ascii_lowercase();
154 DISPOSABLE
155 .iter()
156 .any(|d| domain == *d || domain.ends_with(&format!(".{d}")))
157}
158
159pub fn wait_text(secs: i64) -> String {
161 if secs < 90 * 60 {
162 let minutes = (secs + 59) / 60;
163 format!(
164 "about {} minute{}",
165 minutes.max(1),
166 if minutes == 1 { "" } else { "s" }
167 )
168 } else {
169 let hours = (secs + 3599) / 3600;
170 format!("about {hours} hours")
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use axum::http::HeaderValue;
178
179 fn cloud() -> Config {
180 Config::from_lookup(|k| match k {
181 "CODOSEO_MODE" => Some("cloud".into()),
182 "BASE_URL" => Some("https://codoseo.com".into()),
183 "SECRET_KEY" => Some("k".into()),
184 "SMTP_URL" => Some("smtp://127.0.0.1:2525".into()),
185 _ => None,
186 })
187 .unwrap()
188 }
189
190 fn with_header(value: &str) -> HeaderMap {
191 let mut h = HeaderMap::new();
192 h.insert("cf-connecting-ip", HeaderValue::from_str(value).unwrap());
193 h
194 }
195
196 #[test]
197 fn the_cloud_trusts_its_proxy_header_and_nothing_else() {
198 let peer: IpAddr = "10.0.0.1".parse().unwrap();
199 let cfg = cloud();
200 assert_eq!(
201 client_ip(&with_header("203.0.113.9"), Some(peer), &cfg),
202 Some("203.0.113.9".parse().unwrap())
203 );
204 assert_eq!(
205 client_ip(&with_header(" 2001:db8::1 "), Some(peer), &cfg),
206 Some("2001:db8::1".parse().unwrap())
207 );
208 assert_eq!(
210 client_ip(&with_header("not an ip"), Some(peer), &cfg),
211 Some(peer)
212 );
213 assert_eq!(client_ip(&HeaderMap::new(), Some(peer), &cfg), Some(peer));
214 let mut h = HeaderMap::new();
216 h.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.9"));
217 assert_eq!(client_ip(&h, Some(peer), &cfg), Some(peer));
218 assert_eq!(client_ip(&HeaderMap::new(), None, &cfg), None);
219 }
220
221 #[test]
222 fn self_hosted_ignores_the_header() {
223 let peer: IpAddr = "192.168.1.5".parse().unwrap();
224 let cfg = Config::for_tests();
225 assert_eq!(
226 client_ip(&with_header("203.0.113.9"), Some(peer), &cfg),
227 Some(peer)
228 );
229 }
230
231 #[test]
232 fn hashes_depend_on_secret_day_and_address() {
233 let day = Date::from_calendar_date(2026, time::Month::October, 5).unwrap();
234 let ip: IpAddr = "203.0.113.9".parse().unwrap();
235 let h = ip_hash("s", ip, day);
236 assert_eq!(h.len(), 32);
237 assert_eq!(h, ip_hash("s", ip, day));
238 assert_ne!(h, ip_hash("s", ip, day.next_day().unwrap()));
239 assert_ne!(h, ip_hash("t", ip, day));
240 assert_ne!(h, ip_hash("s", "203.0.113.10".parse().unwrap(), day));
241 }
242
243 #[test]
244 fn ipv6_counts_by_its_64_and_mapped_v4_by_its_v4() {
245 let day = Date::from_calendar_date(2026, time::Month::October, 5).unwrap();
246 let a: IpAddr = "2001:db8:1:2:aaaa::1".parse().unwrap();
247 let b: IpAddr = "2001:db8:1:2:bbbb::ffff".parse().unwrap();
248 let other: IpAddr = "2001:db8:1:3::1".parse().unwrap();
249 assert_eq!(ip_hash("s", a, day), ip_hash("s", b, day));
250 assert_ne!(ip_hash("s", a, day), ip_hash("s", other, day));
251 assert_eq!(
252 ip_hash("s", "::ffff:203.0.113.9".parse().unwrap(), day),
253 ip_hash("s", "203.0.113.9".parse().unwrap(), day)
254 );
255 }
256
257 #[test]
258 fn disposable_domains_match_subdomains_but_not_lookalikes() {
259 for bad in [
260 "a@mailinator.com",
261 "A@MAILINATOR.COM",
262 "a@x.mailinator.com",
263 "a@yopmail.com.",
264 ] {
265 assert!(is_disposable(bad), "{bad}");
266 }
267 for ok in [
268 "a@gmail.com",
269 "a@notmailinator.com",
270 "a@mailinator.example.org",
271 "a@company.io",
272 ] {
273 assert!(!is_disposable(ok), "{ok}");
274 }
275 }
276
277 #[test]
278 fn waits_read_naturally() {
279 assert_eq!(wait_text(1), "about 1 minute");
280 assert_eq!(wait_text(3590), "about 60 minutes");
281 assert_eq!(wait_text(5400), "about 2 hours");
282 assert_eq!(wait_text(80_000), "about 23 hours");
283 }
284}