1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
//! Tera-rendered email helpers — bridge the existing
//! [`crate::email`] mailer trait and the Tera templating engine
//! (already a dep on the `admin` feature).
//!
//! Each email is a set of three sibling templates in a directory:
//!
//! ```text
//! email_templates/
//! welcome.subject.txt -- single-line subject (whitespace trimmed)
//! welcome.txt -- plain-text body (always required)
//! welcome.html -- HTML body (optional)
//! ```
//!
//! Subject + plain text are required; HTML is added when present so
//! mail clients that prefer HTML get it and plain-text fallbacks
//! still work.
//!
//! ## Quick start
//!
//! ```ignore
//! use rustango::email_templates::EmailRenderer;
//! use rustango::email::Mailer;
//! use tera::Context;
//!
//! let renderer = EmailRenderer::from_dir("./email_templates")?;
//!
//! let mut ctx = Context::new();
//! ctx.insert("name", "Alice");
//! ctx.insert("url", "https://app.example.com/verify/abc");
//!
//! let email = renderer.render("welcome", &ctx)?
//! .from("noreply@example.com")
//! .to("alice@example.com");
//!
//! mailer.send(email).await?;
//! ```
//!
//! ## Inline templates (tests + ad-hoc)
//!
//! ```ignore
//! let renderer = EmailRenderer::from_pairs(vec![
//! ("welcome.subject.txt", "Welcome, {{ name }}"),
//! ("welcome.txt", "Hi {{ name }}, click {{ url }} to verify."),
//! ("welcome.html", "<p>Hi {{ name }}, <a href=\"{{ url }}\">verify</a>.</p>"),
//! ])?;
//! ```
use std::path::Path;
use tera::{Context, Tera};
use crate::email::Email;
#[derive(Debug, thiserror::Error)]
pub enum EmailRenderError {
#[error("template error: {0}")]
Tera(String),
#[error("required template missing: {0}")]
Missing(String),
}
impl From<tera::Error> for EmailRenderError {
fn from(e: tera::Error) -> Self {
Self::Tera(e.to_string())
}
}
/// Holds a Tera engine plus per-instance rendering helpers.
///
/// Cheap to clone (the inner `Tera` is wrapped in `Arc` internally
/// when shared across handlers — pass `Arc<EmailRenderer>` if you
/// want to avoid the `Tera` clone cost).
pub struct EmailRenderer {
tera: Tera,
}
impl EmailRenderer {
/// Load every `*.txt`, `*.html`, `*.subject.txt` under `dir`.
/// Subdirectories are walked.
///
/// # Errors
/// Returns the underlying Tera error when a template doesn't
/// parse, or the directory glob can't be evaluated.
pub fn from_dir(dir: impl AsRef<Path>) -> Result<Self, EmailRenderError> {
let glob = format!("{}/**/*", dir.as_ref().display());
let tera = Tera::new(&glob)?;
Ok(Self { tera })
}
/// Build from in-memory `(name, source)` pairs. Useful for
/// tests and one-off scripts that don't want a templates dir.
///
/// # Errors
/// Returns the underlying Tera error if a template doesn't
/// parse.
pub fn from_pairs(pairs: Vec<(&str, &str)>) -> Result<Self, EmailRenderError> {
let mut tera = Tera::default();
// Disable autoescape — email templates are not always HTML, and
// the user is responsible for choosing the right escape strategy
// per template.
tera.autoescape_on(Vec::new());
for (name, source) in pairs {
tera.add_raw_template(name, source)?;
}
Ok(Self { tera })
}
/// Borrow the inner Tera. Useful when you want to register
/// custom filters / functions.
#[must_use]
pub fn tera(&self) -> &Tera {
&self.tera
}
/// Mutable borrow for filter/function registration at startup.
pub fn tera_mut(&mut self) -> &mut Tera {
&mut self.tera
}
/// Render `{name}.subject.txt`, `{name}.txt`, and (optionally)
/// `{name}.html` and pack them into an [`Email`]. The returned
/// email has only `subject`, `body`, and `html_body` populated —
/// chain `.to()` / `.from()` / etc. before sending.
///
/// # Errors
/// `Missing(name)` when subject or text body is absent;
/// `Tera(_)` for any template error.
pub fn render(&self, name: &str, context: &Context) -> Result<Email, EmailRenderError> {
let subject_name = format!("{name}.subject.txt");
let text_name = format!("{name}.txt");
let html_name = format!("{name}.html");
let subject = self
.tera
.render(&subject_name, context)
.map_err(|e| match underlying_kind(&e) {
TemplateMissing::Yes => EmailRenderError::Missing(subject_name.clone()),
TemplateMissing::No => e.into(),
})?
.trim()
.to_owned();
let body =
self.tera
.render(&text_name, context)
.map_err(|e| match underlying_kind(&e) {
TemplateMissing::Yes => EmailRenderError::Missing(text_name.clone()),
TemplateMissing::No => e.into(),
})?;
let mut email = Email::new().subject(subject).body(body);
// HTML body is optional — render only when the template exists.
if self.tera.get_template_names().any(|t| t == html_name) {
let html = self.tera.render(&html_name, context)?;
email = email.html_body(html);
}
Ok(email)
}
}
enum TemplateMissing {
Yes,
No,
}
fn underlying_kind(e: &tera::Error) -> TemplateMissing {
let msg = e.to_string();
if msg.contains("not found") || msg.contains("does not exist") {
TemplateMissing::Yes
} else {
TemplateMissing::No
}
}
#[cfg(test)]
mod tests {
use super::*;
use tera::Context;
fn ctx_alice() -> Context {
let mut c = Context::new();
c.insert("name", "Alice");
c.insert("url", "https://example.com/verify/abc");
c
}
#[test]
fn renders_subject_and_text_body_only() {
let r = EmailRenderer::from_pairs(vec![
("welcome.subject.txt", "Welcome, {{ name }}"),
("welcome.txt", "Hi {{ name }}, visit {{ url }}"),
])
.unwrap();
let email = r.render("welcome", &ctx_alice()).unwrap();
assert_eq!(email.subject, "Welcome, Alice");
assert_eq!(email.body, "Hi Alice, visit https://example.com/verify/abc");
assert!(email.html_body.is_none());
}
#[test]
fn renders_html_body_when_present() {
let r = EmailRenderer::from_pairs(vec![
("welcome.subject.txt", "Hi"),
("welcome.txt", "Hi {{ name }}"),
("welcome.html", "<p>Hi {{ name }}</p>"),
])
.unwrap();
let email = r.render("welcome", &ctx_alice()).unwrap();
assert_eq!(email.html_body.as_deref(), Some("<p>Hi Alice</p>"));
}
#[test]
fn missing_subject_returns_clear_error() {
let r = EmailRenderer::from_pairs(vec![("welcome.txt", "x")]).unwrap();
let err = r.render("welcome", &Context::new()).unwrap_err();
match err {
EmailRenderError::Missing(name) => assert_eq!(name, "welcome.subject.txt"),
other => panic!("expected Missing error, got: {other:?}"),
}
}
#[test]
fn missing_text_body_returns_clear_error() {
let r = EmailRenderer::from_pairs(vec![("welcome.subject.txt", "x")]).unwrap();
let err = r.render("welcome", &Context::new()).unwrap_err();
match err {
EmailRenderError::Missing(name) => assert_eq!(name, "welcome.txt"),
other => panic!("expected Missing error, got: {other:?}"),
}
}
#[test]
fn subject_is_trimmed() {
// Subject lines often gain leading whitespace from indentation
// in templates — trim it.
let r =
EmailRenderer::from_pairs(vec![("hi.subject.txt", " Hello\n"), ("hi.txt", "body")])
.unwrap();
let e = r.render("hi", &Context::new()).unwrap();
assert_eq!(e.subject, "Hello");
}
#[test]
fn template_syntax_error_propagates() {
// Garbled template — Tera should reject at parse time.
let r =
EmailRenderer::from_pairs(vec![("hi.subject.txt", "{{ unbalanced"), ("hi.txt", "x")]);
assert!(r.is_err(), "parse error should bubble out of from_pairs");
}
#[test]
fn from_dir_loads_files_and_renders() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("ping.subject.txt"), "Ping {{ n }}").unwrap();
std::fs::write(dir.path().join("ping.txt"), "n={{ n }}").unwrap();
let r = EmailRenderer::from_dir(dir.path()).unwrap();
let mut c = Context::new();
c.insert("n", &7);
let email = r.render("ping", &c).unwrap();
assert_eq!(email.subject, "Ping 7");
assert_eq!(email.body, "n=7");
}
#[test]
fn tera_mut_lets_caller_register_filters() {
let mut r =
EmailRenderer::from_pairs(vec![("hi.subject.txt", "x"), ("hi.txt", "x")]).unwrap();
// Register a no-op filter; just verifying the access works.
r.tera_mut()
.register_filter("noop", |v: &tera::Value, _: &_| Ok(v.clone()));
// Smoke test that rendering still works.
let _ = r.render("hi", &Context::new()).unwrap();
}
#[test]
fn renders_against_complex_context() {
let r = EmailRenderer::from_pairs(vec![
("o.subject.txt", "Order {{ order.id }}"),
(
"o.txt",
"{{ user.name }} ordered {{ order.items | length }} items.",
),
])
.unwrap();
let mut c = Context::new();
c.insert("user", &serde_json::json!({"name": "Alice"}));
c.insert("order", &serde_json::json!({"id": 42, "items": [1, 2, 3]}));
let e = r.render("o", &c).unwrap();
assert_eq!(e.subject, "Order 42");
assert_eq!(e.body, "Alice ordered 3 items.");
}
}