Skip to main content

max / makenotwork

7.9 KB · 284 lines History Blame Raw
1 //! Mailing list CRUD: per-project lists and subscriber management.
2
3 use sqlx::PgPool;
4
5 use super::enums::MailingListType;
6 use super::follows::FollowerEmailRow;
7 use super::id_types::{MailingListId, ProjectId, UserId};
8 use super::models::DbMailingList;
9 use crate::error::Result;
10
11 /// Create a mailing list for a project. Idempotent via ON CONFLICT.
12 #[tracing::instrument(skip_all)]
13 pub async fn create_list(
14 pool: &PgPool,
15 project_id: ProjectId,
16 list_type: MailingListType,
17 name: &str,
18 description: Option<&str>,
19 ) -> Result<DbMailingList> {
20 let row = sqlx::query_as::<_, DbMailingList>(
21 r#"
22 INSERT INTO mailing_lists (project_id, list_type, name, description)
23 VALUES ($1, $2, $3, $4)
24 ON CONFLICT (project_id, list_type) DO UPDATE SET name = EXCLUDED.name
25 RETURNING *
26 "#,
27 )
28 .bind(project_id)
29 .bind(list_type)
30 .bind(name)
31 .bind(description)
32 .fetch_one(pool)
33 .await?;
34
35 Ok(row)
36 }
37
38 /// Look up a list by project and type.
39 #[tracing::instrument(skip_all)]
40 pub async fn get_list_by_project_and_type(
41 pool: &PgPool,
42 project_id: ProjectId,
43 list_type: MailingListType,
44 ) -> Result<Option<DbMailingList>> {
45 let row = sqlx::query_as::<_, DbMailingList>(
46 "SELECT * FROM mailing_lists WHERE project_id = $1 AND list_type = $2",
47 )
48 .bind(project_id)
49 .bind(list_type)
50 .fetch_optional(pool)
51 .await?;
52
53 Ok(row)
54 }
55
56 /// Subscribe a user to a list. Idempotent via ON CONFLICT DO NOTHING.
57 #[tracing::instrument(skip_all)]
58 pub async fn subscribe(
59 pool: &PgPool,
60 list_id: MailingListId,
61 user_id: UserId,
62 ) -> Result<()> {
63 sqlx::query(
64 r#"
65 INSERT INTO mailing_list_subscribers (list_id, user_id)
66 VALUES ($1, $2)
67 ON CONFLICT (list_id, user_id) DO NOTHING
68 "#,
69 )
70 .bind(list_id)
71 .bind(user_id)
72 .execute(pool)
73 .await?;
74
75 Ok(())
76 }
77
78 /// Unsubscribe a user from a specific list. Returns true if a row was deleted.
79 #[tracing::instrument(skip_all)]
80 pub async fn unsubscribe(
81 pool: &PgPool,
82 list_id: MailingListId,
83 user_id: UserId,
84 ) -> Result<bool> {
85 let result = sqlx::query(
86 "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND user_id = $2",
87 )
88 .bind(list_id)
89 .bind(user_id)
90 .execute(pool)
91 .await?;
92
93 Ok(result.rows_affected() > 0)
94 }
95
96 /// Unsubscribe a user from ALL mailing lists on a project (used on unfollow).
97 #[tracing::instrument(skip_all)]
98 pub async fn unsubscribe_from_project(
99 pool: &PgPool,
100 project_id: ProjectId,
101 user_id: UserId,
102 ) -> Result<u64> {
103 let result = sqlx::query(
104 r#"
105 DELETE FROM mailing_list_subscribers
106 WHERE user_id = $1
107 AND list_id IN (SELECT id FROM mailing_lists WHERE project_id = $2)
108 "#,
109 )
110 .bind(user_id)
111 .bind(project_id)
112 .execute(pool)
113 .await?;
114
115 Ok(result.rows_affected())
116 }
117
118 /// Get email addresses of all subscribers on a list.
119 /// Only verified, non-suspended, non-suppressed users. Capped at 10,000.
120 #[tracing::instrument(skip_all)]
121 pub async fn get_subscriber_emails(
122 pool: &PgPool,
123 list_id: MailingListId,
124 ) -> Result<Vec<FollowerEmailRow>> {
125 let rows = sqlx::query_as::<_, FollowerEmailRow>(
126 r#"
127 SELECT u.id, u.email, u.display_name
128 FROM mailing_list_subscribers s
129 JOIN users u ON u.id = s.user_id
130 WHERE s.list_id = $1
131 AND u.email_verified = true
132 AND u.suspended_at IS NULL
133 AND LOWER(u.email) NOT IN (SELECT LOWER(email) FROM email_suppressions)
134 LIMIT 10000
135 "#,
136 )
137 .bind(list_id)
138 .fetch_all(pool)
139 .await?;
140
141 Ok(rows)
142 }
143
144 /// Auto-create the default content + devlog lists for a new project.
145 #[tracing::instrument(skip_all)]
146 pub async fn create_default_lists(
147 pool: &PgPool,
148 project_id: ProjectId,
149 project_title: &str,
150 ) -> Result<()> {
151 create_list(
152 pool,
153 project_id,
154 MailingListType::Content,
155 &format!("{} — Content", project_title),
156 Some("New releases and content updates"),
157 )
158 .await?;
159
160 create_list(
161 pool,
162 project_id,
163 MailingListType::Devlog,
164 &format!("{} — Devlog", project_title),
165 Some("Development updates and behind-the-scenes"),
166 )
167 .await?;
168
169 Ok(())
170 }
171
172 /// Subscribe an email address (without a user account) to a mailing list.
173 /// Used for importing subscriber lists from external platforms.
174 /// Returns `true` if a new row was created, `false` if the email already exists.
175 #[tracing::instrument(skip_all)]
176 pub async fn subscribe_by_email(
177 pool: &PgPool,
178 list_id: MailingListId,
179 email: &str,
180 ) -> Result<bool> {
181 let result = sqlx::query(
182 r#"
183 INSERT INTO mailing_list_subscribers (list_id, email)
184 VALUES ($1, $2)
185 ON CONFLICT DO NOTHING
186 "#,
187 )
188 .bind(list_id)
189 .bind(email.to_lowercase())
190 .execute(pool)
191 .await?;
192
193 Ok(result.rows_affected() > 0)
194 }
195
196 /// Convenience: find the content list for a project and subscribe a user.
197 /// No-op if the content list doesn't exist yet.
198 #[tracing::instrument(skip_all)]
199 pub async fn subscribe_to_content_list(
200 pool: &PgPool,
201 project_id: ProjectId,
202 user_id: UserId,
203 ) -> Result<()> {
204 if let Some(list) = get_list_by_project_and_type(pool, project_id, MailingListType::Content).await? {
205 subscribe(pool, list.id, user_id).await?;
206 }
207
208 Ok(())
209 }
210
211 #[cfg(test)]
212 mod tests {
213 use super::*;
214
215 #[test]
216 fn mailing_list_type_content_roundtrip() {
217 assert_eq!(MailingListType::Content.to_string(), "content");
218 assert_eq!("content".parse::<MailingListType>().unwrap(), MailingListType::Content);
219 }
220
221 #[test]
222 fn mailing_list_type_devlog_roundtrip() {
223 assert_eq!(MailingListType::Devlog.to_string(), "devlog");
224 assert_eq!("devlog".parse::<MailingListType>().unwrap(), MailingListType::Devlog);
225 }
226
227 #[test]
228 fn mailing_list_type_patches_roundtrip() {
229 assert_eq!(MailingListType::Patches.to_string(), "patches");
230 assert_eq!("patches".parse::<MailingListType>().unwrap(), MailingListType::Patches);
231 }
232
233 #[test]
234 fn mailing_list_type_invalid_parse_fails() {
235 assert!("newsletter".parse::<MailingListType>().is_err());
236 assert!("".parse::<MailingListType>().is_err());
237 assert!("CONTENT".parse::<MailingListType>().is_err());
238 }
239
240 #[test]
241 fn mailing_list_type_serde_json_roundtrip() {
242 let val = MailingListType::Content;
243 let json = serde_json::to_string(&val).unwrap();
244 assert_eq!(json, "\"content\"");
245 let parsed: MailingListType = serde_json::from_str(&json).unwrap();
246 assert_eq!(parsed, val);
247 }
248
249 #[test]
250 fn default_list_name_format_content() {
251 let name = format!("{} — Content", "My Project");
252 assert_eq!(name, "My Project — Content");
253 }
254
255 #[test]
256 fn default_list_name_format_devlog() {
257 let name = format!("{} — Devlog", "My Project");
258 assert_eq!(name, "My Project — Devlog");
259 }
260
261 #[test]
262 fn default_list_names_with_special_chars() {
263 let title = "Héllo & World <3>";
264 assert_eq!(format!("{} — Content", title), "Héllo & World <3> — Content");
265 assert_eq!(format!("{} — Devlog", title), "Héllo & World <3> — Devlog");
266 }
267
268 #[test]
269 fn subscribe_by_email_lowercases() {
270 // The function lowercases via email.to_lowercase() before binding.
271 // Verify the stdlib behaviour our code relies on.
272 assert_eq!("FOO@BAR.COM".to_lowercase(), "foo@bar.com");
273 assert_eq!("MiXeD@CaSe.Org".to_lowercase(), "mixed@case.org");
274 }
275
276 #[test]
277 fn id_types_are_distinct() {
278 let ml_id = MailingListId::new();
279 let proj_id = ProjectId::new();
280 // They wrap different UUIDs and are different types — this is a compile-time check.
281 assert_ne!(ml_id.as_uuid(), proj_id.as_uuid());
282 }
283 }
284