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}