use std::collections::{BTreeMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SendRequestId(pub String);
impl SendRequestId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TemplateId(pub String);
impl TemplateId {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Ppnum(String);
#[derive(Debug, Clone, thiserror::Error)]
pub enum PpnumError {
#[error("ppnum is empty")]
Empty,
#[error("ppnum too short ({len} digits, minimum 11)")]
TooShort { len: usize },
#[error("ppnum contains non-digit character at position {pos}")]
NonDigit { pos: usize },
}
impl Ppnum {
pub fn try_new(s: impl Into<String>) -> Result<Self, PpnumError> {
let s = s.into();
if s.is_empty() {
return Err(PpnumError::Empty);
}
for (i, b) in s.bytes().enumerate() {
if !b.is_ascii_digit() {
return Err(PpnumError::NonDigit { pos: i });
}
}
if s.len() < 11 {
return Err(PpnumError::TooShort { len: s.len() });
}
Ok(Self(s))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl AsRef<str> for Ppnum {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct Recipient {
pub ppnum: Ppnum,
pub vars: BTreeMap<String, String>,
}
impl Recipient {
#[must_use]
pub fn bare(ppnum: Ppnum) -> Self {
Self { ppnum, vars: BTreeMap::new() }
}
}
#[derive(Debug, Clone)]
pub struct RecipientList(Vec<Recipient>);
#[derive(Debug, Clone, thiserror::Error)]
pub enum RecipientListError {
#[error("recipient list is empty")]
Empty,
#[error("recipient list exceeds 1000 (got {count})")]
TooLarge { count: usize },
}
const RECIPIENT_LIST_MAX: usize = 1000;
impl RecipientList {
pub fn try_new(recipients: Vec<Recipient>) -> Result<Self, RecipientListError> {
if recipients.is_empty() {
return Err(RecipientListError::Empty);
}
let mut seen: HashSet<String> = HashSet::with_capacity(recipients.len());
let mut deduped = Vec::with_capacity(recipients.len());
for r in recipients {
if seen.insert(r.ppnum.as_str().to_string()) {
deduped.push(r);
}
}
if deduped.len() > RECIPIENT_LIST_MAX {
return Err(RecipientListError::TooLarge { count: deduped.len() });
}
Ok(Self(deduped))
}
pub fn from_ppnums(ppnums: Vec<Ppnum>) -> Result<Self, RecipientListError> {
Self::try_new(ppnums.into_iter().map(Recipient::bare).collect())
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Recipient> {
self.0.iter()
}
}
impl<'a> IntoIterator for &'a RecipientList {
type Item = &'a Recipient;
type IntoIter = std::slice::Iter<'a, Recipient>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
#[derive(Debug, Clone, Default)]
pub struct PollConfig {
pub expires_in_hours: Option<i32>,
pub allow_multiple: bool,
}
#[derive(Debug, Clone)]
pub struct SendOutcome {
pub id: SendRequestId,
pub state: SendRequestState,
pub total_recipients: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendRequestState {
Queued,
Processing,
Completed,
Failed,
Unknown,
}
#[derive(Debug, Clone)]
pub struct SendStatus {
pub id: SendRequestId,
pub state: SendRequestState,
pub totals: SendStatusTotals,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SendStatusTotals {
pub total: u32,
pub delivered: u32,
pub pending_consent: u32,
pub failed: u32,
}
pub struct DeliveryStream(
Box<dyn futures_core::Stream<Item = Result<DeliveryEvent, crate::Error>> + Send + Unpin>,
);
impl DeliveryStream {
pub(crate) fn new(
inner: impl futures_core::Stream<Item = Result<DeliveryEvent, crate::Error>>
+ Send
+ Unpin
+ 'static,
) -> Self {
Self(Box::new(inner))
}
pub async fn message(&mut self) -> Result<Option<DeliveryEvent>, crate::Error> {
use futures_util::StreamExt as _;
match self.0.next().await {
None => Ok(None),
Some(Ok(evt)) => Ok(Some(evt)),
Some(Err(e)) => Err(e),
}
}
}
impl std::fmt::Debug for DeliveryStream {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeliveryStream").finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub struct DeliveryEvent {
pub event_id: String,
pub send_request_id: SendRequestId,
pub kind: DeliveryEventKind,
pub occurred_at: Option<prost_types::Timestamp>,
}
#[derive(Debug, Clone)]
pub enum DeliveryEventKind {
RecipientDelivered { ppnum: String, message_id: Option<String> },
RecipientFailed { ppnum: String, error_code: Option<String> },
RecipientPendingConsent { ppnum: String },
ConsentGranted,
ConsentDenied,
RequestCompleted,
PollResponseReceived,
Unknown,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn ppnum_accepts_11_digits() {
let p = Ppnum::try_new("12345678901").unwrap();
assert_eq!(p.as_str(), "12345678901");
}
#[test]
fn ppnum_accepts_longer_than_11() {
let p = Ppnum::try_new("123456789012345").unwrap();
assert_eq!(p.as_str().len(), 15);
}
#[test]
fn ppnum_rejects_short() {
let err = Ppnum::try_new("1234567890").unwrap_err();
assert!(matches!(err, PpnumError::TooShort { len: 10 }));
}
#[test]
fn ppnum_rejects_empty() {
let err = Ppnum::try_new("").unwrap_err();
assert!(matches!(err, PpnumError::Empty));
}
#[test]
fn ppnum_rejects_non_digit() {
let err = Ppnum::try_new("1234567890a").unwrap_err();
assert!(matches!(err, PpnumError::NonDigit { pos: 10 }));
}
#[test]
fn ppnum_rejects_hyphens() {
let err = Ppnum::try_new("123-1234-5678").unwrap_err();
assert!(matches!(err, PpnumError::NonDigit { .. }));
}
fn p(s: &str) -> Ppnum {
Ppnum::try_new(s).unwrap()
}
#[test]
fn recipient_list_rejects_empty() {
let err = RecipientList::try_new(vec![]).unwrap_err();
assert!(matches!(err, RecipientListError::Empty));
}
#[test]
fn recipient_list_dedupes_by_ppnum() {
let list = RecipientList::from_ppnums(vec![
p("12345678901"),
p("12345678901"),
p("12345678902"),
])
.unwrap();
assert_eq!(list.len(), 2);
}
#[test]
fn recipient_list_dedup_keeps_first_occurrence() {
let mut a = Recipient::bare(p("12345678901"));
a.vars.insert("name".into(), "first".into());
let mut b = Recipient::bare(p("12345678901"));
b.vars.insert("name".into(), "second".into());
let c = Recipient::bare(p("12345678902"));
let list = RecipientList::try_new(vec![a, b, c]).unwrap();
assert_eq!(list.len(), 2);
let first = list.iter().next().unwrap();
assert_eq!(first.vars.get("name").map(String::as_str), Some("first"));
}
#[test]
fn recipient_list_accepts_max_1000() {
let ppnums: Vec<Ppnum> =
(0..1000).map(|i| p(&format!("100000{:05}", i))).collect();
let list = RecipientList::from_ppnums(ppnums).unwrap();
assert_eq!(list.len(), 1000);
}
#[test]
fn recipient_list_rejects_over_1000_after_dedup() {
let ppnums: Vec<Ppnum> =
(0..1001).map(|i| p(&format!("100000{:05}", i))).collect();
let err = RecipientList::from_ppnums(ppnums).unwrap_err();
assert!(matches!(err, RecipientListError::TooLarge { count: 1001 }));
}
#[test]
fn recipient_list_dedup_can_rescue_oversize_input() {
let mut ppnums: Vec<Ppnum> =
(0..750).map(|i| p(&format!("100000{:05}", i))).collect();
ppnums.extend(ppnums.clone());
let list = RecipientList::from_ppnums(ppnums).unwrap();
assert_eq!(list.len(), 750);
}
}