use rainier_support::Result;
use rainier_view::ViewEngine;
use crate::message::{Attachment, Content, Envelope, Message};
pub trait Mailable: Send + Sync {
fn envelope(&self) -> Envelope;
fn content(&self) -> Result<Content>;
fn attachments(&self) -> Result<Vec<Attachment>> {
Ok(Vec::new())
}
fn headers(&self) -> Vec<(String, String)> {
Vec::new()
}
fn build(&self, views: &dyn ViewEngine) -> Result<Message> {
let mut message = Message::new(self.envelope());
match self.content()? {
Content::View { html, text, data } => {
message.html = Some(views.render(&html, &data)?);
message.text = match text {
Some(name) => Some(views.render(&name, &data)?),
None => None,
};
}
Content::Literal { html, text } => {
message.html = html;
message.text = text;
}
}
message.attachments = self.attachments()?;
for (name, value) in self.headers() {
message = message.with_header(name, value);
}
Ok(message)
}
}
impl Mailable for Message {
fn envelope(&self) -> Envelope {
self.envelope.clone()
}
fn content(&self) -> Result<Content> {
Ok(Content::Literal { html: self.html.clone(), text: self.text.clone() })
}
fn attachments(&self) -> Result<Vec<Attachment>> {
Ok(self.attachments.clone())
}
fn headers(&self) -> Vec<(String, String)> {
self.headers.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rainier_view::MemoryEngine;
struct Welcome {
name: String,
}
impl Mailable for Welcome {
fn envelope(&self) -> Envelope {
Envelope::new("Welcome").from("app@example.com").to("ada@example.com")
}
fn content(&self) -> Result<Content> {
Content::view("mail.welcome", serde_json::json!({ "name": self.name }))
}
fn attachments(&self) -> Result<Vec<Attachment>> {
Ok(vec![Attachment::from_bytes("terms.txt", "text/plain", b"terms".to_vec())])
}
fn headers(&self) -> Vec<(String, String)> {
vec![("X-Campaign".into(), "onboarding".into())]
}
}
struct Plain;
impl Mailable for Plain {
fn envelope(&self) -> Envelope {
Envelope::new("Plain").to("ada@example.com")
}
fn content(&self) -> Result<Content> {
Ok(Content::text("just text"))
}
}
fn views() -> MemoryEngine {
MemoryEngine::new()
.with("mail.welcome", "<p>Hi {{ name }}</p>")
.with("mail.welcome_text", "Hi {{ name }}")
}
#[test]
fn building_renders_the_view_and_collects_everything() {
let message = Welcome { name: "Ada".into() }.build(&views()).unwrap();
assert_eq!(message.envelope.subject, "Welcome");
assert_eq!(message.html.as_deref(), Some("<p>Hi Ada</p>"));
assert_eq!(message.attachments.len(), 1);
assert_eq!(message.headers, vec![("X-Campaign".to_string(), "onboarding".to_string())]);
assert!(message.validate().is_ok());
}
#[test]
fn a_text_view_is_rendered_when_one_is_declared() {
struct WithText;
impl Mailable for WithText {
fn envelope(&self) -> Envelope {
Envelope::new("Hi").from("a@b.co").to("c@d.co")
}
fn content(&self) -> Result<Content> {
Ok(Content::view("mail.welcome", serde_json::json!({ "name": "Ada" }))
.unwrap()
.with_text_view("mail.welcome_text"))
}
}
let message = WithText.build(&views()).unwrap();
assert_eq!(message.text.as_deref(), Some("Hi Ada"));
assert_eq!(message.html.as_deref(), Some("<p>Hi Ada</p>"));
}
#[test]
fn without_a_text_view_the_text_body_is_derived_from_the_html() {
let message = Welcome { name: "Ada".into() }.build(&views()).unwrap();
assert!(message.text.is_none());
assert_eq!(message.text_body(), "Hi Ada");
}
#[test]
fn literal_content_needs_no_view_engine() {
let message = Plain.build(&MemoryEngine::new()).unwrap();
assert_eq!(message.text.as_deref(), Some("just text"));
assert!(message.html.is_none());
}
#[test]
fn a_missing_view_surfaces_as_an_error() {
struct Broken;
impl Mailable for Broken {
fn envelope(&self) -> Envelope {
Envelope::new("Hi").to("a@b.co")
}
fn content(&self) -> Result<Content> {
Content::view("mail.nonexistent", serde_json::json!({}))
}
}
let err = Broken.build(&views()).err().expect("should fail");
assert!(err.message().contains("mail.nonexistent"), "{}", err.message());
}
#[test]
fn view_data_is_escaped_like_any_other_template() {
let message = Welcome { name: "<script>".into() }.build(&views()).unwrap();
assert_eq!(message.html.as_deref(), Some("<p>Hi <script></p>"));
}
}