1use chrono::{DateTime, Utc};
2use reqwest::Client;
3use std::collections::HashMap;
4use thiserror::Error;
5
6#[derive(Error, Debug)]
8pub enum SmsError {
9 #[error("SMS sending failed to: {number}, {message}")]
10 SendFailed { number: String, message: String },
11 #[error("HTTP request failed: {0}")]
12 RequestFailed(#[from] reqwest::Error),
13}
14
15bitflags::bitflags! {
16 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18 pub struct SmsFlags: u32 {
19 const DEBUG = 0b0000_0001;
21 const NOLOG = 0b0000_0010;
23 const FLASH = 0b0000_0100;
25 const TEST = 0b0000_1000;
27 const NOBL = 0b0001_0000;
29 const CONVERT = 0b0010_0000;
31 }
32}
33
34#[derive(Debug, Clone, Copy)]
36pub enum Encoding {
37 Default,
39 EightBit,
41 Udh,
43}
44
45pub struct SmsClient {
47 api_base_url: String,
48 client: Client,
49}
50
51pub struct SmsRequest<'a> {
53 pub api_key: &'a str,
54 pub sender: &'a str,
55 pub number: &'a str,
56 pub text: &'a str,
57 pub time: Option<DateTime<Utc>>,
58 pub dlr_url: Option<&'a str>,
59 pub expired: Option<i32>,
60 pub flags: SmsFlags,
61 pub user_key: Option<&'a str>,
62 pub encoding: Encoding,
63}
64
65impl<'a> SmsRequest<'a> {
66 #[must_use]
68 pub fn new(api_key: &'a str, sender: &'a str, number: &'a str, text: &'a str) -> Self {
69 Self {
70 api_key,
71 sender,
72 number,
73 text,
74 time: None,
75 dlr_url: None,
76 expired: None,
77 flags: SmsFlags::empty(),
78 user_key: None,
79 encoding: Encoding::Default,
80 }
81 }
82
83 #[must_use]
85 pub fn with_time(mut self, time: DateTime<Utc>) -> Self {
86 self.time = Some(time);
87 self
88 }
89
90 #[must_use]
92 pub fn with_dlr_url(mut self, dlr_url: &'a str) -> Self {
93 self.dlr_url = Some(dlr_url);
94 self
95 }
96
97 #[must_use]
99 pub fn with_expired(mut self, expired: i32) -> Self {
100 self.expired = Some(expired);
101 self
102 }
103
104 #[must_use]
106 pub fn with_flags(mut self, flags: SmsFlags) -> Self {
107 self.flags = flags;
108 self
109 }
110
111 #[must_use]
113 pub fn with_user_key(mut self, user_key: &'a str) -> Self {
114 self.user_key = Some(user_key);
115 self
116 }
117
118 #[must_use]
120 pub fn with_encoding(mut self, encoding: Encoding) -> Self {
121 self.encoding = encoding;
122 self
123 }
124}
125
126impl Default for SmsClient {
127 fn default() -> Self {
128 Self::with_api_base_url("https://api.esteria.eu")
129 }
130}
131
132impl SmsClient {
133 #[allow(dead_code)]
135 #[must_use]
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 #[must_use]
142 pub fn with_api_base_url(api_base_url: impl Into<String>) -> Self {
143 Self {
144 api_base_url: api_base_url.into(),
145 client: Client::new(),
146 }
147 }
148
149 pub async fn send_sms(&self, request: SmsRequest<'_>) -> Result<String, SmsError> {
158 let mut params: HashMap<&str, String> = HashMap::new();
159
160 params.insert("api-key", request.api_key.to_string());
161 params.insert("sender", request.sender.to_string());
162 params.insert("number", request.number.trim_start_matches('+').to_string());
163 params.insert("text", request.text.to_string());
164
165 if let Some(time) = request.time {
166 params.insert("time", time.format("%Y-%m-%dT%H:%M:%S").to_string());
167 }
168
169 if let Some(dlr_url) = request.dlr_url {
170 params.insert("dlr-url", dlr_url.to_string());
171 }
172
173 if let Some(expired) = request.expired {
174 params.insert("expired", expired.to_string());
175 }
176
177 if request.flags.contains(SmsFlags::DEBUG) {
178 params.insert("flag-debug", "1".to_string());
179 }
180
181 if request.flags.contains(SmsFlags::NOLOG) {
182 params.insert("flag-nolog", "3".to_string());
183 }
184
185 if request.flags.contains(SmsFlags::FLASH) {
186 params.insert("flag-flash", "1".to_string());
187 }
188
189 if request.flags.contains(SmsFlags::TEST) {
190 params.insert("flag-test", "1".to_string());
191 }
192
193 if request.flags.contains(SmsFlags::NOBL) {
194 params.insert("flag-nobl", "1".to_string());
195 }
196
197 if request.flags.contains(SmsFlags::CONVERT) {
198 params.insert("flag-convert", "1".to_string());
199 }
200
201 if let Some(user_key) = request.user_key {
202 params.insert("user-key", user_key.to_string());
203 }
204
205 match request.encoding {
206 Encoding::Udh => {
207 params.insert("udh", "1".to_string());
208 params.insert("coding", "1".to_string());
209 }
210 Encoding::EightBit => {
211 params.insert("coding", "1".to_string());
212 }
213 Encoding::Default => {}
214 }
215
216 let url = format!("{}/send", self.api_base_url);
217 let response = self.client.get(&url).query(¶ms).send().await?;
218
219 let resp_text = response.text().await?;
220
221 let result = resp_text.trim().parse::<i128>().ok();
222
223 if let Some(code) = result {
224 if code > 100 {
225 return Ok(resp_text);
226 }
227
228 let error_msg = get_response_code_message(code);
229 log::error!("SMS sending failed to: {}, {}", request.number, error_msg);
230
231 return Err(SmsError::SendFailed {
232 number: request.number.to_string(),
233 message: error_msg.to_string(),
234 });
235 }
236
237 log::error!("SMS sending failed to: {}, unknown error", request.number);
238 Err(SmsError::SendFailed {
239 number: request.number.to_string(),
240 message: "unknown error".to_string(),
241 })
242 }
243}
244
245fn get_response_code_message(code: i128) -> &'static str {
246 match code {
247 1 => "system internal error",
248 2 => "missing PARAM_NAME parameter",
249 3 => "unable to authenticate",
250 4 => "IP ADDRESS is not allowed",
251 5 => "invalid SENDER parameter",
252 6 => "SENDER is not allowed",
253 7 => "invalid NUMBER parameter",
254 8 => "invalid CODING parameter",
255 9 => "unable to convert TEXT",
256 10 => "length of UDH and TEXT too long",
257 11 => "empty TEXT parameter",
258 12 => "invalid TIME parameter",
259 13 => "invalid EXPIRED parameter",
260 14 => "invalid DLR-URL parameter",
261 15 => "Invalid FLAG-FLASH parameter",
262 16 => "invalid FLAG-NOLOG parameter",
263 17 => "invalid FLAG-TEST parameter",
264 18 => "invalid FLAG-NOBL parameter",
265 19 => "invalid FLAG-CONVERT parameter",
266 _ => "unknown error",
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use chrono::TimeZone;
274 use httpmock::prelude::*;
275
276 fn base_request<'a>() -> SmsRequest<'a> {
277 SmsRequest::new("k", "Alice", "+1234567890", "Hello")
278 }
279
280 #[tokio::test]
281 async fn send_sms_success_and_params() {
282 let server = MockServer::start();
283
284 let mut flags = SmsFlags::empty();
286 flags |= SmsFlags::DEBUG
287 | SmsFlags::NOLOG
288 | SmsFlags::FLASH
289 | SmsFlags::TEST
290 | SmsFlags::NOBL
291 | SmsFlags::CONVERT;
292
293 let time = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap();
294
295 let req = base_request()
296 .with_time(time)
297 .with_dlr_url("https://example.com/dlr")
298 .with_expired(60)
299 .with_flags(flags)
300 .with_user_key("ukey")
301 .with_encoding(Encoding::Udh);
302
303 let m = server.mock(|when, then| {
305 when.method(GET)
306 .path("/send")
307 .query_param("api-key", "k")
308 .query_param("sender", "Alice")
309 .query_param("number", "1234567890") .query_param("text", "Hello")
311 .query_param("time", "2025-01-02T03:04:05")
312 .query_param("dlr-url", "https://example.com/dlr")
313 .query_param("expired", "60")
314 .query_param("flag-debug", "1")
315 .query_param("flag-nolog", "3")
316 .query_param("flag-flash", "1")
317 .query_param("flag-test", "1")
318 .query_param("flag-nobl", "1")
319 .query_param("flag-convert", "1")
320 .query_param("user-key", "ukey")
321 .query_param("udh", "1")
322 .query_param("coding", "1");
323 then.status(200).body("1234");
324 });
325
326 let client = SmsClient::with_api_base_url(server.base_url());
327 let code = client.send_sms(req).await.unwrap();
328 assert_eq!(code, "1234");
329 m.assert();
330 }
331
332 #[tokio::test]
333 async fn send_sms_api_error_mapped() {
334 let server = MockServer::start();
335 let m = server.mock(|when, then| {
336 when.method(GET).path("/send");
337 then.status(200).body("3"); });
339
340 let client = SmsClient::with_api_base_url(server.base_url());
341 let err = client.send_sms(base_request()).await.unwrap_err();
342 match err {
343 SmsError::SendFailed { number, message } => {
344 assert_eq!(number, "+1234567890");
345 assert_eq!(message, "unable to authenticate");
346 }
347 SmsError::RequestFailed(err) => panic!("Unexpected error type: {err}"),
348 }
349 m.assert();
350 }
351
352 #[tokio::test]
353 async fn send_sms_unknown_text_maps_to_unknown_error() {
354 let server = MockServer::start();
355 let m = server.mock(|when, then| {
356 when.method(GET).path("/send");
357 then.status(200).body("not-a-number");
358 });
359
360 let client = SmsClient::with_api_base_url(server.base_url());
361 let err = client.send_sms(base_request()).await.unwrap_err();
362 match err {
363 SmsError::SendFailed { number, message } => {
364 assert_eq!(number, "+1234567890");
365 assert_eq!(message, "unknown error");
366 }
367 SmsError::RequestFailed(err) => panic!("Unexpected error type: {err}"),
368 }
369 m.assert();
370 }
371
372 #[tokio::test]
373 async fn send_sms_http_failure_is_request_failed() {
374 let client = SmsClient::with_api_base_url("http://10.255.255.1".to_string());
376 let err = client.send_sms(base_request()).await.unwrap_err();
377 matches!(err, SmsError::RequestFailed(_));
378 }
379
380 #[test]
381 fn builder_sets_fields_and_defaults() {
382 let req = SmsRequest::new("key", "S", "N", "T");
383 assert!(req.time.is_none());
384 assert!(req.dlr_url.is_none());
385 assert!(req.expired.is_none());
386 assert!(req.user_key.is_none());
387 assert_eq!(req.flags, SmsFlags::empty());
388 matches!(req.encoding, Encoding::EightBit);
389 }
390
391 #[test]
392 fn get_response_code_message_works() {
393 assert_eq!(get_response_code_message(1), "system internal error");
394 assert_eq!(
395 get_response_code_message(19),
396 "invalid FLAG-CONVERT parameter"
397 );
398 assert_eq!(get_response_code_message(999), "unknown error");
399 }
400}