use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{config::StatusConfig, request_id::RequestId};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Domain(String);
impl Domain {
pub fn from_address(email: &str) -> Option<Self> {
email.rfind('@').map(|i| Domain(email[i + 1..].to_lowercase()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Domain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
BadRequest,
ValidationFailed,
PayloadTooLarge,
UnsupportedMediaType,
RateLimited,
Forbidden,
SmtpUnavailable,
SmtpRejected,
InternalError,
SubmissionNotFound,
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = serde_json::to_value(self)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{self:?}"));
f.write_str(&s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubmissionStatus {
Received,
Rejected,
SmtpSubmissionStarted,
SmtpAccepted,
SmtpFailed,
}
impl SubmissionStatus {
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Rejected | Self::SmtpAccepted | Self::SmtpFailed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubmissionStatusRecord {
pub request_id: RequestId,
pub key_id: String,
pub status: SubmissionStatus,
pub code: Option<ErrorCode>,
pub message: Option<String>,
pub recipient_domains: Vec<Domain>,
pub recipient_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl SubmissionStatusRecord {
pub fn new(
request_id: RequestId,
key_id: String,
recipient_domains: Vec<Domain>,
recipient_count: u32,
ttl_seconds: u64,
) -> Self {
let now = Utc::now();
let expires_at = now
+ chrono::Duration::seconds(ttl_seconds as i64);
Self {
request_id,
key_id,
status: SubmissionStatus::Received,
code: None,
message: Some("Submission received.".into()),
recipient_domains,
recipient_count,
created_at: now,
updated_at: now,
expires_at,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
pub struct StatusUpdate {
pub status: SubmissionStatus,
pub code: Option<ErrorCode>,
pub message: Option<String>,
}
pub trait StatusStore: Send + Sync {
fn put(&self, record: SubmissionStatusRecord);
fn update_status(&self, request_id: &RequestId, key_id: &str, update: StatusUpdate);
fn get(&self, request_id: &RequestId, key_id: &str) -> Option<SubmissionStatusRecord>;
fn expire_old_records(&self);
fn record_count(&self) -> usize;
fn reload_config(&self, config: &StatusConfig);
}
pub fn recipient_domains_from(to: &[String], cc: &[String]) -> Vec<Domain> {
let mut set = std::collections::BTreeSet::new();
for addr in to.iter().chain(cc.iter()) {
if let Some(d) = Domain::from_address(addr) {
set.insert(d);
}
}
set.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_from_address() {
let d = Domain::from_address("Alice@Example.COM").unwrap();
assert_eq!(d.as_str(), "example.com");
}
#[test]
fn domain_from_invalid_email_returns_none() {
assert!(Domain::from_address("not-an-email").is_none());
}
#[test]
fn submission_status_terminal_set() {
assert!(!SubmissionStatus::Received.is_terminal());
assert!(!SubmissionStatus::SmtpSubmissionStarted.is_terminal());
assert!(SubmissionStatus::Rejected.is_terminal());
assert!(SubmissionStatus::SmtpAccepted.is_terminal());
assert!(SubmissionStatus::SmtpFailed.is_terminal());
}
#[test]
fn recipient_domains_dedup_and_sort() {
let to = vec!["a@example.com".into(), "b@example.org".into()];
let cc = vec!["c@example.com".into()]; let ds = recipient_domains_from(&to, &cc);
assert_eq!(ds.len(), 2);
assert_eq!(ds[0].as_str(), "example.com");
assert_eq!(ds[1].as_str(), "example.org");
}
#[test]
fn error_code_serializes_snake_case() {
let s = serde_json::to_string(&ErrorCode::ValidationFailed).unwrap();
assert_eq!(s, r#""validation_failed""#);
}
#[test]
fn new_record_is_received_and_not_expired() {
let r = SubmissionStatusRecord::new(
RequestId::new(), "key".into(), vec![], 0, 3600
);
assert_eq!(r.status, SubmissionStatus::Received);
assert!(!r.is_expired());
}
}