Custom Emails (#4526)

* Dynamic email template

* Set lower cache expiry for templates

* Custom email route

* Fix subject line on custom emails

* chore: query cache, clippy, fmt

* Bugfixes

* Key-based caching on custom emails

* Sequentially process emails prone to causing cache stampede

* Fill variables in dynamic body + subject line

* Update apps/labrinth/src/queue/email/templates.rs

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

* Update apps/labrinth/src/queue/email/templates.rs

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>

---------

Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
This commit is contained in:
François-Xavier Talbot
2025-10-10 17:30:38 +01:00
committed by GitHub
parent aec49cff7c
commit 0c66fa3f12
6 changed files with 342 additions and 65 deletions

View File

@@ -1,14 +1,18 @@
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::v3::notifications::{NotificationChannel, NotificationType};
use crate::routes::ApiError;
use serde::{Deserialize, Serialize};
const TEMPLATES_NAMESPACE: &str = "notifications_templates";
const TEMPLATES_HTML_DATA_NAMESPACE: &str = "notifications_templates_html_data";
const TEMPLATES_DYNAMIC_HTML_NAMESPACE: &str =
"notifications_templates_dynamic_html";
const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes
const TEMPLATES_CACHE_EXPIRY: i64 = 60 * 30; // 30 minutes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationTemplate {
pub id: i64,
pub channel: NotificationChannel,
@@ -75,7 +79,7 @@ impl NotificationTemplate {
TEMPLATES_NAMESPACE,
channel.as_str(),
&templates,
None,
Some(TEMPLATES_CACHE_EXPIRY),
)
.await?;
@@ -111,3 +115,44 @@ impl NotificationTemplate {
.await
}
}
pub async fn get_or_set_cached_dynamic_html<F>(
redis: &RedisPool,
key: &str,
get: impl FnOnce() -> F,
) -> Result<String, ApiError>
where
F: Future<Output = Result<String, ApiError>>,
{
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HtmlBody {
html: String,
}
let mut redis_conn = redis.connect().await?;
if let Some(body) = redis_conn
.get_deserialized_from_json::<HtmlBody>(
TEMPLATES_DYNAMIC_HTML_NAMESPACE,
key,
)
.await?
{
return Ok(body.html);
}
drop(redis_conn);
let cached = HtmlBody { html: get().await? };
let mut redis_conn = redis.connect().await?;
redis_conn
.set_serialized_to_json(
TEMPLATES_DYNAMIC_HTML_NAMESPACE,
key,
&cached,
Some(HTML_DATA_CACHE_EXPIRY),
)
.await?;
Ok(cached.html)
}