use std::sync::Arc;
use backbone_mail::application::service::phone_blacklist_write_service::{
AddOutcome, PhoneBlacklistError, PhoneBlacklistWriteService,
};
use backbone_mail::application::service::phone_validation_service::{
phone_format, E164Number, PhoneFormatError,
};
pub const STOP_CODE_MIN_LEN: usize = 6;
pub const STOP_CODE_MAX_LEN: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StopCodeError {
#[error("STOP code is empty")]
Empty { raw: String },
#[error("STOP code {raw:?} is {len} chars — below the Tier B floor of {min} (the upstream 3-char code shape is banned)")]
TooShort { raw: String, len: usize, min: usize },
#[error("STOP code {raw:?} exceeds the Tier B ceiling of {max}")]
TooLong { raw: String, max: usize },
#[error("STOP code {raw:?} is not alphanumeric ASCII: {detail}")]
NotACode { raw: String, detail: String },
}
pub fn validate_stop_code(raw: &str) -> Result<(), StopCodeError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(StopCodeError::Empty { raw: raw.into() });
}
let len = trimmed.len();
if len < STOP_CODE_MIN_LEN {
return Err(StopCodeError::TooShort { raw: raw.into(), len, min: STOP_CODE_MIN_LEN });
}
if len > STOP_CODE_MAX_LEN {
return Err(StopCodeError::TooLong { raw: raw.into(), max: STOP_CODE_MAX_LEN });
}
if !trimmed.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(StopCodeError::NotACode {
raw: raw.into(),
detail: "codes are [A-Za-z0-9] only".into(),
});
}
Ok(())
}
pub fn is_stop_reply_keyword(text: &str) -> bool {
let folded: String = text
.chars()
.filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace())
.map(|c| c.to_ascii_uppercase())
.collect();
let squeezed = folded.split_whitespace().collect::<Vec<_>>().join(" ");
matches!(squeezed.as_str(), "STOP" | "STOP SMS" | "UNSUBSCRIBE")
}
#[derive(Debug, thiserror::Error)]
pub enum SmsStopError {
#[error(transparent)]
Format(#[from] PhoneFormatError),
#[error("not a STOP request: {text:?}")]
NotAStopRequest { text: String },
#[error(transparent)]
Code(#[from] StopCodeError),
#[error("db: {0}")]
Blacklist(#[from] PhoneBlacklistError),
}
impl SmsStopError {
pub fn code(&self) -> &'static str {
match self {
Self::Format(_) => "phone_format_refused",
Self::NotAStopRequest { .. } => "not_a_stop_request",
Self::Code(_) => "stop_code_refused",
Self::Blacklist(_) => "mailing_db_error",
}
}
pub fn http_status(&self) -> u16 {
match self {
Self::Format(_) | Self::NotAStopRequest { .. } | Self::Code(_) => 422,
Self::Blacklist(_) => 500,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StopReplyOutcome {
pub number: E164Number,
pub add: AddOutcome,
}
pub struct SmsStopService {
blacklist: Arc<PhoneBlacklistWriteService>,
}
impl SmsStopService {
pub fn new(blacklist: Arc<PhoneBlacklistWriteService>) -> Self {
Self { blacklist }
}
pub async fn handle_reply(
&self,
text: &str,
raw_number: &str,
country_hint: Option<&str>,
) -> Result<StopReplyOutcome, SmsStopError> {
if !is_stop_reply_keyword(text) {
return Err(SmsStopError::NotAStopRequest { text: text.into() });
}
self.effect_stop(raw_number, country_hint).await
}
pub async fn stop_by_code(
&self,
stop_code: &str,
entered_raw: &str,
country_hint: Option<&str>,
) -> Result<StopReplyOutcome, SmsStopError> {
validate_stop_code(stop_code)?;
self.effect_stop(entered_raw, country_hint).await
}
async fn effect_stop(
&self,
raw_number: &str,
country_hint: Option<&str>,
) -> Result<StopReplyOutcome, SmsStopError> {
let number = phone_format(raw_number, country_hint)?;
let add = self.blacklist.add(&number).await?;
Ok(StopReplyOutcome { number, add })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn three_character_codes_refuse_with_raw_carried() {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut corpus = Vec::new();
for &a in ALPHABET {
for &b in ALPHABET {
for &c in ALPHABET {
corpus.push((a as char).to_string() + &(b as char).to_string() + &(c as char).to_string());
}
}
}
assert_eq!(corpus.len(), 36 * 36 * 36);
for code in &corpus {
match validate_stop_code(code) {
Err(StopCodeError::TooShort { raw, len, min }) => {
assert_eq!(raw, *code, "the refusal carries the raw input");
assert_eq!(len, 3);
assert_eq!(min, STOP_CODE_MIN_LEN);
}
other => panic!("{code} should refuse as TooShort, got {other:?}"),
}
}
}
#[test]
#[expect(clippy::expect_used, reason = "unit test asserts the stop-code refusal contract")]
fn below_floor_corpus_refuses_typed() {
let corpus = [
"",
" ",
"s",
"st",
"sto",
"STOP",
"abcde",
"Ab3",
"xyz",
" abc ",
"a b c",
"!!!",
"a-bc",
];
for raw in corpus {
let err = validate_stop_code(raw).expect_err(&format!("{raw:?} must refuse"));
let carried = match &err {
StopCodeError::Empty { raw } => raw,
StopCodeError::TooShort { raw, .. } => raw,
StopCodeError::TooLong { raw, .. } => raw,
StopCodeError::NotACode { raw, .. } => raw,
};
assert_eq!(carried, raw, "the refusal carries the raw input verbatim");
}
}
#[test]
fn wellformed_codes_pass() {
for code in ["abc123", "ABCDEF", "Stop19", "a1b2c3d4", "12characters"] {
assert!(validate_stop_code(code).is_ok(), "{code} should pass");
}
assert!(matches!(
validate_stop_code("13charactersx"),
Err(StopCodeError::TooLong { .. })
));
}
#[test]
fn stop_keywords_recognize_tolerantly() {
for text in [
"STOP",
"stop",
" Stop ",
"STOP SMS",
"stop sms",
"unsubSCRIBE",
"\"STOP!\"",
"stop.",
] {
assert!(is_stop_reply_keyword(text), "{text:?} is a STOP intent");
}
for text in ["", "yes", "STOPPING PLEASE", "how do I stop this?", "UNSUB"] {
assert!(!is_stop_reply_keyword(text), "{text:?} is NOT a STOP intent");
}
}
}