restate-email 0.9.0

Restate email worker contracts and service adapter
Documentation
use std::time::Duration;

use email_message::{Address, Attachment, Body, ContentType, EmailAddress, Envelope, Message};
use restate_email::{
    CorrelationId, IdempotencyKey, SendOptions, SendRequest, SendRequestSeed, TransportKey,
    TransportOptionRegistry,
};
use serde::de::DeserializeSeed as _;

#[cfg(feature = "transport-resend")]
use email_kit::transport::transport_option_registry;

fn fixture_message() -> Result<email_message::OutboundMessage, Box<dyn std::error::Error>> {
    let message = Message::builder(Body::text("hello"))
        .from_mailbox("from@example.com".parse()?)
        .add_to(Address::Mailbox("to@example.com".parse()?))
        .subject("Release fixture")
        .add_attachment(
            Attachment::bytes(ContentType::try_from("text/plain")?, b"report".to_vec())
                .with_filename("report.txt"),
        )
        .build()?;

    Ok(email_message::OutboundMessage::new(message)?)
}

fn fixture_envelope() -> Result<Envelope, Box<dyn std::error::Error>> {
    Ok(Envelope::new(
        Some("bounce@example.com".parse::<EmailAddress>()?),
        vec!["to@example.com".parse::<EmailAddress>()?],
    ))
}

fn base_fixture_request() -> Result<SendRequest, Box<dyn std::error::Error>> {
    let send_options = SendOptions::new()
        .with_envelope(fixture_envelope()?)
        .with_timeout(Duration::from_millis(2_500))
        .with_idempotency_key(IdempotencyKey::new("release-fixture-1")?)
        .with_correlation_id(CorrelationId::new("corr-fixture-1")?);

    Ok(SendRequest {
        transport: TransportKey::new("transactional")?,
        message: fixture_message()?,
        options: send_options,
    })
}

fn assert_matches_fixture(
    request: &SendRequest,
    fixture: &str,
    registry: &TransportOptionRegistry,
) -> Result<SendRequest, Box<dyn std::error::Error>> {
    let expected: serde_json::Value = serde_json::from_str(fixture)?;
    let actual = serde_json::to_value(request)?;

    assert_eq!(actual, expected);

    let decoded = SendRequestSeed::new(registry).deserialize(expected.clone())?;
    assert_eq!(serde_json::to_value(&decoded)?, expected);

    Ok(decoded)
}

#[test]
fn send_request_wire_fixture_matches_base_payload() -> Result<(), Box<dyn std::error::Error>> {
    let request = base_fixture_request()?;
    let decoded = assert_matches_fixture(
        &request,
        include_str!("fixtures/send_request_base.json"),
        &TransportOptionRegistry::new(),
    )?;
    let options = decoded.options;

    assert_eq!(options.timeout, Some(Duration::from_millis(2_500)));
    assert_eq!(
        options.idempotency_key.as_ref().map(IdempotencyKey::as_str),
        Some("release-fixture-1")
    );

    Ok(())
}

#[cfg(feature = "transport-resend")]
fn resend_fixture_request() -> Result<SendRequest, Box<dyn std::error::Error>> {
    use email_kit::transport::resend::ResendSendOptions;

    let mut send_options = SendOptions::new()
        .with_envelope(fixture_envelope()?)
        .with_timeout(Duration::from_millis(2_500))
        .with_idempotency_key(IdempotencyKey::new("release-fixture-1")?)
        .with_correlation_id(CorrelationId::new("corr-fixture-1")?);
    send_options
        .transport_options
        .insert(ResendSendOptions::new().with_tag("tenant", "blue"));

    Ok(SendRequest {
        transport: TransportKey::new("transactional")?,
        message: fixture_message()?,
        options: send_options,
    })
}

#[cfg(feature = "transport-resend")]
#[test]
fn send_request_wire_fixture_matches_resend_payload() -> Result<(), Box<dyn std::error::Error>> {
    use email_kit::transport::resend::ResendSendOptions;

    let request = resend_fixture_request()?;
    let decoded = assert_matches_fixture(
        &request,
        include_str!("fixtures/send_request_resend.json"),
        &transport_option_registry(),
    )?;
    let options = decoded.options;
    let resend_options = options
        .transport_options
        .get::<ResendSendOptions>()
        .expect("resend options should hydrate");

    assert_eq!(resend_options.tags.len(), 1);
    assert_eq!(resend_options.tags[0].name, "tenant");
    assert_eq!(resend_options.tags[0].value, "blue");

    Ok(())
}