Skip to main content

postmark/api/bulk/
send_bulk.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3
4use crate::Endpoint;
5use crate::api::email::{Attachment, Header, TrackLink};
6use crate::api::templates::TemplateId;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use typed_builder::TypedBuilder;
10
11#[derive(Debug, Clone, PartialEq, Serialize, TypedBuilder)]
12#[serde(rename_all = "PascalCase")]
13pub struct SendBulkEmailRequest {
14    pub from: String,
15    pub messages: Vec<BulkMessage>,
16    #[builder(default, setter(into, strip_option))]
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub reply_to: Option<String>,
19    #[builder(default, setter(into, strip_option))]
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub subject: Option<String>,
22    #[builder(default, setter(into, strip_option))]
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub html_body: Option<String>,
25    #[builder(default, setter(into, strip_option))]
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub text_body: Option<String>,
28    #[builder(default, setter(into, strip_option))]
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub template_id: Option<TemplateId>,
31    #[builder(default, setter(into, strip_option))]
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub template_alias: Option<String>,
34    #[builder(default, setter(into, strip_option))]
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub inline_css: Option<bool>,
37    #[builder(default, setter(into, strip_option))]
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub tag: Option<String>,
40    #[builder(default, setter(into, strip_option))]
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub metadata: Option<HashMap<String, String>>,
43    #[builder(default, setter(into, strip_option))]
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub message_stream: Option<String>,
46    #[builder(default, setter(into, strip_option))]
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub track_opens: Option<bool>,
49    #[builder(default, setter(into, strip_option))]
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub track_links: Option<TrackLink>,
52    #[builder(default, setter(into, strip_option))]
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub attachments: Option<Vec<Attachment>>,
55    #[builder(default, setter(into, strip_option))]
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub headers: Option<Vec<Header>>,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
61#[serde(rename_all = "PascalCase")]
62pub struct BulkMessage {
63    pub to: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub cc: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub bcc: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub template_model: Option<Value>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub metadata: Option<HashMap<String, String>>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub headers: Option<Vec<Header>>,
74}
75
76#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
77#[serde(rename_all = "PascalCase")]
78pub struct SendBulkEmailResponse {
79    #[serde(rename = "ID", default, skip_serializing_if = "Option::is_none")]
80    pub id: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub status: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub submitted_at: Option<String>,
85    #[serde(default)]
86    pub error_code: i64,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub message: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub errors: Option<HashMap<String, Vec<BulkEmailFieldError>>>,
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[serde(rename_all = "PascalCase")]
95pub struct BulkEmailFieldError {
96    pub error_code: i64,
97    pub message: String,
98}
99
100impl Endpoint for SendBulkEmailRequest {
101    type Request = SendBulkEmailRequest;
102    type Response = SendBulkEmailResponse;
103
104    fn endpoint(&self) -> Cow<'static, str> {
105        "/email/bulk".into()
106    }
107
108    fn body(&self) -> &Self::Request {
109        self
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use httptest::matchers::request;
116    use httptest::{Expectation, Server, responders::*};
117    use serde_json::json;
118
119    use crate::Query;
120    use crate::reqwest::PostmarkClient;
121
122    use super::*;
123
124    #[tokio::test]
125    async fn send_bulk_email() {
126        let server = Server::run();
127
128        server.expect(
129            Expectation::matching(request::method_path("POST", "/email/bulk")).respond_with(
130                json_encoded(json!({
131                    "ID": "f24af63c-533d-4b7a-ad65-4a7b3202d3a7",
132                    "Status": "Accepted",
133                    "SubmittedAt": "2024-03-17T07:25:01.4178645-05:00"
134                })),
135            ),
136        );
137
138        let client = PostmarkClient::builder()
139            .base_url(server.url("/").to_string())
140            .build();
141
142        let req = SendBulkEmailRequest::builder()
143            .from("sender@example.com".to_string())
144            .subject("This is a bulk email for {{FirstName}}")
145            .text_body("Hi, {{FirstName}}")
146            .message_stream("broadcast")
147            .messages(vec![BulkMessage {
148                to: "receiver1@example.com".to_string(),
149                template_model: Some(json!({"FirstName":"Bob"})),
150                ..Default::default()
151            }])
152            .build();
153
154        let resp = req.execute(&client).await.expect("json decode");
155        assert_eq!(resp.status.as_deref(), Some("Accepted"));
156        assert_eq!(resp.error_code, 0);
157    }
158
159    #[tokio::test]
160    async fn send_bulk_email_error_envelope() {
161        let server = Server::run();
162
163        server.expect(
164            Expectation::matching(request::method_path("POST", "/email/bulk")).respond_with(
165                json_encoded(json!({
166                    "ErrorCode": 11,
167                    "Message": "Multiple errors occurred. Inspect the Errors property for more information.",
168                    "Errors": {
169                        "From": [
170                            { "ErrorCode": 300, "Message": "Invalid 'From' address: 'test'." }
171                        ],
172                        "To": [
173                            { "ErrorCode": 300, "Message": "Invalid 'To' address: 'test'." }
174                        ]
175                    }
176                })),
177            ),
178        );
179
180        let client = PostmarkClient::builder()
181            .base_url(server.url("/").to_string())
182            .build();
183
184        let req = SendBulkEmailRequest::builder()
185            .from("test".to_string())
186            .text_body("Hi")
187            .messages(vec![BulkMessage {
188                to: "test".to_string(),
189                ..Default::default()
190            }])
191            .build();
192
193        let resp = req.execute(&client).await.expect("json decode");
194        assert_eq!(resp.error_code, 11);
195        assert!(resp.id.is_none());
196        assert!(resp.status.is_none());
197        let errors = resp.errors.expect("Errors map present");
198        assert_eq!(errors.get("From").unwrap()[0].error_code, 300);
199        assert_eq!(errors.get("To").unwrap()[0].error_code, 300);
200    }
201}