Skip to main content

headless_lms_server/controllers/cms/
email_templates.rs

1//! Controllers for requests starting with `/api/v0/cms/email-templates`.
2
3use headless_lms_utils::email_processor::{self, EmailRenderInput};
4use models::email_layouts::{self, ResolvedLayout};
5use models::email_templates::{EmailTemplate, EmailTemplateUpdate};
6use utoipa::{OpenApi, ToSchema};
7
8use crate::controllers::helpers::email_content::parse_email_content;
9use crate::domain::authorization::AuthorizationToken;
10use crate::prelude::*;
11
12/// More test sends than this from one user within a minute are rejected.
13const MAX_TEST_SENDS_PER_MINUTE: i64 = 5;
14
15#[derive(OpenApi)]
16#[openapi(paths(
17    get_email_template,
18    update_email_template,
19    delete_email_template,
20    preview_email_template,
21    send_test_email
22))]
23pub(crate) struct CmsEmailTemplatesApiDoc;
24
25/// The editor's unsaved subject and body.
26#[derive(Debug, Serialize, Deserialize, ToSchema)]
27pub struct EmailTemplatePreviewRequest {
28    pub subject: String,
29    pub content: serde_json::Value,
30}
31
32/// A template rendered as it would be sent, with sample placeholder values.
33#[derive(Debug, Serialize, Deserialize, ToSchema)]
34pub struct EmailTemplatePreview {
35    pub subject: String,
36    /// The complete HTML document, layout included.
37    pub html: String,
38    pub plain_text: String,
39}
40
41/// Authorizes editing `template`: teaching its course, or global admin for a global template.
42async fn authorize_template_edit(
43    conn: &mut PgConnection,
44    user: &AuthUser,
45    template: &EmailTemplate,
46) -> Result<AuthorizationToken, ControllerError> {
47    let token = match template.course_id {
48        Some(course_id) => {
49            authorize(conn, Act::Teach, Some(user.id), Res::Course(course_id)).await?
50        }
51        None => {
52            authorize(
53                conn,
54                Act::Administrate,
55                Some(user.id),
56                Res::GlobalPermissions,
57            )
58            .await?
59        }
60    };
61    Ok(token)
62}
63
64/**
65GET `/api/v0/cms/email-templates/:id`
66*/
67#[instrument(skip(pool))]
68#[utoipa::path(
69    get,
70    path = "/{email_template_id}",
71    operation_id = "getCmsEmailTemplate",
72    tag = "cms_email_templates",
73    params(
74        ("email_template_id" = Uuid, Path, description = "Email template id")
75    ),
76    responses(
77        (status = 200, description = "Email template", body = EmailTemplate)
78    )
79)]
80async fn get_email_template(
81    email_template_id: web::Path<Uuid>,
82    pool: web::Data<PgPool>,
83    user: AuthUser,
84) -> ControllerResult<web::Json<EmailTemplate>> {
85    let mut conn = pool.acquire().await?;
86    let email_templates =
87        models::email_templates::get_email_template(&mut conn, *email_template_id).await?;
88    let token = authorize_template_edit(&mut conn, &user, &email_templates).await?;
89    token.authorized_ok(web::Json(email_templates))
90}
91
92/**
93PUT `/api/v0/cms/email-templates/:id
94*/
95#[instrument(skip(pool))]
96#[utoipa::path(
97    put,
98    path = "/{email_template_id}",
99    operation_id = "updateCmsEmailTemplate",
100    tag = "cms_email_templates",
101    params(
102        ("email_template_id" = Uuid, Path, description = "Email template id")
103    ),
104    request_body = EmailTemplateUpdate,
105    responses(
106        (status = 200, description = "Updated email template", body = EmailTemplate)
107    )
108)]
109async fn update_email_template(
110    email_template_id: web::Path<Uuid>,
111    payload: web::Json<EmailTemplateUpdate>,
112    pool: web::Data<PgPool>,
113    user: AuthUser,
114) -> ControllerResult<web::Json<EmailTemplate>> {
115    let mut conn = pool.acquire().await?;
116    let template =
117        models::email_templates::get_email_template(&mut conn, *email_template_id).await?;
118    let token = authorize_template_edit(&mut conn, &user, &template).await?;
119    let request_update_template = payload.0;
120    parse_email_content(&request_update_template.content)?;
121    let updated_template = models::email_templates::update_email_template(
122        &mut conn,
123        *email_template_id,
124        request_update_template,
125    )
126    .await?;
127    token.authorized_ok(web::Json(updated_template))
128}
129
130/**
131DELETE `/api/v0/cms/email-templates/:id`
132*/
133#[instrument(skip(pool))]
134#[utoipa::path(
135    delete,
136    path = "/{email_template_id}",
137    operation_id = "deleteCmsEmailTemplate",
138    tag = "cms_email_templates",
139    params(
140        ("email_template_id" = Uuid, Path, description = "Email template id")
141    ),
142    responses(
143        (status = 200, description = "Deleted email template", body = EmailTemplate)
144    )
145)]
146async fn delete_email_template(
147    email_template_id: web::Path<Uuid>,
148    pool: web::Data<PgPool>,
149    user: AuthUser,
150) -> ControllerResult<web::Json<EmailTemplate>> {
151    let mut conn = pool.acquire().await?;
152    let template =
153        models::email_templates::get_email_template(&mut conn, *email_template_id).await?;
154    let token = authorize_template_edit(&mut conn, &user, &template).await?;
155    let deleted =
156        models::email_templates::delete_email_template(&mut conn, *email_template_id).await?;
157    token.authorized_ok(web::Json(deleted))
158}
159
160/**
161POST `/api/v0/cms/email-templates/:id/preview`
162
163Renders the editor's unsaved state exactly as the sender would, with sample placeholder values.
164*/
165#[instrument(skip(pool, app_conf, payload))]
166#[utoipa::path(
167    post,
168    path = "/{email_template_id}/preview",
169    operation_id = "previewCmsEmailTemplate",
170    tag = "cms_email_templates",
171    params(
172        ("email_template_id" = Uuid, Path, description = "Email template id")
173    ),
174    request_body = EmailTemplatePreviewRequest,
175    responses(
176        (status = 200, description = "Rendered email", body = EmailTemplatePreview)
177    )
178)]
179async fn preview_email_template(
180    email_template_id: web::Path<Uuid>,
181    payload: web::Json<EmailTemplatePreviewRequest>,
182    pool: web::Data<PgPool>,
183    app_conf: web::Data<ApplicationConfiguration>,
184    user: AuthUser,
185) -> ControllerResult<web::Json<EmailTemplatePreview>> {
186    let mut conn = pool.acquire().await?;
187    let template =
188        models::email_templates::get_email_template(&mut conn, *email_template_id).await?;
189    let token = authorize_template_edit(&mut conn, &user, &template).await?;
190    let body = parse_email_content(&payload.content)?;
191    let language = models::email_templates::get_language(&mut conn, template.id).await?;
192    let layouts = email_layouts::get_all_live(&mut conn).await?;
193    let layout = ResolvedLayout::for_language(&layouts, language.as_deref());
194    let replacements = template
195        .email_template_type
196        .sample_placeholders(&app_conf.base_url);
197    let rendered = email_processor::render_email(EmailRenderInput {
198        layout_html: layout.html,
199        theme: layout.theme,
200        subject: &payload.subject,
201        body,
202        language: language.as_deref().unwrap_or_default(),
203        replacements: &replacements,
204    });
205    token.authorized_ok(web::Json(EmailTemplatePreview {
206        subject: rendered.subject,
207        html: rendered.html,
208        plain_text: rendered.plain_text,
209    }))
210}
211
212/**
213POST `/api/v0/cms/email-templates/:id/send-test`
214
215Queues the editor's unsaved state, with sample placeholder values, to the requesting user's own
216address. Returns the id of the queued delivery.
217*/
218#[instrument(skip(pool, app_conf, payload))]
219#[utoipa::path(
220    post,
221    path = "/{email_template_id}/send-test",
222    operation_id = "sendCmsEmailTemplateTest",
223    tag = "cms_email_templates",
224    params(
225        ("email_template_id" = Uuid, Path, description = "Email template id")
226    ),
227    request_body = EmailTemplatePreviewRequest,
228    responses(
229        (status = 200, description = "Id of the queued test delivery", body = Uuid)
230    )
231)]
232async fn send_test_email(
233    email_template_id: web::Path<Uuid>,
234    payload: web::Json<EmailTemplatePreviewRequest>,
235    pool: web::Data<PgPool>,
236    app_conf: web::Data<ApplicationConfiguration>,
237    user: AuthUser,
238) -> ControllerResult<web::Json<Uuid>> {
239    let mut conn = pool.acquire().await?;
240    let template =
241        models::email_templates::get_email_template(&mut conn, *email_template_id).await?;
242    let token = authorize_template_edit(&mut conn, &user, &template).await?;
243    parse_email_content(&payload.content)?;
244    let placeholders = serde_json::to_value(
245        template
246            .email_template_type
247            .sample_placeholders(&app_conf.base_url),
248    )?;
249    let delivery_id = models::email_deliveries::insert_test_email_delivery_within_limit(
250        &mut conn,
251        user.id,
252        template.id,
253        &payload.subject,
254        &payload.content,
255        &placeholders,
256        MAX_TEST_SENDS_PER_MINUTE,
257    )
258    .await?
259    .ok_or_else(|| {
260        ControllerError::new(
261            ControllerErrorType::TooManyRequests,
262            "Too many test emails sent in the last minute. Try again shortly.".to_string(),
263            None,
264        )
265    })?;
266    token.authorized_ok(web::Json(delivery_id))
267}
268
269/**
270Add a route for each controller in this module.
271
272The name starts with an underline in order to appear before other functions in the module documentation.
273
274We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
275*/
276pub fn _add_routes(cfg: &mut ServiceConfig) {
277    cfg.route("/{email_template_id}", web::get().to(get_email_template))
278        .route("/{email_template_id}", web::put().to(update_email_template))
279        .route(
280            "/{email_template_id}",
281            web::delete().to(delete_email_template),
282        )
283        .route(
284            "/{email_template_id}/preview",
285            web::post().to(preview_email_template),
286        )
287        .route(
288            "/{email_template_id}/send-test",
289            web::post().to(send_test_email),
290        );
291}