postmark 2.0.1

Postmark rust client
Documentation
use std::borrow::Cow;
use std::collections::HashMap;

use crate::Endpoint;
use crate::api::email::{Attachment, Header, TrackLink};
use crate::api::templates::TemplateId;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use typed_builder::TypedBuilder;

#[derive(Debug, Clone, PartialEq, Serialize, TypedBuilder)]
#[serde(rename_all = "PascalCase")]
pub struct SendBulkEmailRequest {
    pub from: String,
    pub messages: Vec<BulkMessage>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub subject: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub html_body: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text_body: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub template_id: Option<TemplateId>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub template_alias: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub inline_css: Option<bool>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<HashMap<String, String>>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message_stream: Option<String>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub track_opens: Option<bool>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub track_links: Option<TrackLink>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attachments: Option<Vec<Attachment>>,
    #[builder(default, setter(into, strip_option))]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub headers: Option<Vec<Header>>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "PascalCase")]
pub struct BulkMessage {
    pub to: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cc: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bcc: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub template_model: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<HashMap<String, String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub headers: Option<Vec<Header>>,
}

#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SendBulkEmailResponse {
    #[serde(rename = "ID", default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub submitted_at: Option<String>,
    #[serde(default)]
    pub error_code: i64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub errors: Option<HashMap<String, Vec<BulkEmailFieldError>>>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BulkEmailFieldError {
    pub error_code: i64,
    pub message: String,
}

impl Endpoint for SendBulkEmailRequest {
    type Request = SendBulkEmailRequest;
    type Response = SendBulkEmailResponse;

    fn endpoint(&self) -> Cow<'static, str> {
        "/email/bulk".into()
    }

    fn body(&self) -> &Self::Request {
        self
    }
}

#[cfg(test)]
mod tests {
    use httptest::matchers::request;
    use httptest::{Expectation, Server, responders::*};
    use serde_json::json;

    use crate::Query;
    use crate::reqwest::PostmarkClient;

    use super::*;

    #[tokio::test]
    async fn send_bulk_email() {
        let server = Server::run();

        server.expect(
            Expectation::matching(request::method_path("POST", "/email/bulk")).respond_with(
                json_encoded(json!({
                    "ID": "f24af63c-533d-4b7a-ad65-4a7b3202d3a7",
                    "Status": "Accepted",
                    "SubmittedAt": "2024-03-17T07:25:01.4178645-05:00"
                })),
            ),
        );

        let client = PostmarkClient::builder()
            .base_url(server.url("/").to_string())
            .build();

        let req = SendBulkEmailRequest::builder()
            .from("sender@example.com".to_string())
            .subject("This is a bulk email for {{FirstName}}")
            .text_body("Hi, {{FirstName}}")
            .message_stream("broadcast")
            .messages(vec![BulkMessage {
                to: "receiver1@example.com".to_string(),
                template_model: Some(json!({"FirstName":"Bob"})),
                ..Default::default()
            }])
            .build();

        let resp = req.execute(&client).await.expect("json decode");
        assert_eq!(resp.status.as_deref(), Some("Accepted"));
        assert_eq!(resp.error_code, 0);
    }

    #[tokio::test]
    async fn send_bulk_email_error_envelope() {
        let server = Server::run();

        server.expect(
            Expectation::matching(request::method_path("POST", "/email/bulk")).respond_with(
                json_encoded(json!({
                    "ErrorCode": 11,
                    "Message": "Multiple errors occurred. Inspect the Errors property for more information.",
                    "Errors": {
                        "From": [
                            { "ErrorCode": 300, "Message": "Invalid 'From' address: 'test'." }
                        ],
                        "To": [
                            { "ErrorCode": 300, "Message": "Invalid 'To' address: 'test'." }
                        ]
                    }
                })),
            ),
        );

        let client = PostmarkClient::builder()
            .base_url(server.url("/").to_string())
            .build();

        let req = SendBulkEmailRequest::builder()
            .from("test".to_string())
            .text_body("Hi")
            .messages(vec![BulkMessage {
                to: "test".to_string(),
                ..Default::default()
            }])
            .build();

        let resp = req.execute(&client).await.expect("json decode");
        assert_eq!(resp.error_code, 11);
        assert!(resp.id.is_none());
        assert!(resp.status.is_none());
        let errors = resp.errors.expect("Errors map present");
        assert_eq!(errors.get("From").unwrap()[0].error_code, 300);
        assert_eq!(errors.get("To").unwrap()[0].error_code, 300);
    }
}