Skip to content

Commit

Permalink
Editable email templates
Browse files Browse the repository at this point in the history
Emails now in DB rather than config
  • Loading branch information
Celeo committed Oct 15, 2024
1 parent 00af976 commit f9354e9
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 94 deletions.
53 changes: 42 additions & 11 deletions vzdv-site/src/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,22 @@ use lettre::{Message, SmtpTransport, Transport};
use minijinja::{context, Environment};
use sqlx::{Pool, Sqlite};
use vzdv::config::Config;
use vzdv::sql::{self, Controller};
use vzdv::sql::{self, Controller, EmailTemplate};

/// Email templates.
/// Email template names.
pub mod templates {
pub const VISITOR_ACCEPTED: &str = "visitor_accepted";
pub const VISITOR_DENIED: &str = "visitor_denied";
pub const VISITOR_REMOVED: &str = "visitor_removed";
}

/// Email templates by name.
pub struct Templates {
pub visitor_accepted: EmailTemplate,
pub visitor_denied: EmailTemplate,
pub visitor_removed: EmailTemplate,
}

/// Send an SMTP email to the recipient.
pub async fn send_mail(
config: &Config,
Expand All @@ -22,15 +29,7 @@ pub async fn send_mail(
recipient_address: &str,
template_name: &str,
) -> Result<(), AppError> {
// template match from config
let template = match template_name {
templates::VISITOR_ACCEPTED => &config.email.visitor_accepted_template,
templates::VISITOR_DENIED => &config.email.visitor_denied_template,
templates::VISITOR_REMOVED => &config.email.visitor_removed_template,
_ => {
return Err(AppError::UnknownEmailTemplate(template_name.to_owned()));
}
};
let template = query_template(db, template_name).await?;

// ATM and DATM names for signing
let atm_datm: Vec<Controller> = sqlx::query_as(sql::GET_ATM_AND_DATM).fetch_all(db).await?;
Expand Down Expand Up @@ -72,3 +71,35 @@ pub async fn send_mail(
mailer.send(&email)?;
Ok(())
}

/// Get a single template by name.
///
/// Returns an error if the template does not exist.
pub async fn query_template(db: &Pool<Sqlite>, template: &str) -> Result<EmailTemplate, AppError> {
let template = sqlx::query_as(sql::GET_EMAIL_TEMPLATE)
.bind(template)
.fetch_one(db)
.await?;
Ok(template)
}

/// Load email templates from the database.
pub async fn query_templates(db: &Pool<Sqlite>) -> Result<Templates, AppError> {
let visitor_accepted: EmailTemplate = sqlx::query_as(sql::GET_EMAIL_TEMPLATE)
.bind(templates::VISITOR_ACCEPTED)
.fetch_one(db)
.await?;
let visitor_denied: EmailTemplate = sqlx::query_as(sql::GET_EMAIL_TEMPLATE)
.bind(templates::VISITOR_DENIED)
.fetch_one(db)
.await?;
let visitor_removed: EmailTemplate = sqlx::query_as(sql::GET_EMAIL_TEMPLATE)
.bind(templates::VISITOR_REMOVED)
.fetch_one(db)
.await?;
Ok(Templates {
visitor_accepted,
visitor_denied,
visitor_removed,
})
}
75 changes: 65 additions & 10 deletions vzdv-site/src/endpoints/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,10 @@ async fn post_feedback_form_handle(
Ok(Redirect::to("/admin/feedback").into_response())
}

/// Admin page to manually send emails.
/// Page to set email templates and send emails.
///
/// Admin staff members only.
async fn page_email_manual_send(
async fn page_emails(
State(state): State<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
Expand All @@ -187,12 +187,68 @@ async fn page_email_manual_send(
let all_controllers: Vec<Controller> = sqlx::query_as(sql::GET_ALL_CONTROLLERS)
.fetch_all(&state.db)
.await?;
let template = state.templates.get_template("admin/manual_email")?;
let template = state.templates.get_template("admin/emails")?;
let flashed_messages = flashed_messages::drain_flashed_messages(session).await?;
let rendered = template.render(context! { user_info, all_controllers, flashed_messages })?;
let email_templates = email::query_templates(&state.db).await?;
let rendered = template.render(context! {
user_info,
all_controllers,
flashed_messages,
visitor_accepted => email_templates.visitor_accepted,
visitor_denied => email_templates.visitor_denied,
visitor_removed => email_templates.visitor_removed,
})?;
Ok(Html(rendered).into_response())
}

#[derive(Debug, Deserialize)]
struct UpdateTemplateForm {
name: String,
subject: String,
body: String,
}

/// Form submission to update an email template.
///
/// Admin staff members only.
async fn post_email_template_update(
State(state): State<Arc<AppState>>,
session: Session,
Form(update_template_form): Form<UpdateTemplateForm>,
) -> Result<Redirect, AppError> {
let user_info: Option<UserInfo> = session.get(SESSION_USER_INFO_KEY).await?;
if let Some(redirect) = reject_if_not_in(&state, &user_info, PermissionsGroup::Admin).await {
return Ok(redirect);
}
// verify it's one of the three templates
if ![
email::templates::VISITOR_ACCEPTED,
email::templates::VISITOR_DENIED,
email::templates::VISITOR_REMOVED,
]
.iter()
.any(|&name| name == update_template_form.name)
{
flashed_messages::push_flashed_message(
session,
MessageLevel::Error,
&format!("Unknown template name: {}", update_template_form.name),
)
.await?;
return Ok(Redirect::to("/admin/emails"));
}
// save
sqlx::query(sql::UPDATE_EMAIL_TEMPLATE)
.bind(update_template_form.name)
.bind(update_template_form.subject)
.bind(update_template_form.body)
.execute(&state.db)
.await?;
flashed_messages::push_flashed_message(session, MessageLevel::Info, "Email template updated")
.await?;
Ok(Redirect::to("/admin/emails"))
}

#[derive(Debug, Deserialize)]
struct ManualEmailForm {
recipient: u32,
Expand Down Expand Up @@ -634,8 +690,8 @@ pub fn router(templates: &mut Environment) -> Router<Arc<AppState>> {
.unwrap();
templates
.add_template(
"admin/manual_email",
include_str!("../../templates/admin/manual_email.jinja"),
"admin/emails",
include_str!("../../templates/admin/emails.jinja"),
)
.unwrap();
templates
Expand Down Expand Up @@ -679,10 +735,9 @@ pub fn router(templates: &mut Environment) -> Router<Arc<AppState>> {
Router::new()
.route("/admin/feedback", get(page_feedback))
.route("/admin/feedback", post(post_feedback_form_handle))
.route(
"/admin/email/manual",
get(page_email_manual_send).post(post_email_manual_send),
)
.route("/admin/emails", get(page_emails))
.route("/admin/emails/update", post(post_email_template_update))
.route("/admin/emails/send", post(post_email_manual_send))
.route("/admin/logs", get(page_logs))
.route(
"/admin/visitor_applications",
Expand Down
3 changes: 0 additions & 3 deletions vzdv-site/src/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@ pub enum AppError {
MultipartFormParsing(#[from] axum::extract::multipart::MultipartError),
#[error(transparent)]
EmailError(#[from] lettre::transport::smtp::Error),
#[error("unknown email template {0}")]
UnknownEmailTemplate(String),
#[error(transparent)]
FileWriteError(#[from] std::io::Error),
#[error("generic error {0}: {1}")]
Expand All @@ -84,7 +82,6 @@ impl AppError {
Self::MultipartFormGet => "Issue parsing form key",
Self::MultipartFormParsing(_) => "Issue parsing form submission",
Self::EmailError(_) => "Issue sending an email",
Self::UnknownEmailTemplate(_) => "Unknown email template",
Self::FileWriteError(_) => "Writing to a file",
Self::GenericFallback(_, _) => "Unknown error",
}
Expand Down
2 changes: 1 addition & 1 deletion vzdv-site/templates/_layout.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
{% if user_info.is_admin %}
<li><a href="/admin/feedback" class="dropdown-item">Manage feedback</a></li>
<li><a href="/admin/visitor_applications" class="dropdown-item">Manage visitor apps</a></li>
<li><a href="/admin/email/manual" class="dropdown-item">Send emails</a></li>
<li><a href="/admin/emails" class="dropdown-item">Emails</a></li>
<li><a href="/admin/logs" class="dropdown-item">Read logs</a></li>
{% endif %}
</ul>
Expand Down
139 changes: 139 additions & 0 deletions vzdv-site/templates/admin/emails.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
{% extends "_layout" %}

{% block title %}Emails | {{ super() }}{% endblock %}

{% block body %}

<h2 class="pb-3">Email templates</h2>
<p>
There are <strong>3</strong> email templates that this site uses. You can edit them below. VATUSA hosts other templates.
<br>
The following template variables are available for use in emails:
</p>

{% raw %}
<ul>
<li>"{{ recipient_name }}" - name of the recipient</li>
<li>"{{ atm }}" - name of the ARTCC ATM</li>
<li>"{{ datm }}" - name of the ARTCC DATM</li>
</ul>
{% endraw %}

<ul class="nav nav-tabs pt-3" role="tablist">
<button class="nav-link active" id="nav-visitor-accepted-tab" data-bs-toggle="tab" data-bs-target="#nav-visitor-accepted" type="button" role="tab" aria-controls="nav-visitor-accepted" aria-selected="true">Visitor accepted</button>
<button class="nav-link" id="nav-visitor-denied-tab" data-bs-toggle="tab" data-bs-target="#nav-visitor-denied" type="button" role="tab" aria-controls="nav-visitor-denied" aria-selected="false">Visitor denied</button>
<button class="nav-link" id="nav-visitor-removed-tab" data-bs-toggle="tab" data-bs-target="#nav-visitor-removed" type="button" role="tab" aria-controls="nav-visitor-removed" aria-selected="false">Visitor removed</button>
</ul>

<div class="tab-content pt-3" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-visitor-accepted" role="tabpanel" aria-labelledby="nav-visitor-accepted-tab" tabindex="0">
<form action="/admin/emails/update" method="POST">
<input type="hidden" name="name" value="visitor_accepted">
<div class="row">
<div class="col">
<div class="mb-3">
<label for="subject" class="form-label">Subject</label>
<input type="text" id="subject" name="subject" class="form-control" value="{{ visitor_accepted.subject }}">
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="mb-3">
<label for="body" class="form-label">Body</label>
<textarea name="body" id="body" class="form-control" style="height: 20rem">{{ visitor_accepted.body }}</textarea>
</div>
</div>
</div>
<button class="btn btn-primary" role="button" type="submit">
<i class="bi bi-floppy2-fill"></i>
Save
</button>
</form>
</div>
<div class="tab-pane fade" id="nav-visitor-denied" role="tabpanel" aria-labelledby="nav-visitor-denied-tab" tabindex="0">
<form action="/admin/emails/update" method="POST">
<input type="hidden" name="name" value="visitor_denied">
<div class="row">
<div class="col">
<div class="mb-3">
<label for="subject" class="form-label">Subject</label>
<input type="text" id="subject" name="subject" class="form-control" value="{{ visitor_denied.subject }}">
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="mb-3">
<label for="body" class="form-label">Body</label>
<textarea name="body" id="body" class="form-control" style="height: 20rem">{{ visitor_denied.body }}</textarea>
</div>
</div>
</div>
<button class="btn btn-primary" role="button" type="submit">
<i class="bi bi-floppy2-fill"></i>
Save
</button>
</form>
</div>
<div class="tab-pane fade" id="nav-visitor-removed" role="tabpanel" aria-labelledby="nav-visitor-removed-tab" tabindex="0">
<form action="/admin/emails/update" method="POST">
<input type="hidden" name="name" value="visitor_removed">
<div class="row">
<div class="col">
<div class="mb-3">
<label for="subject" class="form-label">Subject</label>
<input type="text" id="subject" name="subject" class="form-control" value="{{ visitor_removed.subject }}">
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="mb-3">
<label for="body" class="form-label">Body</label>
<textarea name="body" id="body" class="form-control" style="height: 20rem">{{ visitor_removed.body }}</textarea>
</div>
</div>
</div>
<button class="btn btn-primary" role="button" type="submit">
<i class="bi bi-floppy2-fill"></i>
Save
</button>
</form>
</div>
</div>

<hr class="py-3">

<h2 class="pb-3">Manually send email</h2>
<p>
This section can be used to manually send an email to a controller.<br>
Primarily intended for use in testing templates by sending an email to yourself.<br><br>
<strong>Important</strong>: this will actually send an email, so be careful.
</p>
<form action="/admin/emails/send" method="POST">
<div class="row mb-3">
<div class="col">
<select class="form-select" name="recipient" id="recipient" required>
<option disabled>Controllers</option>
{% for controller in all_controllers %}
<option value="{{ controller.cid }}">{{ controller.first_name }} {{ controller.last_name }}</option>
{% endfor %}
</select>
</div>
<div class="col">
<select class="form-select" name="template" id="template" required>
<option disabled>Template</option>
<option value="visitor_accepted">Visitor accepted</option>
<option value="visitor_denied">Visitor denied</option>
<option value="visitor_removed">Visitor removed</option>
</select>
</div>
</div>
<button class="btn btn-primary" role="button" type="submit">
<i class="bi bi-envelope"></i>
Send
</button>
</form>

{% endblock %}
35 changes: 0 additions & 35 deletions vzdv-site/templates/admin/manual_email.jinja

This file was deleted.

Loading

0 comments on commit f9354e9

Please sign in to comment.