Skip to main content

rustack_ses_core/
validation.rs

1//! Validation utilities for SES operations.
2//!
3//! Provides tag validation and basic email address format checks.
4
5use rustack_ses_model::error::{SesError, SesErrorCode};
6
7/// Maximum length for tag names and values.
8const MAX_TAG_LENGTH: usize = 255;
9
10/// Validate a message tag name.
11///
12/// Tag names must be:
13/// - Non-empty
14/// - At most 255 characters
15/// - Composed of `[A-Za-z0-9_-]` characters (with `ses:` prefix exception)
16///
17/// # Errors
18///
19/// Returns `SesError` with `InvalidParameterValue` if the tag name is invalid.
20pub fn validate_tag_name(name: &str) -> Result<(), SesError> {
21    if name.is_empty() {
22        return Err(SesError::with_message(
23            SesErrorCode::InvalidParameterValue,
24            "Tag name must not be empty.",
25        ));
26    }
27    if name.len() > MAX_TAG_LENGTH {
28        return Err(SesError::with_message(
29            SesErrorCode::InvalidParameterValue,
30            format!(
31                "Tag name must be at most {MAX_TAG_LENGTH} characters, got {}.",
32                name.len()
33            ),
34        ));
35    }
36    // Allow ses: prefix (AWS reserved tags)
37    let check_part = name.strip_prefix("ses:").unwrap_or(name);
38    if !check_part
39        .chars()
40        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
41    {
42        return Err(SesError::with_message(
43            SesErrorCode::InvalidParameterValue,
44            format!("Tag name contains invalid characters: {name}"),
45        ));
46    }
47    Ok(())
48}
49
50/// Validate a message tag value.
51///
52/// Tag values must be:
53/// - Non-empty
54/// - At most 255 characters
55/// - Composed of `[A-Za-z0-9_\-.@]` characters
56///
57/// # Errors
58///
59/// Returns `SesError` with `InvalidParameterValue` if the tag value is invalid.
60pub fn validate_tag_value(value: &str) -> Result<(), SesError> {
61    if value.is_empty() {
62        return Err(SesError::with_message(
63            SesErrorCode::InvalidParameterValue,
64            "Tag value must not be empty.",
65        ));
66    }
67    if value.len() > MAX_TAG_LENGTH {
68        return Err(SesError::with_message(
69            SesErrorCode::InvalidParameterValue,
70            format!(
71                "Tag value must be at most {MAX_TAG_LENGTH} characters, got {}.",
72                value.len()
73            ),
74        ));
75    }
76    if !value
77        .chars()
78        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '@')
79    {
80        return Err(SesError::with_message(
81            SesErrorCode::InvalidParameterValue,
82            format!("Tag value contains invalid characters: {value}"),
83        ));
84    }
85    Ok(())
86}
87
88/// Validate a list of message tags (name/value pairs).
89///
90/// # Errors
91///
92/// Returns the first validation error encountered.
93pub fn validate_tags(tags: &[(String, String)]) -> Result<(), SesError> {
94    for (name, value) in tags {
95        validate_tag_name(name)?;
96        validate_tag_value(value)?;
97    }
98    Ok(())
99}
100
101/// Basic email address validation.
102///
103/// Checks that the address contains an `@` symbol. This is intentionally
104/// minimal -- full RFC 5322 validation is not required for local development.
105///
106/// # Errors
107///
108/// Returns `SesError` with `InvalidParameterValue` if the email is invalid.
109pub fn validate_email_address(email: &str) -> Result<(), SesError> {
110    if !email.contains('@') {
111        return Err(SesError::with_message(
112            SesErrorCode::InvalidParameterValue,
113            format!("Invalid email address: {email}"),
114        ));
115    }
116    Ok(())
117}
118
119/// Extract the domain from an email address.
120///
121/// Returns `None` if the email does not contain `@`.
122#[must_use]
123pub fn extract_domain(email: &str) -> Option<&str> {
124    email.split('@').nth(1)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_should_accept_valid_tag_name() {
133        assert!(validate_tag_name("campaign").is_ok());
134        assert!(validate_tag_name("my-tag").is_ok());
135        assert!(validate_tag_name("my_tag").is_ok());
136        assert!(validate_tag_name("Tag123").is_ok());
137    }
138
139    #[test]
140    fn test_should_accept_ses_prefix_tag_name() {
141        assert!(validate_tag_name("ses:campaign").is_ok());
142        assert!(validate_tag_name("ses:feedback-id").is_ok());
143    }
144
145    #[test]
146    fn test_should_reject_empty_tag_name() {
147        assert!(validate_tag_name("").is_err());
148    }
149
150    #[test]
151    fn test_should_reject_too_long_tag_name() {
152        let long_name = "a".repeat(256);
153        assert!(validate_tag_name(&long_name).is_err());
154    }
155
156    #[test]
157    fn test_should_reject_invalid_chars_in_tag_name() {
158        assert!(validate_tag_name("tag name").is_err());
159        assert!(validate_tag_name("tag.name").is_err());
160        assert!(validate_tag_name("tag@name").is_err());
161    }
162
163    #[test]
164    fn test_should_accept_valid_tag_value() {
165        assert!(validate_tag_value("welcome").is_ok());
166        assert!(validate_tag_value("test-value").is_ok());
167        assert!(validate_tag_value("user@example.com").is_ok());
168        assert!(validate_tag_value("value_123").is_ok());
169        assert!(validate_tag_value("v1.2.3").is_ok());
170    }
171
172    #[test]
173    fn test_should_reject_empty_tag_value() {
174        assert!(validate_tag_value("").is_err());
175    }
176
177    #[test]
178    fn test_should_reject_too_long_tag_value() {
179        let long_value = "a".repeat(256);
180        assert!(validate_tag_value(&long_value).is_err());
181    }
182
183    #[test]
184    fn test_should_reject_invalid_chars_in_tag_value() {
185        assert!(validate_tag_value("value with spaces").is_err());
186        assert!(validate_tag_value("value<html>").is_err());
187    }
188
189    #[test]
190    fn test_should_validate_tag_pairs() {
191        let tags = vec![
192            ("campaign".to_owned(), "welcome".to_owned()),
193            ("source".to_owned(), "test".to_owned()),
194        ];
195        assert!(validate_tags(&tags).is_ok());
196    }
197
198    #[test]
199    fn test_should_reject_invalid_tag_in_list() {
200        let tags = vec![
201            ("campaign".to_owned(), "welcome".to_owned()),
202            (String::new(), "value".to_owned()),
203        ];
204        assert!(validate_tags(&tags).is_err());
205    }
206
207    #[test]
208    fn test_should_accept_valid_email() {
209        assert!(validate_email_address("user@example.com").is_ok());
210        assert!(validate_email_address("a@b").is_ok());
211    }
212
213    #[test]
214    fn test_should_reject_email_without_at() {
215        assert!(validate_email_address("userexample.com").is_err());
216        assert!(validate_email_address("").is_err());
217    }
218
219    #[test]
220    fn test_should_extract_domain() {
221        assert_eq!(extract_domain("user@example.com"), Some("example.com"));
222        assert_eq!(extract_domain("noat"), None);
223    }
224}