//! Mailing list CRUD: per-project lists and subscriber management. use sqlx::PgPool; use super::enums::MailingListType; use super::follows::FollowerEmailRow; use super::id_types::{MailingListId, ProjectId, UserId}; use super::models::DbMailingList; use crate::error::Result; /// Create a mailing list for a project. Idempotent via ON CONFLICT. #[tracing::instrument(skip_all)] pub async fn create_list( pool: &PgPool, project_id: ProjectId, list_type: MailingListType, name: &str, description: Option<&str>, ) -> Result { let row = sqlx::query_as::<_, DbMailingList>( r#" INSERT INTO mailing_lists (project_id, list_type, name, description) VALUES ($1, $2, $3, $4) ON CONFLICT (project_id, list_type) DO UPDATE SET name = EXCLUDED.name RETURNING * "#, ) .bind(project_id) .bind(list_type) .bind(name) .bind(description) .fetch_one(pool) .await?; Ok(row) } /// Look up a list by project and type. #[tracing::instrument(skip_all)] pub async fn get_list_by_project_and_type( pool: &PgPool, project_id: ProjectId, list_type: MailingListType, ) -> Result> { let row = sqlx::query_as::<_, DbMailingList>( "SELECT * FROM mailing_lists WHERE project_id = $1 AND list_type = $2", ) .bind(project_id) .bind(list_type) .fetch_optional(pool) .await?; Ok(row) } /// Subscribe a user to a list. Idempotent via ON CONFLICT DO NOTHING. #[tracing::instrument(skip_all)] pub async fn subscribe( pool: &PgPool, list_id: MailingListId, user_id: UserId, ) -> Result<()> { sqlx::query( r#" INSERT INTO mailing_list_subscribers (list_id, user_id) VALUES ($1, $2) ON CONFLICT (list_id, user_id) DO NOTHING "#, ) .bind(list_id) .bind(user_id) .execute(pool) .await?; Ok(()) } /// Unsubscribe a user from a specific list. Returns true if a row was deleted. #[tracing::instrument(skip_all)] pub async fn unsubscribe( pool: &PgPool, list_id: MailingListId, user_id: UserId, ) -> Result { let result = sqlx::query( "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND user_id = $2", ) .bind(list_id) .bind(user_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Unsubscribe a user from ALL mailing lists on a project (used on unfollow). #[tracing::instrument(skip_all)] pub async fn unsubscribe_from_project( pool: &PgPool, project_id: ProjectId, user_id: UserId, ) -> Result { let result = sqlx::query( r#" DELETE FROM mailing_list_subscribers WHERE user_id = $1 AND list_id IN (SELECT id FROM mailing_lists WHERE project_id = $2) "#, ) .bind(user_id) .bind(project_id) .execute(pool) .await?; Ok(result.rows_affected()) } /// Get email addresses of all subscribers on a list. /// Only verified, non-suspended, non-suppressed users. Capped at 10,000. #[tracing::instrument(skip_all)] pub async fn get_subscriber_emails( pool: &PgPool, list_id: MailingListId, ) -> Result> { let rows = sqlx::query_as::<_, FollowerEmailRow>( r#" SELECT u.id, u.email, u.display_name FROM mailing_list_subscribers s JOIN users u ON u.id = s.user_id WHERE s.list_id = $1 AND u.email_verified = true AND u.suspended_at IS NULL AND LOWER(u.email) NOT IN (SELECT LOWER(email) FROM email_suppressions) LIMIT 10000 "#, ) .bind(list_id) .fetch_all(pool) .await?; Ok(rows) } /// Auto-create the default content + devlog lists for a new project. #[tracing::instrument(skip_all)] pub async fn create_default_lists( pool: &PgPool, project_id: ProjectId, project_title: &str, ) -> Result<()> { create_list( pool, project_id, MailingListType::Content, &format!("{} — Content", project_title), Some("New releases and content updates"), ) .await?; create_list( pool, project_id, MailingListType::Devlog, &format!("{} — Devlog", project_title), Some("Development updates and behind-the-scenes"), ) .await?; Ok(()) } /// Subscribe an email address (without a user account) to a mailing list. /// Used for importing subscriber lists from external platforms. /// Returns `true` if a new row was created, `false` if the email already exists. #[tracing::instrument(skip_all)] pub async fn subscribe_by_email( pool: &PgPool, list_id: MailingListId, email: &str, ) -> Result { let result = sqlx::query( r#" INSERT INTO mailing_list_subscribers (list_id, email) VALUES ($1, $2) ON CONFLICT DO NOTHING "#, ) .bind(list_id) .bind(email.to_lowercase()) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Convenience: find the content list for a project and subscribe a user. /// No-op if the content list doesn't exist yet. #[tracing::instrument(skip_all)] pub async fn subscribe_to_content_list( pool: &PgPool, project_id: ProjectId, user_id: UserId, ) -> Result<()> { if let Some(list) = get_list_by_project_and_type(pool, project_id, MailingListType::Content).await? { subscribe(pool, list.id, user_id).await?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn mailing_list_type_content_roundtrip() { assert_eq!(MailingListType::Content.to_string(), "content"); assert_eq!("content".parse::().unwrap(), MailingListType::Content); } #[test] fn mailing_list_type_devlog_roundtrip() { assert_eq!(MailingListType::Devlog.to_string(), "devlog"); assert_eq!("devlog".parse::().unwrap(), MailingListType::Devlog); } #[test] fn mailing_list_type_patches_roundtrip() { assert_eq!(MailingListType::Patches.to_string(), "patches"); assert_eq!("patches".parse::().unwrap(), MailingListType::Patches); } #[test] fn mailing_list_type_invalid_parse_fails() { assert!("newsletter".parse::().is_err()); assert!("".parse::().is_err()); assert!("CONTENT".parse::().is_err()); } #[test] fn mailing_list_type_serde_json_roundtrip() { let val = MailingListType::Content; let json = serde_json::to_string(&val).unwrap(); assert_eq!(json, "\"content\""); let parsed: MailingListType = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, val); } #[test] fn default_list_name_format_content() { let name = format!("{} — Content", "My Project"); assert_eq!(name, "My Project — Content"); } #[test] fn default_list_name_format_devlog() { let name = format!("{} — Devlog", "My Project"); assert_eq!(name, "My Project — Devlog"); } #[test] fn default_list_names_with_special_chars() { let title = "Héllo & World <3>"; assert_eq!(format!("{} — Content", title), "Héllo & World <3> — Content"); assert_eq!(format!("{} — Devlog", title), "Héllo & World <3> — Devlog"); } #[test] fn subscribe_by_email_lowercases() { // The function lowercases via email.to_lowercase() before binding. // Verify the stdlib behaviour our code relies on. assert_eq!("FOO@BAR.COM".to_lowercase(), "foo@bar.com"); assert_eq!("MiXeD@CaSe.Org".to_lowercase(), "mixed@case.org"); } #[test] fn id_types_are_distinct() { let ml_id = MailingListId::new(); let proj_id = ProjectId::new(); // They wrap different UUIDs and are different types — this is a compile-time check. assert_ne!(ml_id.as_uuid(), proj_id.as_uuid()); } }