1#![doc = include_str!("../README.md")]
17#![forbid(unsafe_code)]
18
19mod inbound;
20
21pub use inbound::{MessageDetail, MessagePage, MessageQuery, MessageSummary};
22
23use async_trait::async_trait;
24use bytes::Bytes;
25use cratefield_core::{
26 Clock, HttpClient, HttpError, MailError, MailProvider, Mailer, Message, SendOutcome,
27 retry_after,
28};
29use http::header::{AUTHORIZATION, CONTENT_TYPE};
30use http::{Request, StatusCode};
31use std::fmt;
32use std::sync::Arc;
33use std::time::Duration;
34
35pub mod webhook;
36
37pub use webhook::{Envelope, OwlpostEvent, WebhookError, parse_verified};
38
39mod suppressions;
40
41pub use suppressions::Suppression;
42
43mod domains;
44
45pub use domains::{DnsRecord, Domain, DomainError, DomainStatus, RecordPurpose, RecordType};
46
47pub const DEFAULT_BASE_URL: &str = "https://api.owlpost.to";
52
53pub const MAX_BATCH: usize = 100;
56
57#[derive(Debug)]
61pub struct Owlpost {
62 client: OwlpostClient,
63 from: String,
64 reply_to: Option<String>,
65}
66
67impl Owlpost {
68 pub fn new(
72 http: Arc<dyn HttpClient>,
73 clock: Arc<dyn Clock>,
74 api_key: Option<String>,
75 from: impl Into<String>,
76 reply_to: Option<String>,
77 ) -> Self {
78 Self {
79 client: OwlpostClient::new(http, clock, api_key),
80 from: from.into(),
81 reply_to,
82 }
83 }
84
85 #[must_use]
88 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
89 self.client = self.client.with_base_url(base_url);
90 self
91 }
92
93 pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
97 Self::new(
98 http,
99 clock,
100 std::env::var("OWLPOST_API_KEY").ok(),
101 std::env::var("MAIL_FROM").unwrap_or_else(|_| String::new()),
102 std::env::var("MAIL_REPLY_TO").ok(),
103 )
104 .with_base_url(base_url_from_env(std::env::var("OWLPOST_BASE_URL").ok()))
105 }
106
107 #[allow(clippy::needless_pass_by_value)]
116 pub async fn send_with(
117 &self,
118 message: Message,
119 options: SendOptions,
120 ) -> Result<SendOutcome, MailError> {
121 match self
122 .client
123 .send_email(&self.from, self.reply_to.as_deref(), &message, &options)
124 .await
125 {
126 Ok(id) => Ok(SendOutcome::Sent { id }),
127 Err(OwlpostError::NotConfigured) => Ok(SendOutcome::NotConfigured),
128 Err(error) => Err(error.into()),
129 }
130 }
131
132 #[allow(clippy::needless_pass_by_value)]
141 pub async fn batch(
142 &self,
143 emails: Vec<(Message, SendOptions)>,
144 idempotency_key: Option<String>,
145 ) -> Result<Vec<String>, OwlpostError> {
146 self.client
147 .send_batch(
148 &self.from,
149 self.reply_to.as_deref(),
150 &emails,
151 idempotency_key.as_deref(),
152 )
153 .await
154 }
155
156 pub async fn get_email(&self, id: &str) -> Result<Email, OwlpostError> {
163 self.client.get_email(id).await
164 }
165}
166
167pub(crate) struct OwlpostClient {
172 http: Arc<dyn HttpClient>,
173 clock: Arc<dyn Clock>,
175 api_key: Option<String>,
176 base_url: String,
177}
178
179impl fmt::Debug for OwlpostClient {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 f.debug_struct("OwlpostClient")
183 .field("base_url", &self.base_url)
184 .field("api_key_configured", &self.api_key.is_some())
185 .finish_non_exhaustive()
186 }
187}
188
189impl OwlpostClient {
190 fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>, api_key: Option<String>) -> Self {
193 Self {
194 http,
195 clock,
196 api_key: normalize_key(api_key),
197 base_url: DEFAULT_BASE_URL.to_owned(),
198 }
199 }
200
201 fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
203 self.base_url = base_url.into();
204 self
205 }
206
207 fn transport(&self, err: impl fmt::Display) -> OwlpostError {
209 OwlpostError::Mail(MailError::Transport(self.redact(err.to_string())))
210 }
211
212 async fn call(
216 &self,
217 method: http::Method,
218 path: &str,
219 body: Option<Vec<u8>>,
220 idempotency_key: Option<&str>,
221 ) -> Result<String, OwlpostError> {
222 self.call_mapped(
223 method,
224 path,
225 body,
226 idempotency_key,
227 |client, status, text, retry_after| -> OwlpostError {
228 client.map_status(status, text, retry_after).into()
229 },
230 )
231 .await
232 }
233
234 async fn call_mapped<E>(
240 &self,
241 method: http::Method,
242 path: &str,
243 body: Option<Vec<u8>>,
244 idempotency_key: Option<&str>,
245 map: impl FnOnce(&OwlpostClient, StatusCode, &str, Option<Duration>) -> E,
246 ) -> Result<String, E>
247 where
248 E: From<OwlpostError> + fmt::Display,
249 {
250 let Some(api_key) = &self.api_key else {
251 tracing::info!(
252 provider = "owlpost",
253 outcome = "not_configured",
254 idempotency = idempotency_key.unwrap_or(""),
255 "mailer outcome"
256 );
257 return Err(E::from(OwlpostError::NotConfigured));
258 };
259 let outcome = match method {
262 http::Method::POST | http::Method::PUT | http::Method::PATCH => "sent",
263 http::Method::DELETE => "deleted",
264 _ => "read",
265 };
266 let mut builder = Request::builder()
267 .method(method)
268 .uri(format!("{}{path}", self.base_url.trim_end_matches('/')))
269 .header(AUTHORIZATION, format!("Bearer {api_key}"));
270 if body.is_some() {
271 builder = builder.header(CONTENT_TYPE, "application/json");
272 }
273 if let Some(key) = idempotency_key {
274 builder = builder.header("Idempotency-Key", key);
275 }
276 let request = builder
277 .body(Bytes::from(body.unwrap_or_default()))
278 .map_err(|err| E::from(self.transport(err)))?;
279
280 let response = self
281 .http
282 .send(request)
283 .await
284 .map_err(|err: HttpError| E::from(self.transport(err)))?;
285 let status = response.status();
286 let retry_after = retry_after(response.headers(), self.clock.as_ref());
287 let text = String::from_utf8_lossy(response.body()).to_string();
288 if status.is_success() {
289 tracing::info!(
290 provider = "owlpost",
291 code = status.as_u16(),
292 outcome,
293 idempotency = idempotency_key.unwrap_or(""),
294 "mailer outcome"
295 );
296 return Ok(text);
297 }
298 let error = map(self, status, &text, retry_after);
299 tracing::warn!(
300 provider = "owlpost",
301 code = status.as_u16(),
302 outcome = "failed",
303 idempotency = idempotency_key.unwrap_or(""),
304 error = %error,
305 "mailer outcome"
306 );
307 Err(error)
308 }
309
310 async fn send_email(
312 &self,
313 from_default: &str,
314 reply_to_default: Option<&str>,
315 message: &Message,
316 options: &SendOptions,
317 ) -> Result<String, OwlpostError> {
318 refuse_topic_with_transactional(options)?;
319 let payload = outbound(from_default, reply_to_default, message, options);
320 let body = serde_json::to_vec(&payload).map_err(|err| self.transport(err))?;
321 let text = self
322 .call(
323 http::Method::POST,
324 "/v1/emails",
325 Some(body),
326 message.idempotency_key.as_deref(),
327 )
328 .await?;
329 let parsed: SendResponse =
330 serde_json::from_str(&text).map_err(|err| self.transport(err))?;
331 Ok(parsed.id)
332 }
333
334 async fn send_batch(
336 &self,
337 from_default: &str,
338 reply_to_default: Option<&str>,
339 emails: &[(Message, SendOptions)],
340 idempotency_key: Option<&str>,
341 ) -> Result<Vec<String>, OwlpostError> {
342 if emails.is_empty() {
343 return Err(MailError::Invalid {
344 detail: "a batch must contain at least one email".to_owned(),
345 }
346 .into());
347 }
348 if emails.len() > MAX_BATCH {
349 return Err(MailError::Invalid {
350 detail: format!(
351 "a batch of {} emails exceeds the {MAX_BATCH} maximum",
352 emails.len()
353 ),
354 }
355 .into());
356 }
357 for (_, options) in emails {
358 refuse_topic_with_transactional(options)?;
359 }
360 let payloads: Vec<OutboundEmail<'_>> = emails
361 .iter()
362 .map(|(message, options)| outbound(from_default, reply_to_default, message, options))
363 .collect();
364 let body = serde_json::to_vec(&payloads).map_err(|err| self.transport(err))?;
365 let text = self
366 .call(
367 http::Method::POST,
368 "/v1/emails/batch",
369 Some(body),
370 idempotency_key,
371 )
372 .await?;
373 let parsed: BatchResponse =
374 serde_json::from_str(&text).map_err(|err| self.transport(err))?;
375 Ok(parsed.data.into_iter().map(|item| item.id).collect())
376 }
377
378 async fn get_email(&self, id: &str) -> Result<Email, OwlpostError> {
380 if !valid_email_id(id) {
381 return Err(MailError::Invalid {
382 detail: format!("invalid email id {id:?}"),
383 }
384 .into());
385 }
386 let text = self
387 .call(http::Method::GET, &format!("/v1/emails/{id}"), None, None)
388 .await?;
389 serde_json::from_str(&text).map_err(|err| self.transport(err))
390 }
391
392 fn map_status(
395 &self,
396 status: StatusCode,
397 body: &str,
398 retry_after: Option<Duration>,
399 ) -> MailError {
400 let detail = self.redact(problem_detail(body));
401 match status {
402 StatusCode::UNAUTHORIZED => MailError::Unauthorized,
403 StatusCode::FORBIDDEN => {
407 let lowered = detail.to_ascii_lowercase();
408 if lowered.contains("verify") || lowered.contains("domain") {
409 MailError::DomainNotVerified {
410 domain: extract_domain(&detail),
411 }
412 } else {
413 MailError::Unauthorized
414 }
415 }
416 StatusCode::TOO_MANY_REQUESTS => MailError::RateLimited { retry_after },
417 status if status.is_server_error() => {
418 MailError::Upstream(upstream_detail(detail, retry_after))
419 }
420 status if status.is_client_error() => MailError::Invalid { detail },
424 status => MailError::Upstream(format!("unexpected status {status}: {detail}")),
425 }
426 }
427
428 fn is_configured(&self) -> bool {
430 self.api_key.is_some()
431 }
432
433 fn redact(&self, text: String) -> String {
436 match &self.api_key {
437 Some(key) => text.replace(key.as_str(), "[redacted]"),
438 None => text,
439 }
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq)]
447#[non_exhaustive]
448pub enum OwlpostError {
449 NotConfigured,
452 Mail(MailError),
454}
455
456impl fmt::Display for OwlpostError {
457 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458 match self {
459 Self::NotConfigured => f.write_str("owlpost mailer is not configured (no API key)"),
460 Self::Mail(error) => error.fmt(f),
461 }
462 }
463}
464
465impl std::error::Error for OwlpostError {
466 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
467 match self {
468 Self::NotConfigured => None,
469 Self::Mail(error) => Some(error),
470 }
471 }
472}
473
474impl From<MailError> for OwlpostError {
475 fn from(error: MailError) -> Self {
476 Self::Mail(error)
477 }
478}
479
480impl From<OwlpostError> for MailError {
483 fn from(error: OwlpostError) -> Self {
484 match error {
485 OwlpostError::NotConfigured => MailError::Unauthorized,
486 OwlpostError::Mail(error) => error,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Default)]
494#[non_exhaustive]
495pub struct SendOptions {
496 pub stream: Option<Stream>,
498 pub topic: Option<String>,
500 pub cc: Vec<String>,
502 pub bcc: Vec<String>,
504 pub scheduled_at: Option<String>,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
510#[serde(rename_all = "lowercase")]
511#[non_exhaustive]
512pub enum Stream {
513 Transactional,
514 Broadcast,
515}
516
517#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)]
519#[non_exhaustive]
520pub struct Email {
521 pub id: String,
522 pub to: Vec<String>,
523 pub from: String,
524 pub subject: String,
525 pub created_at: String,
526 #[serde(default)]
527 pub last_event: Option<String>,
528}
529
530fn normalize_key(key: Option<String>) -> Option<String> {
535 key.filter(|key| !key.trim().is_empty())
536}
537
538fn base_url_from_env(value: Option<String>) -> String {
541 match value {
542 Some(base) if !base.trim().is_empty() => base.trim().to_owned(),
543 _ => DEFAULT_BASE_URL.to_owned(),
544 }
545}
546
547#[derive(serde::Serialize)]
548struct OutboundEmail<'a> {
549 from: &'a str,
550 to: &'a str,
551 #[serde(skip_serializing_if = "Option::is_none")]
552 reply_to: Option<&'a str>,
553 subject: &'a str,
554 html: &'a str,
555 text: &'a str,
556 #[serde(skip_serializing_if = "MessageHeaders::is_empty")]
559 headers: MessageHeaders<'a>,
560 #[serde(skip_serializing_if = "Vec::is_empty")]
561 tags: Vec<OwlpostTag>,
562 #[serde(skip_serializing_if = "Option::is_none")]
565 stream: Option<Stream>,
566 #[serde(skip_serializing_if = "Option::is_none")]
567 topic: Option<&'a str>,
568 #[serde(skip_serializing_if = "slice_is_empty")]
569 cc: &'a [String],
570 #[serde(skip_serializing_if = "slice_is_empty")]
571 bcc: &'a [String],
572 #[serde(skip_serializing_if = "Option::is_none")]
573 scheduled_at: Option<&'a str>,
574}
575
576fn slice_is_empty<T>(slice: &&[T]) -> bool {
578 slice.is_empty()
579}
580
581fn outbound<'a>(
585 from_default: &'a str,
586 reply_to_default: Option<&'a str>,
587 message: &'a Message,
588 options: &'a SendOptions,
589) -> OutboundEmail<'a> {
590 OutboundEmail {
591 from: if message.from.is_empty() {
592 from_default
593 } else {
594 message.from.as_str()
595 },
596 to: &message.to,
597 reply_to: message.reply_to.as_deref().or(reply_to_default),
598 subject: &message.subject,
599 html: &message.html,
600 text: &message.text,
601 headers: MessageHeaders(&message.headers),
602 tags: owlpost_tags(&message.tags),
603 stream: options.stream,
604 topic: options.topic.as_deref(),
605 cc: &options.cc,
606 bcc: &options.bcc,
607 scheduled_at: options.scheduled_at.as_deref(),
608 }
609}
610
611fn valid_email_id(id: &str) -> bool {
614 !id.is_empty()
615 && id
616 .bytes()
617 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
618}
619
620fn refuse_topic_with_transactional(options: &SendOptions) -> Result<(), MailError> {
623 if options.topic.is_some() && options.stream == Some(Stream::Transactional) {
624 return Err(MailError::Invalid {
625 detail: "a topic is only valid for broadcast mail; a transactional message must not \
626 carry one"
627 .to_owned(),
628 });
629 }
630 Ok(())
631}
632
633struct MessageHeaders<'a>(&'a [(String, String)]);
638
639impl MessageHeaders<'_> {
640 fn is_empty(&self) -> bool {
641 self.0.is_empty()
642 }
643}
644
645impl serde::Serialize for MessageHeaders<'_> {
646 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
647 serializer.collect_map(self.0.iter().map(|(name, value)| (name, value)))
648 }
649}
650
651#[derive(serde::Serialize)]
656struct OwlpostTag {
657 name: String,
658 value: &'static str,
659}
660
661fn owlpost_tags(tags: &[String]) -> Vec<OwlpostTag> {
662 tags.iter()
663 .map(|tag| OwlpostTag {
664 name: sanitize_tag(tag),
665 value: "1",
666 })
667 .collect()
668}
669
670fn sanitize_tag(tag: &str) -> String {
673 let cleaned: String = tag
674 .chars()
675 .map(|c| {
676 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
677 c
678 } else {
679 '_'
680 }
681 })
682 .collect();
683 if cleaned.is_empty() {
684 "tag".to_owned()
685 } else {
686 cleaned
687 }
688}
689
690#[derive(serde::Deserialize)]
691struct SendResponse {
692 #[serde(default)]
693 id: String,
694}
695
696#[derive(serde::Deserialize)]
698struct BatchResponse {
699 #[serde(default)]
700 data: Vec<SendResponse>,
701}
702
703#[derive(serde::Deserialize)]
706struct ProblemDetails {
707 #[serde(default)]
708 title: String,
709 #[serde(default)]
710 detail: String,
711}
712
713fn problem_detail(body: &str) -> String {
716 match serde_json::from_str::<ProblemDetails>(body) {
717 Ok(problem) => {
718 let title = problem.title.trim();
719 let detail = problem.detail.trim();
720 match (title.is_empty(), detail.is_empty()) {
721 (false, false) => format!("{title}: {detail}"),
722 (false, true) => title.to_owned(),
723 (true, false) => detail.to_owned(),
724 (true, true) => body.to_string(),
725 }
726 }
727 Err(_) => body.to_string(),
728 }
729}
730
731fn extract_domain(message: &str) -> String {
735 message
736 .split_whitespace()
737 .find(|token| token.contains('.') && !token.starts_with('"'))
738 .unwrap_or(message)
739 .trim_matches(['"', '.', ','])
740 .to_string()
741}
742
743fn upstream_detail(detail: String, retry_after: Option<Duration>) -> String {
747 match retry_after {
748 Some(after) => format!("{detail} (retry after {}s)", after.as_secs()),
749 None => detail,
750 }
751}
752
753#[async_trait]
754impl Mailer for Owlpost {
755 fn providers(&self) -> Vec<MailProvider> {
759 vec![MailProvider::new("owlpost", self.client.is_configured())]
760 }
761
762 async fn send(&self, message: Message) -> Result<SendOutcome, MailError> {
765 self.send_with(message, SendOptions::default()).await
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn blank_key_or_base_url_reads_as_unset() {
775 assert_eq!(normalize_key(None), None);
778 assert_eq!(normalize_key(Some(String::new())), None);
779 assert_eq!(normalize_key(Some(" ".to_owned())), None);
780 assert_eq!(
781 normalize_key(Some("op_live_x".to_owned())),
782 Some("op_live_x".to_owned())
783 );
784
785 assert_eq!(base_url_from_env(None), DEFAULT_BASE_URL);
786 assert_eq!(base_url_from_env(Some(String::new())), DEFAULT_BASE_URL);
787 assert_eq!(base_url_from_env(Some(" ".to_owned())), DEFAULT_BASE_URL);
788 assert_eq!(
789 base_url_from_env(Some(" http://owlpost.fake ".to_owned())),
790 "http://owlpost.fake"
791 );
792 }
793
794 #[test]
795 fn tag_names_never_contain_a_forbidden_character() {
796 assert_eq!(sanitize_tag("waitlist:cratefield"), "waitlist_cratefield");
797 assert_eq!(sanitize_tag(""), "tag");
798 assert_eq!(sanitize_tag("ok-name_1"), "ok-name_1");
799 }
800
801 #[test]
802 fn problem_detail_prefers_title_and_detail() {
803 assert_eq!(
804 problem_detail(r#"{"title":"Bad Request","detail":"bad to"}"#),
805 "Bad Request: bad to"
806 );
807 assert_eq!(problem_detail(r#"{"title":"Bad Request"}"#), "Bad Request");
808 assert_eq!(problem_detail(r#"{"detail":"bad to"}"#), "bad to");
809 assert_eq!(problem_detail("boom"), "boom");
811 }
812}