Skip to main content

cloudillo_email/
task.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Email sender task for scheduler integration
5//!
6//! Handles async, persistent email sending with template rendering.
7//! Templates are rendered at execution time, not scheduling time.
8//! Retry logic is handled by the scheduler's built-in RetryPolicy.
9
10use crate::prelude::*;
11use crate::EmailMessage;
12use async_trait::async_trait;
13use cloudillo_core::scheduler::Task;
14use serde::{Deserialize, Serialize};
15use std::fmt::Debug;
16use std::sync::Arc;
17
18pub type TaskId = u64;
19
20/// Email sender task for persistent async sending
21///
22/// Stores template name and variables instead of rendered content.
23/// Template is rendered at execution time for fresh content.
24/// Subject can be provided explicitly or extracted from template frontmatter.
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct EmailSenderTask {
27	pub tn_id: TnId,
28	pub to: String,
29	/// Optional subject - if None, will be extracted from template frontmatter
30	#[serde(default)]
31	pub subject: Option<String>,
32	pub template_name: String,
33	pub template_vars: serde_json::Value,
34	/// Optional language code for localized templates (e.g., "hu", "de")
35	#[serde(default)]
36	pub lang: Option<String>,
37	/// Optional sender name override (e.g., "Cloudillo (myinstance)" or identity_provider)
38	#[serde(default)]
39	pub from_name_override: Option<String>,
40}
41
42impl EmailSenderTask {
43	/// Create new email sender task from template information
44	pub fn new(
45		tn_id: TnId,
46		to: String,
47		subject: Option<String>,
48		template_name: String,
49		template_vars: serde_json::Value,
50		lang: Option<String>,
51		from_name_override: Option<String>,
52	) -> Self {
53		Self { tn_id, to, subject, template_name, template_vars, lang, from_name_override }
54	}
55}
56
57#[async_trait]
58impl Task<App> for EmailSenderTask {
59	fn kind() -> &'static str {
60		"email.send"
61	}
62
63	fn kind_of(&self) -> &'static str {
64		Self::kind()
65	}
66
67	fn build(_id: TaskId, context: &str) -> ClResult<Arc<dyn Task<App>>> {
68		// Deserialize from context
69		let task: EmailSenderTask = serde_json::from_str(context).map_err(|e| {
70			Error::ValidationError(format!("Failed to deserialize email task: {}", e))
71		})?;
72		Ok(Arc::new(task))
73	}
74
75	fn serialize(&self) -> String {
76		serde_json::to_string(self)
77			.unwrap_or_else(|_| format!("email.send:{}:{}", self.tn_id.0, self.to))
78	}
79
80	async fn run(&self, app: &App) -> ClResult<()> {
81		info!(
82			"Executing email task for {} (template: {}, lang: {:?})",
83			self.to, self.template_name, self.lang
84		);
85
86		let email_module = app.ext::<Arc<crate::EmailModule>>()?;
87
88		// Render template at execution time
89		let render_result = email_module
90			.template_engine
91			.render(self.tn_id, &self.template_name, &self.template_vars, self.lang.as_deref())
92			.await?;
93
94		// Use provided subject or extract from template
95		let subject = self.subject.clone().or(render_result.subject).ok_or_else(|| {
96			Error::ConfigError(format!(
97				"No subject provided and template '{}' has no subject in frontmatter",
98				self.template_name
99			))
100		})?;
101
102		// Build email message
103		let message = EmailMessage {
104			to: self.to.clone(),
105			subject,
106			text_body: render_result.text_body,
107			html_body: Some(render_result.html_body),
108			from_name_override: self.from_name_override.clone(),
109		};
110
111		// Send email
112		email_module.send_now(self.tn_id, message).await?;
113
114		info!("Email task completed for {}", self.to);
115		Ok(())
116	}
117}
118
119#[cfg(test)]
120mod tests {
121	use super::*;
122
123	#[test]
124	fn test_email_task_creation() {
125		let vars = serde_json::json!({
126			"user_name": "Alice",
127			"instance_name": "Cloudillo",
128		});
129
130		let task = EmailSenderTask::new(
131			TnId(1),
132			"user@example.com".to_string(),
133			Some("Test Email".to_string()),
134			"welcome".to_string(),
135			vars.clone(),
136			None,
137			None,
138		);
139
140		assert_eq!(task.tn_id.0, 1);
141		assert_eq!(task.to, "user@example.com");
142		assert_eq!(task.subject, Some("Test Email".to_string()));
143		assert_eq!(task.template_name, "welcome");
144		assert_eq!(task.template_vars, vars);
145		assert_eq!(task.lang, None);
146	}
147
148	#[test]
149	fn test_email_task_with_lang() {
150		let vars = serde_json::json!({
151			"user_name": "Béla",
152		});
153
154		let task = EmailSenderTask::new(
155			TnId(1),
156			"user@example.com".to_string(),
157			None, // Subject from template frontmatter
158			"welcome".to_string(),
159			vars.clone(),
160			Some("hu".to_string()),
161			None,
162		);
163
164		assert_eq!(task.lang, Some("hu".to_string()));
165		assert!(task.subject.is_none());
166	}
167
168	#[test]
169	fn test_email_task_serialization() {
170		let vars = serde_json::json!({
171			"user_name": "Bob",
172		});
173
174		let task = EmailSenderTask::new(
175			TnId(1),
176			"user@example.com".to_string(),
177			Some("Test".to_string()),
178			"notification".to_string(),
179			vars,
180			Some("de".to_string()),
181			None,
182		);
183
184		// Use Task trait's serialize method
185		let serialized = cloudillo_core::scheduler::Task::serialize(&task);
186
187		// Should be valid JSON
188		assert!(serialized.contains("user@example.com"));
189		assert!(serialized.contains("notification"));
190		assert!(serialized.contains("de"));
191		let deserialized: Result<EmailSenderTask, _> = serde_json::from_str(&serialized);
192		assert!(deserialized.is_ok());
193	}
194
195	#[test]
196	fn test_email_task_kind() {
197		assert_eq!(EmailSenderTask::kind(), "email.send");
198
199		let vars = serde_json::json!({});
200		let task = EmailSenderTask::new(
201			TnId(1),
202			"test@example.com".to_string(),
203			Some("Test".to_string()),
204			"test".to_string(),
205			vars,
206			None,
207			None,
208		);
209		assert_eq!(task.kind_of(), "email.send");
210	}
211
212	#[test]
213	fn test_email_task_template_vars() {
214		let vars = serde_json::json!({
215			"user_name": "Charlie",
216			"instance_name": "Cloudillo",
217			"welcome_link": "https://example.com/welcome",
218		});
219
220		let task = EmailSenderTask::new(
221			TnId(1),
222			"user@example.com".to_string(),
223			None, // Subject from frontmatter
224			"welcome".to_string(),
225			vars.clone(),
226			None,
227			None,
228		);
229
230		assert_eq!(task.template_name, "welcome");
231		assert_eq!(task.template_vars["user_name"], "Charlie");
232		assert_eq!(task.template_vars["instance_name"], "Cloudillo");
233		assert_eq!(task.template_vars["welcome_link"], "https://example.com/welcome");
234	}
235
236	#[test]
237	fn test_email_task_backward_compat() {
238		// Test that old serialized tasks without lang field still deserialize
239		let json = r#"{
240			"tn_id": 1,
241			"to": "user@example.com",
242			"subject": "Test",
243			"template_name": "test",
244			"template_vars": {}
245		}"#;
246
247		let task: Result<EmailSenderTask, _> = serde_json::from_str(json);
248		assert!(task.is_ok());
249		let task = task.unwrap();
250		assert!(task.lang.is_none());
251		// Subject should deserialize from string value
252		assert_eq!(task.subject, Some("Test".to_string()));
253	}
254}
255
256// vim: ts=4