The smokesignal.events web application
at main 760 lines 24 kB view raw
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}