puerto 0.19.0

Puerto — Rust full-stack DDD framework CLI. Scaffolds Clean Architecture projects with auto-wired DI, SQLx support, and zero manual wiring. Formerly puerto-framework.
//! `mail_{{ name }}`: sends the `{{ name }}` mail on {% for e in events %}`{{ e.event_type }}`{% if not loop.last %}, {% endif %}{% endfor %}. Written once by
//! `puerto generate mail --on`; it is yours now — change who it goes to, skip some events, add
//! values the snapshot does not carry.
//!
//! Delivery is at-least-once: `send_once` keeps a `sent_mails` row per event, so a redelivered
//! event does not send the mail again. Keep `MAIL` as it is: renaming it sends every mail again.

use std::sync::Arc;

use async_trait::async_trait;
use business::domain::mail::{{ name }}::{ {{- pascal }}Mail, {{ pascal }}Mailer};
{%- for import in imports %}
use {{ import }};
{%- endfor %}
use puerto_core::events::{EventEnvelope, EventHandler, HandlerOutcome, REASON_PAYLOAD_INVALID};
use puerto_core::mail::SentMails;
use puerto_mail::send_once;

/// The key of this mail in `sent_mails`.
const MAIL: &str = "{{ name }}";

pub struct Send{{ pascal }}Mail {
    pub mailer: Arc<dyn {{ pascal }}Mailer>,
    pub sent_mails: Arc<dyn SentMails>,
}

#[async_trait]
impl EventHandler for Send{{ pascal }}Mail {
    async fn handle(&self, event: &EventEnvelope) -> HandlerOutcome {
        let mail = match event.event_type.as_str() {
{%- for e in events %}
            {{ e.constant }} => match event.decode::<{{ e.entity_pascal }}Snapshot>() {
                Ok({{ e.entity_snake }}) => {{ pascal }}Mail {
                    to: {{ e.entity_snake }}.{{ to }}.clone(),
{%- for f in fields %}
                    {{ f.name }}: {{ e.entity_snake }}.{{ f.name }},
{%- endfor %}
                },
                Err(err) => return HandlerOutcome::dead_letter(REASON_PAYLOAD_INVALID, err.detail),
            },
{%- endfor %}
            // Not one of its events (puerto.toml `[[mail]] on`): nothing to send.
            _ => return HandlerOutcome::Ack,
        };
        if mail.to.trim().is_empty() {
            tracing::warn!(event_id = %event.id, "mail_{{ name }}: no recipient, not sent");
            return HandlerOutcome::Ack;
        }
        send_once(self.sent_mails.as_ref(), event.id, MAIL, self.mailer.send(&mail)).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use business::domain::mail::{{ name }}::mocks::Mock{{ pascal }}Mailer;
    use business::domain::{{ first.entity_snake }}::events::{{ first.entity_pascal }}Event;
    use business::tests::mothers::{{ first.entity_snake }}_mother::{{ first.entity_pascal }}Mother;
    use puerto_core::events::REASON_PERMANENT_ERROR;
    use puerto_core::mail::MailError;
    use puerto_mail::testing::InMemorySentMails;

    fn {{ first.const_lower }}() -> ({{ first.entity_pascal }}Snapshot, EventEnvelope) {
        let snapshot = {{ first.entity_pascal }}Snapshot::from_entity(&{{ first.entity_pascal }}Mother::random());
        let event = EventEnvelope::new(&{{ first.entity_pascal }}Event::{{ first.variant }}(snapshot.clone()));
        (snapshot, event)
    }

    /// A mailer expected to send `times` mails, each answering `result`.
    fn mailer(times: usize, result: Result<(), MailError>) -> Mock{{ pascal }}Mailer {
        let mut mailer = Mock{{ pascal }}Mailer::new();
        mailer
            .expect_send()
            .times(times)
            .returning(move |_| result.clone());
        mailer
    }

    fn handler(mailer: Mock{{ pascal }}Mailer) -> Send{{ pascal }}Mail {
        Send{{ pascal }}Mail {
            mailer: Arc::new(mailer),
            sent_mails: Arc::new(InMemorySentMails::new()),
        }
    }

    #[tokio::test]
    async fn should_send_the_mail_to_the_{{ to }}_when_a_{{ first.event_type }}_event_arrives() {
        // Arrange
        let (snapshot, event) = {{ first.const_lower }}();
        let mut mock = Mock{{ pascal }}Mailer::new();
        mock.expect_send()
            .withf(move |mail| mail.to == snapshot.{{ to }})
            .times(1)
            .returning(|_| Ok(()));

        // Act
        let outcome = handler(mock).handle(&event).await;

        // Assert
        assert_eq!(outcome, HandlerOutcome::Ack);
    }

    #[tokio::test]
    async fn should_send_it_once_when_the_same_event_is_delivered_twice() {
        // Arrange
        let (_, event) = {{ first.const_lower }}();
        let handler = handler(mailer(1, Ok(())));

        // Act
        let first = handler.handle(&event).await;
        let second = handler.handle(&event).await;

        // Assert
        assert_eq!((first, second), (HandlerOutcome::Ack, HandlerOutcome::Ack));
    }

    #[tokio::test]
    async fn should_dead_letter_without_sending_when_the_payload_is_malformed() {
        // Arrange
        let (_, mut event) = {{ first.const_lower }}();
        event.payload = serde_json::json!({ "unexpected": true });

        // Act
        let outcome = handler(mailer(0, Ok(()))).handle(&event).await;

        // Assert
        assert!(
            matches!(
                outcome,
                HandlerOutcome::DeadLetter {
                    reason: REASON_PAYLOAD_INVALID,
                    ..
                }
            ),
            "{outcome:?}"
        );
    }

    #[tokio::test]
    async fn should_retry_when_the_mail_server_is_unavailable() {
        // Arrange
        let (_, event) = {{ first.const_lower }}();
        let refused = Err(MailError::Transient("connection refused".to_string()));

        // Act
        let outcome = handler(mailer(1, refused)).handle(&event).await;

        // Assert
        assert_eq!(outcome, HandlerOutcome::Retry);
    }

    #[tokio::test]
    async fn should_dead_letter_when_the_mail_is_rejected() {
        // Arrange
        let (_, event) = {{ first.const_lower }}();
        let rejected = Err(MailError::Permanent("550 no such user".to_string()));

        // Act
        let outcome = handler(mailer(1, rejected)).handle(&event).await;

        // Assert
        assert!(
            matches!(
                outcome,
                HandlerOutcome::DeadLetter {
                    reason: REASON_PERMANENT_ERROR,
                    ..
                }
            ),
            "{outcome:?}"
        );
    }
}