oxedyne/fe2o3/fe2o3_net/src/sms.rs
32.1 KiB, 149 runs
created by r1870400018:21417, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! A bring-your-own-key client for the SMS gateways, in the half that is not a socket. |
| 2 | //! |
| 3 | //! # What it is for |
| 4 | //! |
| 5 | //! One message, one receipt. It exists because a text message is the only alert channel that |
| 6 | //! reaches a person with no data connection: push needs the internet, mail needs the internet, |
| 7 | //! and a host that has just gone dark is exactly when neither may be to hand. So this is the |
| 8 | //! last leg of an alerting path rather than a messaging feature, and it is deliberately small. |
| 9 | //! |
| 10 | //! # Why it does not send anything |
| 11 | //! |
| 12 | //! [`Provider::request`] returns the *parts* of a call -- host, port, path, method, headers, |
| 13 | //! body -- and stops. The caller dials. That is the same division [`crate::search`] draws and |
| 14 | //! for the same reason: the caller resolves the host, refuses a private address and repeats the |
| 15 | //! refusal on every redirect hop, and a module that opened its own socket would walk around all |
| 16 | //! of it. There is deliberately no convenience here that sends. |
| 17 | //! |
| 18 | //! # Three vendors, one authentication scheme |
| 19 | //! |
| 20 | //! Every provider here reads its credential from an HTTP `Authorization: Basic` header. That is |
| 21 | //! not a coincidence, it is the entry requirement: a vendor whose scheme puts the secret in the |
| 22 | //! request *body* is excluded, because bodies are logged, echoed in error messages and captured |
| 23 | //! by proxies in ways headers are not. Vonage is the notable absence on exactly that ground -- |
| 24 | //! it takes `api_key` and `api_secret` as body parameters. Adding it would mean the module could |
| 25 | //! no longer promise what [`Provider::request`]'s tests assert, which is that **the secret |
| 26 | //! appears in the headers and nowhere else**. |
| 27 | //! |
| 28 | //! The three differ in everything else: two take JSON and one takes a form body, two put the |
| 29 | //! account identifier in the path and one does not, and each names its fields differently. So |
| 30 | //! the enum carries real per-arm code, and [`Receipt`] is the narrow common shape they are |
| 31 | //! flattened into. |
| 32 | //! |
| 33 | //! # A credential is a pair |
| 34 | //! |
| 35 | //! All three authenticate as a user and a secret, though each calls the pair something else: a |
| 36 | //! username and an API key, an account identifier and an auth token. [`Credential`] carries the |
| 37 | //! two without adopting any one vendor's names for them. |
| 38 | //! |
| 39 | //! # A receipt means the vendor took the message |
| 40 | //! |
| 41 | //! [`Provider::parse`] returns a [`Receipt`] only for a message the vendor accepted. Every |
| 42 | //! refusal is an error that carries the vendor's own words, and a refusal can arrive three ways: |
| 43 | //! an HTTP status that is not `2xx`, an error document, or a per-message status inside an |
| 44 | //! otherwise successful reply. ClickSend uses the third for an unfunded account. It answers |
| 45 | //! `200` and `response_code: SUCCESS` for the call, and marks the message itself |
| 46 | //! `INSUFFICIENT_CREDIT`. Until 2026-09-23 that status was passed through as a receipt, and from |
| 47 | //! 2026-08-31 every text in one estate was refused and logged as sent. |
| 48 | //! |
| 49 | //! # What is not here |
| 50 | //! |
| 51 | //! No delivery receipts, no inbound messages, no scheduling, no templates, no contact lists. An |
| 52 | //! alert is sent and forgotten; whether it arrived is answered by the person's phone buzzing, |
| 53 | //! and a delivery-receipt webhook is a second service to run on the host that may be the one in |
| 54 | //! trouble. |
| 55 | //! |
| 56 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 57 | //! Anthropic Claude |
| 58 | |
| 59 | use oxedyne_fe2o3_core::prelude::*; |
| 60 | use oxedyne_fe2o3_jdat::{ |
| 61 | prelude::*, |
| 62 | string::dec::DecoderConfig, |
| 63 | usr::{ |
| 64 | UsrKind, |
| 65 | UsrKindCode, |
| 66 | UsrKindId, |
| 67 | }, |
| 68 | }; |
| 69 | use oxedyne_fe2o3_text::base64; |
| 70 | |
| 71 | use std::collections::BTreeMap; |
| 72 | |
| 73 | use crate::http::{ |
| 74 | header::HttpMethod, |
| 75 | pct, |
| 76 | }; |
| 77 | |
| 78 | |
| 79 | // The longest body a single call will carry. Not a protocol limit -- a gateway will happily |
| 80 | // accept more and bill it as many segments -- but an alert that runs past this has stopped |
| 81 | // being an alert. Ten segments of plain GSM text is far more than a sentence naming a host and |
| 82 | // a fault, and the refusal is louder than a silent truncation would be. |
| 83 | pub const MAX_BODY_LEN: usize = 1530; |
| 84 | |
| 85 | /// The account and secret a gateway authenticates with. |
| 86 | /// |
| 87 | /// Two fields, because all three vendors here want a pair and each calls it something |
| 88 | /// different: a username and an API key, an account identifier and an auth token. Naming them |
| 89 | /// after any one vendor would make the other two read as exceptions. |
| 90 | /// |
| 91 | /// **Neither field is ever written to a log by this module**, and [`Provider::request`] puts |
| 92 | /// both only in the `Authorization` header. See the module documentation for why that is a |
| 93 | /// requirement rather than a habit. |
| 94 | pub struct Credential<'a> { |
| 95 | pub user: &'a str, // a username, an account identifier, an authentication identifier |
| 96 | pub secret: &'a str, // an API key, an auth token |
| 97 | } |
| 98 | |
| 99 | /// One message to one number. |
| 100 | pub struct Message<'a> { |
| 101 | pub to: &'a str, // E.164, with the leading `+` |
| 102 | // As the vendor wants it: a number the account owns, or an alphanumeric identifier where |
| 103 | // the destination permits one. Empty asks the vendor for its default, which is what an |
| 104 | // account with a single number should do rather than repeat itself. |
| 105 | pub from: &'a str, |
| 106 | pub body: &'a str, |
| 107 | } |
| 108 | |
| 109 | /// Everything needed to place one call, and nothing else. |
| 110 | /// |
| 111 | /// The caller owns the socket. See the module documentation. |
| 112 | pub struct SmsCall { |
| 113 | pub host: String, // dialled, and the name the certificate is validated against |
| 114 | pub port: u16, // always 443 for these vendors, carried rather than assumed |
| 115 | pub path: String, // with any account identifier already escaped into it |
| 116 | pub method: HttpMethod, |
| 117 | pub headers: Vec<(String, String)>, // including the credential under `Authorization` |
| 118 | pub body: Vec<u8>, |
| 119 | } |
| 120 | |
| 121 | /// What a gateway said when it took the message. |
| 122 | /// |
| 123 | /// Only an accepted message has one: a refusal is an error from [`Provider::parse`], never a |
| 124 | /// receipt with a refusing status in it. |
| 125 | /// |
| 126 | /// Every field is a string except the count, because the three vendors disagree about the type |
| 127 | /// of every one of them -- a price arrives as a number from one and as a quoted decimal from |
| 128 | /// another -- and a receipt that reformatted them would be asserting a precision none of them |
| 129 | /// promises. The price in particular is passed through exactly as written, in whatever currency |
| 130 | /// the account is billed in, and is never parsed into a figure this module would then be |
| 131 | /// claiming to understand. |
| 132 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 133 | pub struct Receipt { |
| 134 | pub id: String, // the vendor's own, for a support ticket |
| 135 | // The vendor's word for an accepted message, not normalised: `SUCCESS`, `queued` and |
| 136 | // `message(s) queued` all mean accepted, and flattening them into one word would throw away |
| 137 | // the only text a person can quote back to the vendor. |
| 138 | pub status: String, |
| 139 | pub parts: u32, // segments billed, or zero where the vendor did not say |
| 140 | pub price: String, // verbatim and unparsed, or empty where the vendor did not say |
| 141 | } |
| 142 | |
| 143 | /// An SMS gateway. |
| 144 | /// |
| 145 | /// Three, all authenticating by `Authorization: Basic`. See the module documentation for why |
| 146 | /// that is the entry requirement and which vendor it excludes. |
| 147 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 148 | pub enum Provider { |
| 149 | ClickSend, // Australian, billing in Australian dollars, JSON body, no account in the path |
| 150 | Twilio, // form-encoded body, account identifier in the path |
| 151 | Plivo, // JSON body, account identifier in the path |
| 152 | } |
| 153 | |
| 154 | impl Provider { |
| 155 | // A list rather than a `match`, so a variant added and not listed here is unreachable |
| 156 | // through `Self::from_id` -- the safe direction for a set whose members each carry a |
| 157 | // credential. |
| 158 | pub const ALL: [Self; 3] = [Self::ClickSend, Self::Twilio, Self::Plivo]; |
| 159 | |
| 160 | /// The id, one spelling everywhere: this enum, a configuration value, a log line. |
| 161 | pub fn id(&self) -> &'static str { |
| 162 | match self { |
| 163 | Self::ClickSend => "clicksend", |
| 164 | Self::Twilio => "twilio", |
| 165 | Self::Plivo => "plivo", |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | pub fn from_id(s: &str) -> Option<Self> { |
| 170 | let s = s.trim().to_lowercase(); |
| 171 | Self::ALL.into_iter().find(|p| p.id() == s) |
| 172 | } |
| 173 | |
| 174 | /// Dialled, and the name the certificate is validated against. |
| 175 | pub fn host(&self) -> &'static str { |
| 176 | match self { |
| 177 | Self::ClickSend => "rest.clicksend.com", |
| 178 | Self::Twilio => "api.twilio.com", |
| 179 | Self::Plivo => "api.plivo.com", |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | /// The `Authorization` value for a credential. |
| 184 | /// |
| 185 | /// One place, because three vendors spelling out the same base64 would be three places for |
| 186 | /// it to be spelled wrong. |
| 187 | fn authorization(&self, cred: &Credential) -> String { |
| 188 | fmt!("Basic {}", base64::encode(fmt!("{}:{}", cred.user, cred.secret).as_bytes())) |
| 189 | } |
| 190 | |
| 191 | /// Host, path, method, headers and body for one message. **Not a request that is sent**: |
| 192 | /// the caller owns the transport, the address check and the TLS. |
| 193 | pub fn request(&self, cred: &Credential, m: &Message) -> Outcome<SmsCall> { |
| 194 | // Checked here rather than left to the vendor, because a malformed number comes back as |
| 195 | // a 400 with a vendor-specific code at the far end of a socket, and this is an alerting |
| 196 | // path: the failure that matters is the one discovered while the operator is asleep. |
| 197 | if !is_e164(m.to) { |
| 198 | return Err(err!( |
| 199 | "An SMS recipient must be in E.164 with a leading '+', got {:?}.", m.to; |
| 200 | Invalid, Input)); |
| 201 | } |
| 202 | if m.body.is_empty() { |
| 203 | return Err(err!("An SMS needs something to say."; Invalid, Input, Missing)); |
| 204 | } |
| 205 | if m.body.len() > MAX_BODY_LEN { |
| 206 | return Err(err!( |
| 207 | "An SMS body of {} bytes is past the {} this will carry. An alert longer than \ |
| 208 | that has stopped being an alert.", m.body.len(), MAX_BODY_LEN; |
| 209 | Invalid, Input, TooBig)); |
| 210 | } |
| 211 | |
| 212 | let headers = |ct: &str| vec![ |
| 213 | (fmt!("Authorization"), self.authorization(cred)), |
| 214 | (fmt!("Content-Type"), fmt!("{}", ct)), |
| 215 | (fmt!("Accept"), fmt!("application/json")), |
| 216 | ]; |
| 217 | |
| 218 | match self { |
| 219 | Self::ClickSend => { |
| 220 | // The account is identified by the credential alone, so the path is fixed and |
| 221 | // carries nothing. One message per call: this is an alerter, not a campaign. |
| 222 | let mut one = DaticleMap::new(); |
| 223 | one.insert(dat!("to"), dat!(m.to.to_string())); |
| 224 | one.insert(dat!("body"), dat!(m.body.to_string())); |
| 225 | if !m.from.is_empty() { |
| 226 | one.insert(dat!("from"), dat!(m.from.to_string())); |
| 227 | } |
| 228 | let mut b = DaticleMap::new(); |
| 229 | b.insert(dat!("messages"), Dat::List(vec![Dat::Map(one)])); |
| 230 | Ok(SmsCall { |
| 231 | host: self.host().to_string(), |
| 232 | port: 443, |
| 233 | path: fmt!("/v3/sms/send"), |
| 234 | method: HttpMethod::POST, |
| 235 | headers: headers("application/json"), |
| 236 | body: res!(Dat::Map(b).json()).into_bytes(), |
| 237 | }) |
| 238 | }, |
| 239 | Self::Twilio => { |
| 240 | // The account identifier is in the path as well as in the credential. It is |
| 241 | // escaped even though it is an opaque identifier of known shape, because a path |
| 242 | // built by concatenation is a path that will one day be built from something |
| 243 | // else. |
| 244 | let path = fmt!("/2010-04-01/Accounts/{}/Messages.json", |
| 245 | pct::encode_component(cred.user)); |
| 246 | let mut form = fmt!("To={}&Body={}", |
| 247 | pct::encode_component(m.to), pct::encode_component(m.body)); |
| 248 | if !m.from.is_empty() { |
| 249 | form.push_str(&fmt!("&From={}", pct::encode_component(m.from))); |
| 250 | } |
| 251 | Ok(SmsCall { |
| 252 | host: self.host().to_string(), |
| 253 | port: 443, |
| 254 | path, |
| 255 | method: HttpMethod::POST, |
| 256 | headers: headers("application/x-www-form-urlencoded"), |
| 257 | body: form.into_bytes(), |
| 258 | }) |
| 259 | }, |
| 260 | Self::Plivo => { |
| 261 | let path = fmt!("/v1/Account/{}/Message/", pct::encode_component(cred.user)); |
| 262 | let mut b = DaticleMap::new(); |
| 263 | b.insert(dat!("dst"), dat!(m.to.to_string())); |
| 264 | b.insert(dat!("text"), dat!(m.body.to_string())); |
| 265 | if !m.from.is_empty() { |
| 266 | b.insert(dat!("src"), dat!(m.from.to_string())); |
| 267 | } |
| 268 | Ok(SmsCall { |
| 269 | host: self.host().to_string(), |
| 270 | port: 443, |
| 271 | path, |
| 272 | method: HttpMethod::POST, |
| 273 | headers: headers("application/json"), |
| 274 | body: res!(Dat::Map(b).json()).into_bytes(), |
| 275 | }) |
| 276 | }, |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | /// Read a gateway's answer to one call: a [`Receipt`] when the vendor took the message, and an |
| 281 | /// error in the vendor's own words when it did not. |
| 282 | /// |
| 283 | /// `status` is the HTTP status the answer came with. A refusal is surfaced with the provider's |
| 284 | /// own words rather than a summary, since that text is where a vendor explains a rejected |
| 285 | /// credential or an unfunded account. |
| 286 | pub fn parse(&self, status: u16, body: &[u8]) -> Outcome<Receipt> { |
| 287 | let refused = !(200..300).contains(&status); |
| 288 | let txt = match std::str::from_utf8(body) { |
| 289 | Ok(s) => s, |
| 290 | Err(e) => return Err(err!(e, |
| 291 | "{} answered HTTP {} with something that is not text.", self.id(), status; |
| 292 | Network, Data, Decode)), |
| 293 | }; |
| 294 | let dat = match Dat::decode_string_with_config(txt.to_string(), &json_decoder()) { |
| 295 | Ok(d) => d, |
| 296 | // A refusal from a proxy in front of the vendor is often a page rather than JSON. It is |
| 297 | // still a refusal, and its text is still the best explanation to hand. |
| 298 | Err(e) => return Err(if refused { |
| 299 | err!(e, "{} refused the message with HTTP {}: {}", self.id(), status, clip(txt); |
| 300 | Network, Invalid) |
| 301 | } else { |
| 302 | err!(e, "{} answered with something that is not JSON: {}", self.id(), clip(txt); |
| 303 | Network, Data, Decode) |
| 304 | }), |
| 305 | }; |
| 306 | let map = match &dat { |
| 307 | Dat::Map(m) => m, |
| 308 | other => return Err(err!( |
| 309 | "{} answered HTTP {} with a JSON {:?} rather than an object: {}", |
| 310 | self.id(), status, other.kind(), clip(txt); Network, Data, Mismatch)), |
| 311 | }; |
| 312 | |
| 313 | // The error document first, and before the happy path, because two of these vendors |
| 314 | // answer a rejected credential with HTTP 200 and an error object. A parser that read the |
| 315 | // success fields first would find them absent and report a shape problem, hiding the |
| 316 | // sentence that says the account is out of credit. |
| 317 | if let Some(msg) = error_text(map) { |
| 318 | return Err(err!("{} refused the message: {}", self.id(), msg; Network, Invalid)); |
| 319 | } |
| 320 | // A status that is not a success is a refusal whatever the body holds. `message` is read |
| 321 | // here although `error_text` passes it over: on a refusal it is the explanation, while on |
| 322 | // one vendor's success it is the success text. |
| 323 | if refused { |
| 324 | let msg = text(map, "message"); |
| 325 | return Err(err!("{} refused the message with HTTP {}: {}", self.id(), status, |
| 326 | if msg.is_empty() { clip(txt) } else { msg }; |
| 327 | Network, Invalid)); |
| 328 | } |
| 329 | |
| 330 | match self { |
| 331 | Self::ClickSend => { |
| 332 | // data.messages[0]. The call can succeed while the message is refused: an unfunded |
| 333 | // account is `response_code: SUCCESS` for the call and `INSUFFICIENT_CREDIT` for |
| 334 | // the message. `SUCCESS` is the one status that means the message was taken. |
| 335 | let one = res!(first_message(map, "data", "messages").ok_or_else(|| err!( |
| 336 | "{} answered with no message record: {}", self.id(), clip(txt); |
| 337 | Network, Data, Missing))); |
| 338 | let st = text(&one, "status"); |
| 339 | if !st.eq_ignore_ascii_case("SUCCESS") { |
| 340 | return Err(err!("{} refused the message: {}", self.id(), |
| 341 | if st.is_empty() { fmt!("no status given, {}", clip(txt)) } else { st }; |
| 342 | Network, Invalid)); |
| 343 | } |
| 344 | Ok(Receipt { |
| 345 | id: text(&one, "message_id"), |
| 346 | status: st, |
| 347 | parts: number(&one, "message_parts"), |
| 348 | price: text(&one, "message_price"), |
| 349 | }) |
| 350 | }, |
| 351 | Self::Twilio => { |
| 352 | // A created message carries its `sid`. One that failed at once says so in its |
| 353 | // `status`, with an `error_code` and the vendor's `error_message`. |
| 354 | let sid = text(map, "sid"); |
| 355 | if sid.is_empty() { |
| 356 | return Err(err!( |
| 357 | "{} answered with no message record: {}", self.id(), clip(txt); |
| 358 | Network, Data, Missing)); |
| 359 | } |
| 360 | let st = text(map, "status"); |
| 361 | let code = text(map, "error_code"); |
| 362 | let failed = ["failed", "undelivered", "canceled"].iter() |
| 363 | .any(|f| st.eq_ignore_ascii_case(f)); |
| 364 | if failed || !code.is_empty() { |
| 365 | let words: Vec<String> = [ |
| 366 | st.clone(), |
| 367 | if code.is_empty() { String::new() } else { fmt!("error {}", code) }, |
| 368 | text(map, "error_message"), |
| 369 | ].into_iter().filter(|w| !w.is_empty()).collect(); |
| 370 | return Err(err!("{} refused the message: {}", self.id(), words.join(", "); |
| 371 | Network, Invalid)); |
| 372 | } |
| 373 | Ok(Receipt { |
| 374 | id: sid, |
| 375 | status: st, |
| 376 | parts: number(map, "num_segments"), |
| 377 | price: text(map, "price"), |
| 378 | }) |
| 379 | }, |
| 380 | Self::Plivo => { |
| 381 | // The identifier arrives as a list of one, since the endpoint can take several |
| 382 | // destinations. This module sends to one, and a message with no identifier is one |
| 383 | // the vendor did not queue. |
| 384 | let id = match map.get(&dat!("message_uuid")) { |
| 385 | Some(Dat::List(l)) => l.first().map(scalar_text).unwrap_or_default(), |
| 386 | Some(d) => scalar_text(d), |
| 387 | None => String::new(), |
| 388 | }; |
| 389 | if id.is_empty() { |
| 390 | return Err(err!( |
| 391 | "{} answered with no message identifier: {}", self.id(), clip(txt); |
| 392 | Network, Data, Missing)); |
| 393 | } |
| 394 | Ok(Receipt { |
| 395 | id, |
| 396 | status: text(map, "message"), |
| 397 | parts: 0, |
| 398 | price: String::new(), |
| 399 | }) |
| 400 | }, |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | /// Is this an E.164 number? |
| 406 | /// |
| 407 | /// A leading `+`, then between eight and fifteen digits and nothing else. Deliberately strict: |
| 408 | /// spaces, hyphens and brackets are how a human writes a number and every vendor here refuses |
| 409 | /// them, so accepting them would only move the refusal to the far end of a socket. |
| 410 | pub fn is_e164(s: &str) -> bool { |
| 411 | let mut it = s.chars(); |
| 412 | if it.next() != Some('+') { |
| 413 | return false; |
| 414 | } |
| 415 | let digits = s.len() - 1; |
| 416 | digits >= 8 && digits <= 15 && it.all(|c| c.is_ascii_digit()) |
| 417 | } |
| 418 | |
| 419 | fn json_decoder() -> DecoderConfig< |
| 420 | BTreeMap<UsrKindCode, UsrKind>, |
| 421 | BTreeMap<String, UsrKindId>, |
| 422 | > |
| 423 | { |
| 424 | DecoderConfig::json(None) |
| 425 | } |
| 426 | |
| 427 | /// As much of a reply as belongs in an error message. |
| 428 | fn clip(s: &str) -> String { |
| 429 | let s = s.trim(); |
| 430 | if s.len() <= 200 { |
| 431 | return s.to_string(); |
| 432 | } |
| 433 | // Back to a character boundary: a byte slice through a multi-byte character panics, and |
| 434 | // this runs in the alerting path, on text a vendor wrote. |
| 435 | let mut end = 200; |
| 436 | while !s.is_char_boundary(end) { |
| 437 | end -= 1; |
| 438 | } |
| 439 | fmt!("{}...", &s[..end]) |
| 440 | } |
| 441 | |
| 442 | /// A scalar as text, whatever the vendor made it. |
| 443 | /// |
| 444 | /// A price arrives quoted from one vendor and bare from another; a segment count arrives as a |
| 445 | /// string from one and an integer from another. Reading either shape is not laxity, it is the |
| 446 | /// only way one receipt can describe three vendors without lying about one of them. |
| 447 | /// |
| 448 | /// The integer widths are spelled out rather than left to `Display`, because a daticle knows its |
| 449 | /// own width and says so, and a receipt carrying `(u64|1)` where a person expected `1` is a |
| 450 | /// receipt that has quietly leaked the serialisation format into a support ticket. |
| 451 | fn scalar_text(d: &Dat) -> String { |
| 452 | match d { |
| 453 | Dat::Str(s) => s.clone(), |
| 454 | Dat::Empty => String::new(), |
| 455 | // JSON's `null` decodes as an absent option, and it means the vendor said nothing. Left |
| 456 | // to the fallback it read "(none)", so a Twilio receipt carried that as its price. |
| 457 | Dat::Opt(o) => match &**o { |
| 458 | Some(inner) => scalar_text(inner), |
| 459 | None => String::new(), |
| 460 | }, |
| 461 | Dat::U8(n) => fmt!("{}", n), |
| 462 | Dat::U16(n) => fmt!("{}", n), |
| 463 | Dat::U32(n) => fmt!("{}", n), |
| 464 | Dat::U64(n) => fmt!("{}", n), |
| 465 | Dat::I32(n) => fmt!("{}", n), |
| 466 | Dat::I64(n) => fmt!("{}", n), |
| 467 | Dat::F32(n) => fmt!("{}", n), |
| 468 | Dat::F64(n) => fmt!("{}", n), |
| 469 | other => fmt!("{:?}", other), |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | /// Empty when absent. |
| 474 | fn text(map: &DaticleMap, key: &str) -> String { |
| 475 | map.get(&dat!(key)).map(scalar_text).unwrap_or_default() |
| 476 | } |
| 477 | |
| 478 | /// Zero when absent or unreadable. Both shapes are read, because one vendor quotes its segment |
| 479 | /// count and another sends it as a number. |
| 480 | /// |
| 481 | /// The widths are listed rather than parsed back out of a rendered daticle: a daticle prints its |
| 482 | /// own width, so `parse::<u32>()` over `Display` silently returned zero for every integer reply. |
| 483 | fn number(map: &DaticleMap, key: &str) -> u32 { |
| 484 | match map.get(&dat!(key)) { |
| 485 | Some(Dat::Str(s)) => s.trim().parse::<u32>().unwrap_or(0), |
| 486 | Some(Dat::U8(n)) => *n as u32, |
| 487 | Some(Dat::U16(n)) => *n as u32, |
| 488 | Some(Dat::U32(n)) => *n, |
| 489 | // Saturating rather than wrapping. A segment count cannot reach this, which is the |
| 490 | // point: if one ever does, the reply is not a segment count and a large number is a |
| 491 | // better clue than a small one produced by truncation. |
| 492 | Some(Dat::U64(n)) => (*n).min(u32::MAX as u64) as u32, |
| 493 | Some(Dat::I32(n)) => if *n > 0 { *n as u32 } else { 0 }, |
| 494 | Some(Dat::I64(n)) => if *n > 0 { (*n as u64).min(u32::MAX as u64) as u32 } else { 0 }, |
| 495 | _ => 0, |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | /// `outer.inner[0]` as a map, for a vendor that nests its receipt in a list. |
| 500 | fn first_message(map: &DaticleMap, outer: &str, inner: &str) -> Option<DaticleMap> { |
| 501 | let d = match map.get(&dat!(outer)) { |
| 502 | Some(Dat::Map(m)) => m, |
| 503 | _ => return None, |
| 504 | }; |
| 505 | match d.get(&dat!(inner)) { |
| 506 | Some(Dat::List(l)) => match l.first() { |
| 507 | Some(Dat::Map(m)) => Some(m.clone()), |
| 508 | _ => None, |
| 509 | }, |
| 510 | _ => None, |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | /// The vendor's own words for a refusal, where the document is one. |
| 515 | /// |
| 516 | /// Checked before the success fields: two of these vendors answer a rejected credential with |
| 517 | /// HTTP 200 and an error object, so a parser that looked for the receipt first would report a |
| 518 | /// missing field where the vendor had written a sentence explaining itself. |
| 519 | fn error_text(map: &DaticleMap) -> Option<String> { |
| 520 | // A response code that is present and is not a success is the plainest signal. |
| 521 | if let Some(Dat::Str(code)) = map.get(&dat!("response_code")) { |
| 522 | if !code.eq_ignore_ascii_case("SUCCESS") { |
| 523 | let msg = text(map, "response_msg"); |
| 524 | return Some(if msg.is_empty() { code.clone() } else { fmt!("{} ({})", msg, code) }); |
| 525 | } |
| 526 | } |
| 527 | // Otherwise a message-shaped error field, under whichever name the vendor uses. `message` |
| 528 | // is not among them: one vendor uses it for the SUCCESS text. The vendor's error code goes |
| 529 | // with the words where the document carries one, since it is what a support ticket quotes. |
| 530 | for k in ["error", "error_message", "error-message", "detail"] { |
| 531 | let msg = match map.get(&dat!(k)) { |
| 532 | Some(Dat::Str(s)) if !s.is_empty() => s.clone(), |
| 533 | Some(Dat::Map(m)) => text(m, "message"), |
| 534 | _ => String::new(), |
| 535 | }; |
| 536 | if !msg.is_empty() { |
| 537 | let code = match text(map, "error_code") { |
| 538 | c if !c.is_empty() => c, |
| 539 | _ => text(map, "code"), |
| 540 | }; |
| 541 | return Some(if code.is_empty() { msg } else { fmt!("{} (error {})", msg, code) }); |
| 542 | } |
| 543 | } |
| 544 | None |
| 545 | } |
| 546 | |
| 547 | |
| 548 | #[cfg(test)] |
| 549 | mod tests { |
| 550 | use super::*; |
| 551 | |
| 552 | /// A header by name, compared as HTTP compares them. |
| 553 | fn hdr<'a>(c: &'a SmsCall, name: &str) -> Option<&'a str> { |
| 554 | c.headers.iter() |
| 555 | .find(|(k, _)| k.eq_ignore_ascii_case(name)) |
| 556 | .map(|(_, v)| v.as_str()) |
| 557 | } |
| 558 | |
| 559 | fn cred() -> Credential<'static> { |
| 560 | Credential { user: "acct-identifier", secret: "s3cr3t-token-value" } |
| 561 | } |
| 562 | |
| 563 | fn msg() -> Message<'static> { |
| 564 | Message { to: "+61400000000", from: "", body: "jarrah gateway down" } |
| 565 | } |
| 566 | |
| 567 | /// THE PROPERTY THIS MODULE PROMISES: the secret is in the headers and nowhere else. |
| 568 | /// |
| 569 | /// Asserted per provider rather than once over a list, so a fourth arm added without |
| 570 | /// thinking about it fails here rather than quietly widening the promise. See the module |
| 571 | /// documentation for why a body is a worse place for a secret than a header. |
| 572 | #[test] |
| 573 | fn secret_only_ever_in_the_authorization_header() { |
| 574 | let c = cred(); |
| 575 | let m = msg(); |
| 576 | for p in Provider::ALL { |
| 577 | let call = match p.request(&c, &m) { |
| 578 | Ok(call) => call, |
| 579 | Err(e) => panic!("{} would not build a request: {}", p.id(), e), |
| 580 | }; |
| 581 | let body = String::from_utf8_lossy(&call.body).to_string(); |
| 582 | assert!(!call.path.contains(c.secret), |
| 583 | "{} put the secret in the path: {}", p.id(), call.path); |
| 584 | assert!(!body.contains(c.secret), |
| 585 | "{} put the secret in the body: {}", p.id(), body); |
| 586 | // And it IS present, in the one place it belongs -- so this test cannot pass by |
| 587 | // the credential having been dropped altogether. |
| 588 | let auth = match hdr(&call, "authorization") { |
| 589 | Some(a) => a, |
| 590 | None => panic!("{} sent no Authorization header", p.id()), |
| 591 | }; |
| 592 | let expect = base64::encode(fmt!("{}:{}", c.user, c.secret).as_bytes()); |
| 593 | assert_eq!(auth, fmt!("Basic {}", expect), |
| 594 | "{} did not send the credential as Basic", p.id()); |
| 595 | assert!(!call.host.is_empty(), "{} did not name its host", p.id()); |
| 596 | assert_eq!(call.port, 443, "{} did not use TLS", p.id()); |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | /// The recipient and the text survive into the request, whatever the vendor's field names. |
| 601 | #[test] |
| 602 | fn the_message_reaches_the_body() { |
| 603 | let c = cred(); |
| 604 | let m = msg(); |
| 605 | for p in Provider::ALL { |
| 606 | let call = res_unwrap(p.request(&c, &m), p); |
| 607 | let body = String::from_utf8_lossy(&call.body).to_string(); |
| 608 | // The number is percent-escaped in a form body and plain in a JSON one, so the |
| 609 | // digits are what is looked for rather than the whole string. |
| 610 | assert!(body.contains("61400000000"), |
| 611 | "{} lost the recipient: {}", p.id(), body); |
| 612 | assert!(body.contains("gateway") || body.contains("gateway%20"), |
| 613 | "{} lost the text: {}", p.id(), body); |
| 614 | } |
| 615 | } |
| 616 | |
| 617 | #[test] |
| 618 | fn a_number_that_is_not_e164_is_refused_before_a_socket_opens() { |
| 619 | let c = cred(); |
| 620 | for bad in ["0400 000 000", "61400000000", "+61-400-000-000", "+123", ""] { |
| 621 | let m = Message { to: bad, from: "", body: "x" }; |
| 622 | for p in Provider::ALL { |
| 623 | assert!(p.request(&c, &m).is_err(), |
| 624 | "{} accepted {:?} as a number", p.id(), bad); |
| 625 | } |
| 626 | } |
| 627 | assert!(is_e164("+61400000000")); |
| 628 | assert!(is_e164("+14155550123")); |
| 629 | } |
| 630 | |
| 631 | #[test] |
| 632 | fn an_empty_or_enormous_body_is_refused() { |
| 633 | let c = cred(); |
| 634 | let long = "x".repeat(MAX_BODY_LEN + 1); |
| 635 | for p in Provider::ALL { |
| 636 | assert!(p.request(&c, &Message { to: "+61400000000", from: "", body: "" }).is_err(), |
| 637 | "{} accepted an empty body", p.id()); |
| 638 | assert!(p.request(&c, &Message { to: "+61400000000", from: "", body: &long }).is_err(), |
| 639 | "{} accepted a body past the cap", p.id()); |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | /// An id round-trips, and an unknown one is refused rather than defaulted. |
| 644 | #[test] |
| 645 | fn ids_are_one_spelling() { |
| 646 | for p in Provider::ALL { |
| 647 | assert_eq!(Provider::from_id(p.id()), Some(p)); |
| 648 | assert_eq!(Provider::from_id(&p.id().to_uppercase()), Some(p)); |
| 649 | } |
| 650 | assert_eq!(Provider::from_id("vonage"), None); |
| 651 | assert_eq!(Provider::from_id(""), None); |
| 652 | } |
| 653 | |
| 654 | #[test] |
| 655 | fn a_receipt_is_read_from_each_vendors_own_shape() { |
| 656 | let cs = br#"{"http_code":200,"response_code":"SUCCESS","data":{"messages":[ |
| 657 | {"message_id":"ABC-123","status":"SUCCESS","message_parts":1,"message_price":"0.0790"}]}}"#; |
| 658 | let r = res_unwrap(Provider::ClickSend.parse(200, cs), Provider::ClickSend); |
| 659 | assert_eq!(r.id, "ABC-123"); |
| 660 | assert_eq!(r.status, "SUCCESS"); |
| 661 | assert_eq!(r.parts, 1); |
| 662 | assert_eq!(r.price, "0.0790", "the price is passed through verbatim"); |
| 663 | |
| 664 | let tw = br#"{"sid":"SM9","status":"queued","num_segments":"2","price":null, |
| 665 | "error_code":null,"error_message":null}"#; |
| 666 | let r = res_unwrap(Provider::Twilio.parse(201, tw), Provider::Twilio); |
| 667 | assert_eq!(r.id, "SM9"); |
| 668 | assert_eq!(r.status, "queued"); |
| 669 | assert_eq!(r.parts, 2, "a count quoted as a string is still a count"); |
| 670 | assert_eq!(r.price, "", "a null price is no price, not the text of a null"); |
| 671 | |
| 672 | let pl = br#"{"message_uuid":["uu-1"],"message":"message(s) queued"}"#; |
| 673 | let r = res_unwrap(Provider::Plivo.parse(202, pl), Provider::Plivo); |
| 674 | assert_eq!(r.id, "uu-1", "the identifier is lifted out of its list of one"); |
| 675 | } |
| 676 | |
| 677 | /// A refusal that arrives with HTTP 200 is still a refusal, and it says why. |
| 678 | #[test] |
| 679 | fn an_error_document_is_an_error_and_repeats_the_vendors_words() { |
| 680 | let out_of_credit = br#"{"http_code":400,"response_code":"NO_CREDIT", |
| 681 | "response_msg":"Insufficient credit"}"#; |
| 682 | match Provider::ClickSend.parse(200, out_of_credit) { |
| 683 | Ok(r) => panic!("an unfunded account read as a receipt: {:?}", r), |
| 684 | Err(e) => { |
| 685 | let s = e.to_string(); |
| 686 | assert!(s.contains("Insufficient credit"), |
| 687 | "the vendor's own sentence was thrown away: {}", s); |
| 688 | }, |
| 689 | } |
| 690 | let bad_key = br#"{"status":401,"message":"Authenticate","error":"authentication failed"}"#; |
| 691 | assert!(Provider::Twilio.parse(200, bad_key).is_err(), |
| 692 | "a rejected credential read as a receipt"); |
| 693 | } |
| 694 | |
| 695 | /// THE FAULT OF 2026-08-31: the call succeeds and the message inside it is refused. This is |
| 696 | /// the shape ClickSend answers an unfunded account with, `SUCCESS` for the call and |
| 697 | /// `INSUFFICIENT_CREDIT` for the message, and every such text was logged as sent for three |
| 698 | /// weeks. It is a refusal, and it names the vendor's status. |
| 699 | #[test] |
| 700 | fn a_message_refused_inside_a_successful_call_is_a_refusal() { |
| 701 | let unfunded = br#"{"http_code":200,"response_code":"SUCCESS", |
| 702 | "response_msg":"Messages queued for delivery.","data":{"total_price":0,"total_count":1, |
| 703 | "queued_count":0,"messages":[{"direction":"out","date":1756600000,"to":"+61400000000", |
| 704 | "body":"birch copy is DOWN","from":"","schedule":0, |
| 705 | "message_id":"4C1F2D3E-5A6B-4C7D-8E9F-0A1B2C3D4E5F","message_parts":1, |
| 706 | "message_price":"0.0000","from_email":null,"list_id":null,"custom_string":"", |
| 707 | "contact_id":null,"user_id":1,"subaccount_id":1,"country":"AU","carrier":"Telstra", |
| 708 | "status":"INSUFFICIENT_CREDIT"}],"_currency":{"currency_name_short":"AUD", |
| 709 | "currency_prefix_d":"$","currency_prefix_c":"c", |
| 710 | "currency_name_long":"Australian Dollars"}}}"#; |
| 711 | match Provider::ClickSend.parse(200, unfunded) { |
| 712 | Ok(r) => panic!("a text refused for want of credit read as sent: {:?}", r), |
| 713 | Err(e) => { |
| 714 | let s = e.to_string(); |
| 715 | assert!(s.contains("INSUFFICIENT_CREDIT") && s.contains("clicksend"), |
| 716 | "the refusal must name the vendor and its status: {}", s); |
| 717 | }, |
| 718 | } |
| 719 | // Any status but SUCCESS is a refusal, and so is none at all. |
| 720 | let bad_number = br#"{"response_code":"SUCCESS","data":{"messages":[ |
| 721 | {"message_id":"X","status":"INVALID_RECIPIENT"}]}}"#; |
| 722 | assert!(Provider::ClickSend.parse(200, bad_number).is_err()); |
| 723 | let silent = br#"{"response_code":"SUCCESS","data":{"messages":[{"message_id":"X"}]}}"#; |
| 724 | assert!(Provider::ClickSend.parse(200, silent).is_err(), |
| 725 | "a message with no status is not a message the vendor took"); |
| 726 | } |
| 727 | |
| 728 | /// A status that is not a `2xx` is a refusal whatever the body holds, in the vendor's words |
| 729 | /// where it wrote any and in the page's own text where it did not. |
| 730 | #[test] |
| 731 | fn a_status_that_is_not_a_success_is_a_refusal_in_the_vendors_words() { |
| 732 | let bad_to = br#"{"code":21211,"message":"Invalid 'To' Phone Number: +6140000000", |
| 733 | "more_info":"https://www.twilio.com/docs/errors/21211","status":400}"#; |
| 734 | match Provider::Twilio.parse(400, bad_to) { |
| 735 | Ok(r) => panic!("a 400 read as a receipt: {:?}", r), |
| 736 | Err(e) => { |
| 737 | let s = e.to_string(); |
| 738 | assert!(s.contains("Invalid 'To' Phone Number") && s.contains("400"), |
| 739 | "the vendor's explanation and the status were thrown away: {}", s); |
| 740 | }, |
| 741 | } |
| 742 | let page = b"<html><body>502 Bad Gateway</body></html>"; |
| 743 | match Provider::Plivo.parse(502, page) { |
| 744 | Ok(r) => panic!("a proxy's error page read as a receipt: {:?}", r), |
| 745 | Err(e) => assert!(e.to_string().contains("502 Bad Gateway"), |
| 746 | "the page's own text is the best explanation to hand: {}", e), |
| 747 | } |
| 748 | // A body that would be a receipt under a 2xx is still refused under a 5xx. |
| 749 | let looks_fine = br#"{"message_uuid":["uu-1"],"message":"message(s) queued"}"#; |
| 750 | assert!(Provider::Plivo.parse(503, looks_fine).is_err()); |
| 751 | } |
| 752 | |
| 753 | /// A message the vendor created and failed at once is a refusal, with its code and words; |
| 754 | /// an answer with no message in it at all is no receipt either. |
| 755 | #[test] |
| 756 | fn a_message_that_failed_at_once_or_was_never_made_is_a_refusal() { |
| 757 | let filtered = br#"{"sid":"SM10","status":"failed","error_code":30007, |
| 758 | "error_message":"Message filtered","num_segments":"1","price":null}"#; |
| 759 | match Provider::Twilio.parse(201, filtered) { |
| 760 | Ok(r) => panic!("a failed message read as a receipt: {:?}", r), |
| 761 | Err(e) => { |
| 762 | let s = e.to_string(); |
| 763 | assert!(s.contains("30007") && s.contains("Message filtered"), |
| 764 | "the code and the words were thrown away: {}", s); |
| 765 | }, |
| 766 | } |
| 767 | assert!(Provider::Twilio.parse(201, br#"{"status":"queued"}"#).is_err(), |
| 768 | "no sid means no message was made"); |
| 769 | assert!(Provider::Plivo.parse(202, br#"{"message":"message(s) queued"}"#).is_err(), |
| 770 | "no identifier means nothing was queued"); |
| 771 | assert!(Provider::Plivo.parse(202, br#"{"message_uuid":[]}"#).is_err()); |
| 772 | } |
| 773 | |
| 774 | /// An error message quotes at most 200 bytes of a reply, and never cuts a character in half: |
| 775 | /// a panic here would take the alerting task with it. |
| 776 | #[test] |
| 777 | fn a_long_reply_is_clipped_on_a_character_boundary() { |
| 778 | let long = "\u{00e9}".repeat(150); // 300 bytes, each character two |
| 779 | let c = clip(&long); |
| 780 | assert!(c.ends_with("...")); |
| 781 | assert!(c.len() <= 203); |
| 782 | let refusal = fmt!("<p>{}</p>", "\u{20ac}".repeat(100)); // three bytes a character |
| 783 | assert!(Provider::Twilio.parse(500, refusal.as_bytes()).is_err()); |
| 784 | } |
| 785 | |
| 786 | /// Unwrap in a test, naming which provider failed. |
| 787 | fn res_unwrap<T>(r: Outcome<T>, p: Provider) -> T { |
| 788 | match r { |
| 789 | Ok(v) => v, |
| 790 | Err(e) => panic!("{}: {}", p.id(), e), |
| 791 | } |
| 792 | } |
| 793 | } |