The smokesignal.events web application
1use anyhow::Result;
2use async_trait::async_trait;
3use lettre::{
4 Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
5 message::{Attachment as LettreAttachment, Mailbox, MultiPart, header::ContentType},
6};
7use std::sync::Arc;
8use tracing::{info, warn};
9
10use crate::email_templates::{EmailTemplateEngine, contexts};
11use crate::storage::{
12 StoragePool,
13 denylist::{
14 denylist_exists, notification_unsubscribe_all_pattern,
15 notification_unsubscribe_sender_pattern,
16 },
17};
18use crate::throttle::Throttle;
19use crate::unsubscribe_token::{UnsubscribeAction, generate_token};
20
21/// Represents an email attachment
22pub struct EmailAttachment {
23 /// File name for the attachment
24 pub filename: String,
25 /// MIME content type
26 pub content_type: String,
27 /// Attachment content as bytes
28 pub content: Vec<u8>,
29}
30
31// Email constants
32const FROM_EMAIL_ADDRESS: &str = "events@smokesignal.events";
33const FROM_EMAIL_NAME: &str = "Smoke Signal Events";
34
35const EMAIL_RSVP_GOING_SUBJECT: &str = "Someone is going to your event!";
36const EMAIL_RSVP_ACCEPTED_SUBJECT: &str = "Your RSVP has been accepted";
37const EMAIL_EVENT_CHANGED_SUBJECT: &str = "An event you're going to has changed";
38const EMAIL_CONFIRMATION_SUBJECT: &str = "Confirm your email address";
39const EMAIL_REMINDER_24H_SUBJECT: &str = "Reminder: Your event is starting soon";
40const EMAIL_EVENT_SUMMARY_SUBJECT: &str = "Event Summary - You're going!";
41
42/// Sanitize a filename by removing/replacing invalid characters
43fn sanitize_filename(name: &str) -> String {
44 name.chars()
45 .map(|c| match c {
46 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
47 c if c.is_control() => '_',
48 c => c,
49 })
50 .collect::<String>()
51 .chars()
52 .take(100) // Limit length to 100 chars
53 .collect()
54}
55
56/// Trait for sending email notifications
57#[async_trait]
58pub trait Emailer: Send + Sync {
59 /// Send notification that someone has created a "going" RSVP for their event
60 async fn notify_rsvp_going(
61 &self,
62 receiver_email: &str,
63 receiver_did: &str,
64 rsvp_did: &str,
65 rsvp_identity: &str,
66 event_name: &str,
67 event_url: &str,
68 ) -> Result<()>;
69
70 /// Send notification that an RSVP has been accepted
71 async fn notify_rsvp_accepted(
72 &self,
73 receiver_email: &str,
74 receiver_did: &str,
75 event_name: &str,
76 event_url: &str,
77 ) -> Result<()>;
78
79 /// Send notification that an event has been changed
80 async fn notify_event_changed(
81 &self,
82 receiver_email: &str,
83 receiver_did: &str,
84 event_did: &str,
85 event_name: &str,
86 event_url: &str,
87 ) -> Result<()>;
88
89 /// Send email confirmation link
90 async fn send_email_confirmation(
91 &self,
92 recipient_email: &str,
93 confirmation_url: &str,
94 ) -> Result<()>;
95
96 /// Send 24-hour event reminder
97 async fn send_event_reminder_24h(
98 &self,
99 receiver_email: &str,
100 receiver_did: &str,
101 event_name: &str,
102 event_start_time: &str,
103 event_url: &str,
104 ) -> Result<()>;
105
106 /// Send event summary with ICS attachment when user RSVPs "going"
107 ///
108 /// The `ics_attachment` parameter should be generated using
109 /// `crate::ics_helpers::generate_event_ics()` and converted to bytes.
110 #[allow(clippy::too_many_arguments)]
111 async fn send_event_summary(
112 &self,
113 receiver_email: &str,
114 receiver_did: &str,
115 event_name: &str,
116 event_description: Option<&str>,
117 event_location: Option<&str>,
118 event_start_time: &str,
119 event_end_time: Option<&str>,
120 event_url: &str,
121 ics_attachment: Vec<u8>,
122 ) -> Result<()>;
123
124 /// Check if email should be sent based on denylist and notification preferences
125 async fn should_send_email(
126 &self,
127 sender_did: Option<&str>,
128 receiver_did: &str,
129 receiver_email: &str,
130 ) -> Result<bool>;
131}
132
133/// Default emailer implementation using lettre
134pub struct LettreEmailer {
135 mailer: Arc<AsyncSmtpTransport<Tokio1Executor>>,
136 storage_pool: StoragePool,
137 template_engine: EmailTemplateEngine,
138 external_base: String,
139 email_secret_key: crate::config::EmailSecretKey,
140 throttle: Arc<dyn Throttle>,
141}
142
143impl LettreEmailer {
144 pub fn new(
145 smtp_credentials: &str,
146 storage_pool: StoragePool,
147 external_base: String,
148 email_secret_key: crate::config::EmailSecretKey,
149 throttle: Arc<dyn Throttle>,
150 ) -> Result<Self> {
151 let mailer = AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_credentials)
152 .map_err(|e| anyhow::anyhow!("Failed to parse SMTP credentials: {}", e))?
153 .build();
154
155 let template_engine = EmailTemplateEngine::new()?;
156
157 Ok(Self {
158 mailer: Arc::new(mailer),
159 storage_pool,
160 template_engine,
161 external_base,
162 email_secret_key,
163 throttle,
164 })
165 }
166
167 async fn send_email(
168 &self,
169 recipient_email: &str,
170 subject: &str,
171 plain_body: String,
172 html_body: String,
173 ) -> Result<()> {
174 // Check throttle - fast exit if recipient has received too many emails recently
175 match self.throttle.check_and_record(recipient_email).await {
176 Ok(true) => {
177 // Allowed, continue
178 }
179 Ok(false) => {
180 warn!(
181 recipient_email,
182 subject, "Email throttled - recipient has received too many emails recently"
183 );
184 return Ok(());
185 }
186 Err(err) => {
187 warn!(
188 ?err,
189 recipient_email, subject, "Throttle check failed, allowing email to proceed"
190 );
191 // Continue anyway - don't let throttle errors block email delivery
192 }
193 }
194
195 // Parse addresses
196 let from_address = FROM_EMAIL_ADDRESS
197 .parse::<Address>()
198 .map_err(|e| anyhow::anyhow!("Invalid from address: {}", e))?;
199
200 let to_address = recipient_email
201 .parse::<Address>()
202 .map_err(|e| anyhow::anyhow!("Invalid recipient address {}: {}", recipient_email, e))?;
203
204 // Build email message
205 let email_message = Message::builder()
206 .from(Mailbox::new(Some(FROM_EMAIL_NAME.to_owned()), from_address))
207 .to(Mailbox::new(None, to_address))
208 .subject(subject)
209 .multipart(MultiPart::alternative_plain_html(plain_body, html_body))
210 .map_err(|e| anyhow::anyhow!("Failed to build email: {}", e))?;
211
212 // Send email
213 let response = self
214 .mailer
215 .send(email_message)
216 .await
217 .map_err(|e| anyhow::anyhow!("Failed to send email: {}", e))?;
218
219 info!(
220 ?response,
221 recipient_email, subject, "Email sent successfully"
222 );
223 Ok(())
224 }
225
226 async fn send_email_with_attachments(
227 &self,
228 recipient_email: &str,
229 subject: &str,
230 plain_body: String,
231 html_body: String,
232 attachments: Vec<EmailAttachment>,
233 ) -> Result<()> {
234 // Check throttle - fast exit if recipient has received too many emails recently
235 match self.throttle.check_and_record(recipient_email).await {
236 Ok(true) => {
237 // Allowed, continue
238 }
239 Ok(false) => {
240 warn!(
241 recipient_email,
242 subject,
243 "Email with attachments throttled - recipient has received too many emails recently"
244 );
245 return Ok(());
246 }
247 Err(err) => {
248 warn!(
249 ?err,
250 recipient_email,
251 subject,
252 "Throttle check failed for email with attachments, allowing email to proceed"
253 );
254 // Continue anyway - don't let throttle errors block email delivery
255 }
256 }
257
258 // Parse addresses
259 let from_address = FROM_EMAIL_ADDRESS
260 .parse::<Address>()
261 .map_err(|e| anyhow::anyhow!("Invalid from address: {}", e))?;
262
263 let to_address = recipient_email
264 .parse::<Address>()
265 .map_err(|e| anyhow::anyhow!("Invalid recipient address {}: {}", recipient_email, e))?;
266
267 // Build the alternative text/html part
268 let content_part = MultiPart::alternative_plain_html(plain_body, html_body);
269
270 // Build mixed multipart with content and attachments
271 let mut mixed_multipart = MultiPart::mixed().multipart(content_part);
272
273 // Add each attachment
274 for attachment in attachments {
275 let content_type = attachment
276 .content_type
277 .parse::<ContentType>()
278 .map_err(|e| {
279 anyhow::anyhow!("Invalid content type {}: {}", attachment.content_type, e)
280 })?;
281
282 let attachment_part =
283 LettreAttachment::new(attachment.filename).body(attachment.content, content_type);
284
285 mixed_multipart = mixed_multipart.singlepart(attachment_part);
286 }
287
288 // Build email message
289 let email_message = Message::builder()
290 .from(Mailbox::new(Some(FROM_EMAIL_NAME.to_owned()), from_address))
291 .to(Mailbox::new(None, to_address))
292 .subject(subject)
293 .multipart(mixed_multipart)
294 .map_err(|e| anyhow::anyhow!("Failed to build email: {}", e))?;
295
296 // Send email
297 let response = self
298 .mailer
299 .send(email_message)
300 .await
301 .map_err(|e| anyhow::anyhow!("Failed to send email: {}", e))?;
302
303 info!(
304 ?response,
305 recipient_email, subject, "Email with attachments sent successfully"
306 );
307 Ok(())
308 }
309}
310
311#[async_trait]
312impl Emailer for LettreEmailer {
313 async fn notify_rsvp_going(
314 &self,
315 receiver_email: &str,
316 receiver_did: &str,
317 rsvp_did: &str,
318 rsvp_identity: &str,
319 event_name: &str,
320 event_url: &str,
321 ) -> Result<()> {
322 // Check if we should send the email (denylist checks)
323 if !self
324 .should_send_email(Some(rsvp_did), receiver_did, receiver_email)
325 .await?
326 {
327 info!(
328 receiver_did,
329 rsvp_did, "Skipping RSVP going notification due to denylist"
330 );
331 return Ok(());
332 }
333
334 // Check if the receiver has RSVP notifications enabled
335 if !crate::storage::notification::rsvp_created_enabled(&self.storage_pool, receiver_did)
336 .await?
337 {
338 info!(
339 receiver_did,
340 rsvp_did,
341 "Skipping RSVP going notification - receiver has not enabled RSVP notifications"
342 );
343 return Ok(());
344 }
345
346 // Generate unsubscribe token for disabling RSVP notifications
347 let unsubscribe_action = UnsubscribeAction::DisableRsvpNotifications {
348 receiver: receiver_did.to_string(),
349 };
350 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?;
351 let unsubscribe_url = format!(
352 "https://{}/unsubscribe/{}",
353 self.external_base, unsubscribe_token
354 );
355
356 // Render email templates
357 let ctx = contexts::rsvp_going(rsvp_identity, event_name, event_url, &unsubscribe_url);
358 let plain_body = self
359 .template_engine
360 .render_text("rsvp_going", ctx.clone())?;
361 let html_body = self.template_engine.render_html("rsvp_going", ctx)?;
362
363 self.send_email(
364 receiver_email,
365 EMAIL_RSVP_GOING_SUBJECT,
366 plain_body,
367 html_body,
368 )
369 .await
370 }
371
372 async fn notify_rsvp_accepted(
373 &self,
374 receiver_email: &str,
375 receiver_did: &str,
376 event_name: &str,
377 event_url: &str,
378 ) -> Result<()> {
379 // Check if we should send the email (denylist checks)
380 if !self
381 .should_send_email(None, receiver_did, receiver_email)
382 .await?
383 {
384 info!(
385 receiver_did,
386 "Skipping RSVP accepted notification due to denylist"
387 );
388 return Ok(());
389 }
390
391 // Generate unsubscribe token for all notifications
392 let unsubscribe_action = UnsubscribeAction::DisableRsvpNotifications {
393 receiver: receiver_did.to_string(),
394 };
395 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?;
396 let unsubscribe_url = format!(
397 "https://{}/unsubscribe/{}",
398 self.external_base, unsubscribe_token
399 );
400
401 // Render email templates
402 let ctx = contexts::rsvp_accepted(event_name, event_url, &unsubscribe_url);
403 let plain_body = self
404 .template_engine
405 .render_text("rsvp_accepted", ctx.clone())?;
406 let html_body = self.template_engine.render_html("rsvp_accepted", ctx)?;
407
408 self.send_email(
409 receiver_email,
410 EMAIL_RSVP_ACCEPTED_SUBJECT,
411 plain_body,
412 html_body,
413 )
414 .await
415 }
416
417 async fn notify_event_changed(
418 &self,
419 receiver_email: &str,
420 receiver_did: &str,
421 event_did: &str,
422 event_name: &str,
423 event_url: &str,
424 ) -> Result<()> {
425 // Check if we should send the email (denylist checks)
426 if !self
427 .should_send_email(Some(event_did), receiver_did, receiver_email)
428 .await?
429 {
430 info!(
431 receiver_did,
432 event_did, "Skipping event changed notification due to denylist"
433 );
434 return Ok(());
435 }
436
437 // Check if the receiver has event change notifications enabled
438 if !crate::storage::notification::event_change_enabled(&self.storage_pool, receiver_did)
439 .await?
440 {
441 info!(
442 receiver_did,
443 event_did,
444 "Skipping event changed notification - receiver has not enabled event change notifications"
445 );
446 return Ok(());
447 }
448
449 // Generate unsubscribe token for disabling event change notifications
450 let unsubscribe_action = UnsubscribeAction::DisableEventChangeNotifications {
451 receiver: receiver_did.to_string(),
452 };
453 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?;
454 let unsubscribe_url = format!(
455 "https://{}/unsubscribe/{}",
456 self.external_base, unsubscribe_token
457 );
458
459 // Render email templates
460 let ctx = contexts::event_changed(event_name, event_url, &unsubscribe_url);
461 let plain_body = self
462 .template_engine
463 .render_text("event_changed", ctx.clone())?;
464 let html_body = self.template_engine.render_html("event_changed", ctx)?;
465
466 self.send_email(
467 receiver_email,
468 EMAIL_EVENT_CHANGED_SUBJECT,
469 plain_body,
470 html_body,
471 )
472 .await
473 }
474
475 async fn send_email_confirmation(
476 &self,
477 recipient_email: &str,
478 confirmation_url: &str,
479 ) -> Result<()> {
480 // Render email templates (no unsubscribe link for confirmation emails)
481 let ctx = contexts::confirmation(confirmation_url, None);
482 let plain_body = self
483 .template_engine
484 .render_text("confirmation", ctx.clone())?;
485 let html_body = self.template_engine.render_html("confirmation", ctx)?;
486
487 self.send_email(
488 recipient_email,
489 EMAIL_CONFIRMATION_SUBJECT,
490 plain_body,
491 html_body,
492 )
493 .await
494 }
495
496 async fn send_event_reminder_24h(
497 &self,
498 receiver_email: &str,
499 receiver_did: &str,
500 event_name: &str,
501 event_start_time: &str,
502 event_url: &str,
503 ) -> Result<()> {
504 // Check if we should send the email (denylist checks only - no sender)
505 if !self
506 .should_send_email(None, receiver_did, receiver_email)
507 .await?
508 {
509 info!(
510 receiver_did,
511 "Skipping 24h reminder - receiver is in denylist"
512 );
513 return Ok(());
514 }
515
516 // Check if the receiver has 24h reminder notifications enabled
517 if !crate::storage::notification::event_24h_start(&self.storage_pool, receiver_did).await? {
518 info!(
519 receiver_did,
520 "Skipping 24h reminder - receiver has not enabled 24h reminders"
521 );
522 return Ok(());
523 }
524
525 // Generate unsubscribe token for disabling 24h reminder notifications
526 let unsubscribe_action = UnsubscribeAction::Disable24hReminderNotifications {
527 receiver: receiver_did.to_string(),
528 };
529 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?;
530 let unsubscribe_url = format!(
531 "https://{}/unsubscribe/{}",
532 self.external_base, unsubscribe_token
533 );
534
535 // Render email templates
536 let ctx =
537 contexts::event_reminder_24h(event_name, event_start_time, event_url, &unsubscribe_url);
538 let plain_body = self
539 .template_engine
540 .render_text("event_reminder_24h", ctx.clone())?;
541 let html_body = self
542 .template_engine
543 .render_html("event_reminder_24h", ctx)?;
544
545 self.send_email(
546 receiver_email,
547 EMAIL_REMINDER_24H_SUBJECT,
548 plain_body,
549 html_body,
550 )
551 .await
552 }
553
554 async fn send_event_summary(
555 &self,
556 receiver_email: &str,
557 receiver_did: &str,
558 event_name: &str,
559 event_description: Option<&str>,
560 event_location: Option<&str>,
561 event_start_time: &str,
562 event_end_time: Option<&str>,
563 event_url: &str,
564 ics_attachment: Vec<u8>,
565 ) -> Result<()> {
566 // Check if we should send the email (denylist checks only - no sender)
567 if !self
568 .should_send_email(None, receiver_did, receiver_email)
569 .await?
570 {
571 info!(
572 receiver_did,
573 "Skipping event summary - receiver is in denylist"
574 );
575 return Ok(());
576 }
577
578 // Check if the receiver has RSVP summary notifications enabled
579 if !crate::storage::notification::rsvp_summary_enabled(&self.storage_pool, receiver_did)
580 .await?
581 {
582 info!(
583 receiver_did,
584 "Skipping event summary - receiver has not enabled RSVP summary notifications"
585 );
586 return Ok(());
587 }
588
589 // Generate unsubscribe token for disabling RSVP summary notifications
590 let unsubscribe_action = UnsubscribeAction::DisableRsvpSummaryNotifications {
591 receiver: receiver_did.to_string(),
592 };
593 let unsubscribe_token = generate_token(&unsubscribe_action, &self.email_secret_key)?;
594 let unsubscribe_url = format!(
595 "https://{}/unsubscribe/{}",
596 self.external_base, unsubscribe_token
597 );
598
599 // Render email templates
600 let ctx = contexts::event_summary(
601 event_name,
602 event_description,
603 event_location,
604 event_start_time,
605 event_end_time,
606 event_url,
607 &unsubscribe_url,
608 );
609 let plain_body = self
610 .template_engine
611 .render_text("event_summary", ctx.clone())?;
612 let html_body = self.template_engine.render_html("event_summary", ctx)?;
613
614 // Create ICS attachment
615 let attachment = EmailAttachment {
616 filename: format!("{}.ics", sanitize_filename(event_name)),
617 content_type: "text/calendar; charset=utf-8; method=PUBLISH".to_string(),
618 content: ics_attachment,
619 };
620
621 self.send_email_with_attachments(
622 receiver_email,
623 EMAIL_EVENT_SUMMARY_SUBJECT,
624 plain_body,
625 html_body,
626 vec![attachment],
627 )
628 .await
629 }
630
631 async fn should_send_email(
632 &self,
633 sender_did: Option<&str>,
634 receiver_did: &str,
635 receiver_email: &str,
636 ) -> Result<bool> {
637 // Check if the receiver's email is in the denylist
638 if denylist_exists(&self.storage_pool, &[receiver_email]).await? {
639 info!(
640 receiver_email,
641 "Email is in denylist, skipping notification"
642 );
643 return Ok(false);
644 }
645
646 // Check if receiver has unsubscribed from all notifications
647 let unsubscribe_all_pattern = notification_unsubscribe_all_pattern(receiver_did);
648 if denylist_exists(&self.storage_pool, &[&unsubscribe_all_pattern]).await? {
649 info!(
650 receiver_did,
651 "Receiver has unsubscribed from all notifications"
652 );
653 return Ok(false);
654 }
655
656 // Check if receiver has unsubscribed from this specific sender (if provided)
657 if let Some(sender) = sender_did {
658 let unsubscribe_sender_pattern =
659 notification_unsubscribe_sender_pattern(receiver_did, sender);
660 if denylist_exists(&self.storage_pool, &[&unsubscribe_sender_pattern]).await? {
661 info!(
662 sender_did = sender,
663 receiver_did, "Receiver has unsubscribed from notifications from this sender"
664 );
665 return Ok(false);
666 }
667 }
668
669 // All checks passed
670 Ok(true)
671 }
672}
673
674/// No-op emailer for testing or when email is disabled
675pub struct NoOpEmailer;
676
677#[async_trait]
678impl Emailer for NoOpEmailer {
679 async fn notify_rsvp_going(
680 &self,
681 _receiver_email: &str,
682 _receiver_did: &str,
683 _rsvp_did: &str,
684 _rsvp_identity: &str,
685 _event_name: &str,
686 _event_url: &str,
687 ) -> Result<()> {
688 info!("NoOpEmailer: Would send RSVP going notification");
689 Ok(())
690 }
691
692 async fn notify_event_changed(
693 &self,
694 _receiver_email: &str,
695 _receiver_did: &str,
696 _event_did: &str,
697 _event_name: &str,
698 _event_url: &str,
699 ) -> Result<()> {
700 info!("NoOpEmailer: Would send event changed notification");
701 Ok(())
702 }
703
704 async fn send_email_confirmation(
705 &self,
706 _recipient_email: &str,
707 _confirmation_url: &str,
708 ) -> Result<()> {
709 info!("NoOpEmailer: Would send email confirmation");
710 Ok(())
711 }
712
713 async fn send_event_reminder_24h(
714 &self,
715 _receiver_email: &str,
716 _receiver_did: &str,
717 _event_name: &str,
718 _event_start_time: &str,
719 _event_url: &str,
720 ) -> Result<()> {
721 info!("NoOpEmailer: Would send 24h event reminder");
722 Ok(())
723 }
724
725 async fn send_event_summary(
726 &self,
727 _receiver_email: &str,
728 _receiver_did: &str,
729 _event_name: &str,
730 _event_description: Option<&str>,
731 _event_location: Option<&str>,
732 _event_start_time: &str,
733 _event_end_time: Option<&str>,
734 _event_url: &str,
735 _ics_attachment: Vec<u8>,
736 ) -> Result<()> {
737 info!("NoOpEmailer: Would send event summary with ICS attachment");
738 Ok(())
739 }
740
741 async fn notify_rsvp_accepted(
742 &self,
743 _receiver_email: &str,
744 _receiver_did: &str,
745 _event_name: &str,
746 _event_url: &str,
747 ) -> Result<()> {
748 info!("NoOpEmailer: Would send RSVP accepted notification");
749 Ok(())
750 }
751
752 async fn should_send_email(
753 &self,
754 _sender_did: Option<&str>,
755 _receiver_did: &str,
756 _receiver_email: &str,
757 ) -> Result<bool> {
758 Ok(true)
759 }
760}