Delegate question mailflow - #170
Open
fabiancem wants to merge 8 commits into
Open
Conversation
elftausend
requested changes
Jul 5, 2026
Comment on lines
+177
to
+186
| "/v1/admin/delegate-questions/pending", | ||
| get(pending_delegate_questions_route), | ||
| ) | ||
| .route( | ||
| "/v1/admin/delegate-questions/{question_id}/approve", | ||
| post(approve_delegate_question_route), | ||
| ) | ||
| .route( | ||
| "/v1/admin/delegate-questions/{question_id}/reject", | ||
| post(reject_delegate_question_route), |
Comment on lines
1
to
9
| <script lang="ts"> | ||
| import { topicColors } from '$lib/interestColors'; | ||
| import type { InterestShare } from '$lib/types'; | ||
| import { Chart, Svg, Group, Rect } from 'layerchart'; | ||
| import { Chart, Svg, Group, Rect, Treemap } from 'layerchart'; | ||
| import { hierarchy } from 'd3-hierarchy'; | ||
| import ExtendInfoDialog from '../ExtendInfoDialog.svelte'; | ||
| import DetailedInterestsModal from './DetailedInterestsModal.svelte'; | ||
| import { Treemap } from 'layerchart/hierarchy'; | ||
|
|
||
| interface Props { |
| for raw_message in messages { | ||
| let reply = parse_incoming_reply(&raw_message); | ||
| if let Err(error) = store_reply(&pool, reply).await { | ||
| eprintln!("Could not store incoming reply: {error}"); |
| .route(PLENARY_SESSIONS_PER_GP, get(plenary_sessions_per_gp_route)) | ||
| .route("/v1/questions", get(all_delegate_questions_route)) | ||
| .route( | ||
| "/v1/admin/delegate-questions/pending", |
Member
There was a problem hiding this comment.
naming convention would be at least delegate_questions
| .route(ALL_AT_DATE, get(delegates_at_route)) | ||
| .route(ID, get(delegate_by_id_path_route)) | ||
| .route( | ||
| "/{delegate_id}/question-recipient", |
Comment on lines
+1
to
+22
| { | ||
| "ÖVP": { | ||
| "name": "ÖVP-Klub", | ||
| "email": "team@oevpklub.at" | ||
| }, | ||
| "SPÖ": { | ||
| "name": "SPÖ-Klub", | ||
| "email": "klub@spoe.at" | ||
| }, | ||
| "FPÖ": { | ||
| "name": "FPÖ-Klub", | ||
| "email": "parlamentsklub@fpoe.at" | ||
| }, | ||
| "GRÜNE": { | ||
| "name": "Grüner Klub", | ||
| "email": "dialogbuero@gruene.at" | ||
| }, | ||
| "NEOS": { | ||
| "name": "NEOS-Klub", | ||
| "email": "parlamentsklub@neos.eu" | ||
| } | ||
| } |
Member
There was a problem hiding this comment.
would rather use a list but for static entries this should be fine
Comment on lines
+1
to
+729
| use axum::{extract::Path, Json}; | ||
| use chrono::{DateTime, Utc}; | ||
| use delegate_question_mail::new_question_message_id; | ||
| use once_cell::sync::Lazy; | ||
| use reqwest::StatusCode; | ||
| use serde::{Deserialize, Serialize}; | ||
| use sqlx::{Row, Transaction}; | ||
| use std::collections::HashMap; | ||
|
|
||
| use crate::{ | ||
| email::{send_mail_with_message_id, MAILER}, | ||
| jwt::Claims, | ||
| GenericError, PgPoolConnection, | ||
| }; | ||
|
|
||
| const MAX_SUBJECT_LENGTH: usize = 255; | ||
| const MAX_BODY_LENGTH: usize = 10_000; | ||
| const DELEGATE_QUESTION_TEMPLATE: &str = | ||
| include_str!("../../email/delegate_question_template.html"); | ||
| const PARTY_QUESTION_TEMPLATE: &str = include_str!("../../email/party_question_template.html"); | ||
| const PARTY_QUESTION_RECIPIENTS: &str = | ||
| include_str!("../../../config/party-question-recipients.json"); | ||
|
|
||
| static PARTY_RECIPIENTS: Lazy<HashMap<String, PartyRecipientConfig>> = Lazy::new(|| { | ||
| serde_json::from_str(PARTY_QUESTION_RECIPIENTS) | ||
| .expect("party question recipient configuration must be valid JSON") | ||
| }); | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| pub struct CreateDelegateQuestion { | ||
| pub subject: String, | ||
| pub body: String, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct DelegateQuestionCreated { | ||
| pub id: i64, | ||
| pub delivery: QuestionDelivery, | ||
| pub recipient_name: String, | ||
| pub status: String, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize, Clone, Copy)] | ||
| #[serde(rename_all = "snake_case")] | ||
| pub enum QuestionDelivery { | ||
| Delegate, | ||
| Party, | ||
| } | ||
|
|
||
| impl QuestionDelivery { | ||
| fn as_str(self) -> &'static str { | ||
| match self { | ||
| Self::Delegate => "delegate", | ||
| Self::Party => "party", | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct DelegateQuestionRecipient { | ||
| pub delivery: QuestionDelivery, | ||
| pub recipient_name: String, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct PublicDelegateQuestion { | ||
| pub delegate_id: i32, | ||
| pub subject: String, | ||
| pub body: String, | ||
| pub created_at: DateTime<Utc>, | ||
| pub answers: Vec<PublicDelegateQuestionAnswer>, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct PublicDelegateQuestionAnswer { | ||
| pub body: String, | ||
| pub received_at: DateTime<Utc>, | ||
| } | ||
|
|
||
| #[derive(Debug, Serialize)] | ||
| pub struct AdminDelegateQuestion { | ||
| pub id: i64, | ||
| pub user_id: i32, | ||
| pub delegate_id: i32, | ||
| pub delegate_name: String, | ||
| pub recipient_email: String, | ||
| pub recipient_kind: String, | ||
| pub recipient_name: String, | ||
| pub subject: String, | ||
| pub body: String, | ||
| pub status: String, | ||
| pub created_at: DateTime<Utc>, | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| struct PartyRecipientConfig { | ||
| name: String, | ||
| email: String, | ||
| } | ||
|
|
||
| struct DelegateContact { | ||
| name: String, | ||
| recipient_name: String, | ||
| recipient_email: String, | ||
| delivery: QuestionDelivery, | ||
| } | ||
|
|
||
| pub async fn ask_delegate_question_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| claims: Claims, | ||
| Path(delegate_id): Path<i32>, | ||
| Json(question): Json<CreateDelegateQuestion>, | ||
| ) -> Result<Json<DelegateQuestionCreated>, GenericError> { | ||
| let subject = question.subject.trim().to_owned(); | ||
| let body = question.body.trim().to_owned(); | ||
| validate_question(&subject, &body)?; | ||
|
|
||
| let delegate = find_delegate_contact(&pg, delegate_id).await?; | ||
| let outgoing_message_id = new_question_message_id(); | ||
|
|
||
| let question_id = create_question( | ||
| &pg, | ||
| claims.id, | ||
| delegate_id, | ||
| &delegate.recipient_email, | ||
| delegate.delivery, | ||
| &delegate.recipient_name, | ||
| &subject, | ||
| &body, | ||
| &outgoing_message_id, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(Json(DelegateQuestionCreated { | ||
| id: question_id, | ||
| delivery: delegate.delivery, | ||
| recipient_name: delegate.recipient_name, | ||
| status: "pending".to_string(), | ||
| })) | ||
| } | ||
|
|
||
| pub async fn delegate_question_recipient_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| Path(delegate_id): Path<i32>, | ||
| ) -> Result<Json<DelegateQuestionRecipient>, GenericError> { | ||
| let recipient = find_delegate_contact(&pg, delegate_id).await?; | ||
|
|
||
| Ok(Json(DelegateQuestionRecipient { | ||
| delivery: recipient.delivery, | ||
| recipient_name: recipient.recipient_name, | ||
| })) | ||
| } | ||
|
|
||
| pub async fn delegate_questions_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| Path(delegate_id): Path<i32>, | ||
| ) -> Result<Json<Vec<PublicDelegateQuestion>>, GenericError> { | ||
| fetch_public_questions(&pg, Some(delegate_id)) | ||
| .await | ||
| .map(Json) | ||
| } | ||
|
|
||
| pub async fn all_delegate_questions_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| ) -> Result<Json<Vec<PublicDelegateQuestion>>, GenericError> { | ||
| fetch_public_questions(&pg, None).await.map(Json) | ||
| } | ||
|
|
||
| pub async fn pending_delegate_questions_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| claims: Claims, | ||
| ) -> Result<Json<Vec<AdminDelegateQuestion>>, GenericError> { | ||
| ensure_admin(&claims)?; | ||
| fetch_review_questions(&pg).await.map(Json) | ||
| } | ||
|
|
||
| pub async fn approve_delegate_question_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| claims: Claims, | ||
| Path(question_id): Path<i64>, | ||
| ) -> Result<Json<AdminDelegateQuestion>, GenericError> { | ||
| ensure_admin(&claims)?; | ||
|
|
||
| let question = find_admin_question(&pg, question_id).await?; | ||
| if question.status != "pending" && question.status != "failed" { | ||
| return Err(GenericError::Custom(( | ||
| StatusCode::CONFLICT, | ||
| "Question can not be approved", | ||
| ))); | ||
| } | ||
|
|
||
| send_question_mail(&pg, question_id).await?; | ||
| find_admin_question(&pg, question_id).await.map(Json) | ||
| } | ||
|
|
||
| pub async fn reject_delegate_question_route( | ||
| PgPoolConnection(pg): PgPoolConnection, | ||
| claims: Claims, | ||
| Path(question_id): Path<i64>, | ||
| ) -> Result<Json<AdminDelegateQuestion>, GenericError> { | ||
| ensure_admin(&claims)?; | ||
| let question = find_admin_question(&pg, question_id).await?; | ||
| if question.status != "pending" && question.status != "failed" { | ||
| return Err(GenericError::Custom(( | ||
| StatusCode::CONFLICT, | ||
| "Question can not be rejected", | ||
| ))); | ||
| } | ||
|
|
||
| set_question_status(&pg, question_id, "rejected").await?; | ||
| find_admin_question(&pg, question_id).await.map(Json) | ||
| } | ||
|
|
||
| fn ensure_admin(claims: &Claims) -> Result<(), GenericError> { | ||
| if claims.is_admin { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| Err(GenericError::Custom(( | ||
| StatusCode::UNAUTHORIZED, | ||
| "insufficient permissions", | ||
| ))) | ||
| } | ||
|
|
||
| fn validate_question(subject: &str, body: &str) -> Result<(), GenericError> { | ||
| if subject.is_empty() || body.is_empty() { | ||
| return Err(GenericError::Custom(( | ||
| StatusCode::BAD_REQUEST, | ||
| "Subject and body are required", | ||
| ))); | ||
| } | ||
|
|
||
| if subject.chars().count() > MAX_SUBJECT_LENGTH || body.chars().count() > MAX_BODY_LENGTH { | ||
| return Err(GenericError::Custom(( | ||
| StatusCode::BAD_REQUEST, | ||
| "Question is too long", | ||
| ))); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| async fn find_delegate_contact( | ||
| pg: &sqlx::PgPool, | ||
| delegate_id: i32, | ||
| ) -> Result<DelegateContact, GenericError> { | ||
| let row = sqlx::query( | ||
| " | ||
| SELECT d.name, d.party, c.mail | ||
| FROM delegates d | ||
| JOIN contacts c ON c.id = d.id | ||
| WHERE d.id = $1 | ||
| ", | ||
| ) | ||
| .bind(delegate_id) | ||
| .fetch_optional(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))? | ||
| .ok_or(GenericError::Custom(( | ||
| StatusCode::NOT_FOUND, | ||
| "Delegate was not found", | ||
| )))?; | ||
|
|
||
| let email: Option<String> = row | ||
| .try_get("mail") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| let name: String = row | ||
| .try_get("name") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| if let Some(email) = email.filter(|email| !email.trim().is_empty()) { | ||
| return Ok(DelegateContact { | ||
| recipient_name: name.clone(), | ||
| name, | ||
| recipient_email: email, | ||
| delivery: QuestionDelivery::Delegate, | ||
| }); | ||
| } | ||
|
|
||
| let party: Option<String> = row | ||
| .try_get("party") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
| let party = party.ok_or(GenericError::Custom(( | ||
| StatusCode::UNPROCESSABLE_ENTITY, | ||
| "Delegate has no email address or party assignment", | ||
| )))?; | ||
| let recipient = PARTY_RECIPIENTS | ||
| .get(party.trim()) | ||
| .ok_or(GenericError::Custom(( | ||
| StatusCode::UNPROCESSABLE_ENTITY, | ||
| "No question recipient is configured for this party", | ||
| )))?; | ||
|
|
||
| Ok(DelegateContact { | ||
| name, | ||
| recipient_name: recipient.name.clone(), | ||
| recipient_email: recipient.email.clone(), | ||
| delivery: QuestionDelivery::Party, | ||
| }) | ||
| } | ||
|
|
||
| async fn fetch_public_questions( | ||
| pg: &sqlx::PgPool, | ||
| delegate_id: Option<i32>, | ||
| ) -> Result<Vec<PublicDelegateQuestion>, GenericError> { | ||
| let rows = sqlx::query( | ||
| " | ||
| SELECT | ||
| q.id AS question_id, | ||
| q.delegate_id, | ||
| q.subject, | ||
| q.body AS question_body, | ||
| q.created_at, | ||
| a.body AS answer_body, | ||
| a.received_at AS answer_received_at | ||
| FROM delegate_questions q | ||
| LEFT JOIN delegate_question_answers a ON a.question_id = q.id | ||
| WHERE ($1::INTEGER IS NULL OR q.delegate_id = $1) | ||
| AND q.status IN ('sent', 'answered') | ||
| ORDER BY q.created_at DESC, a.received_at ASC NULLS LAST | ||
| ", | ||
| ) | ||
| .bind(delegate_id) | ||
| .fetch_all(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| let mut questions = Vec::new(); | ||
| let mut current_question_id = None; | ||
|
|
||
| for row in rows { | ||
| let question_id: i64 = row | ||
| .try_get("question_id") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| if current_question_id != Some(question_id) { | ||
| questions.push(PublicDelegateQuestion { | ||
| delegate_id: row | ||
| .try_get("delegate_id") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| subject: row | ||
| .try_get("subject") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| body: row | ||
| .try_get("question_body") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| created_at: row | ||
| .try_get("created_at") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| answers: Vec::new(), | ||
| }); | ||
| current_question_id = Some(question_id); | ||
| } | ||
|
|
||
| let answer_body: Option<String> = row | ||
| .try_get("answer_body") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
| let answer_received_at: Option<DateTime<Utc>> = row | ||
| .try_get("answer_received_at") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| if let (Some(body), Some(received_at)) = (answer_body, answer_received_at) { | ||
| if let Some(question) = questions.last_mut() { | ||
| question | ||
| .answers | ||
| .push(PublicDelegateQuestionAnswer { body, received_at }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Ok(questions) | ||
| } | ||
|
|
||
| async fn fetch_review_questions( | ||
| pg: &sqlx::PgPool, | ||
| ) -> Result<Vec<AdminDelegateQuestion>, GenericError> { | ||
| let rows = sqlx::query( | ||
| " | ||
| SELECT | ||
| q.id, | ||
| q.user_id, | ||
| q.delegate_id, | ||
| d.name AS delegate_name, | ||
| q.recipient_email, | ||
| q.recipient_kind, | ||
| q.recipient_name, | ||
| q.subject, | ||
| q.body, | ||
| q.status, | ||
| q.created_at | ||
| FROM delegate_questions q | ||
| JOIN delegates d ON d.id = q.delegate_id | ||
| WHERE q.status IN ('pending', 'failed') | ||
| ORDER BY q.created_at ASC | ||
| ", | ||
| ) | ||
| .fetch_all(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| rows.into_iter().map(admin_question_from_row).collect() | ||
| } | ||
|
|
||
| async fn find_admin_question( | ||
| pg: &sqlx::PgPool, | ||
| question_id: i64, | ||
| ) -> Result<AdminDelegateQuestion, GenericError> { | ||
| let row = sqlx::query( | ||
| " | ||
| SELECT | ||
| q.id, | ||
| q.user_id, | ||
| q.delegate_id, | ||
| d.name AS delegate_name, | ||
| q.recipient_email, | ||
| q.recipient_kind, | ||
| q.recipient_name, | ||
| q.subject, | ||
| q.body, | ||
| q.status, | ||
| q.created_at | ||
| FROM delegate_questions q | ||
| JOIN delegates d ON d.id = q.delegate_id | ||
| WHERE q.id = $1 | ||
| ", | ||
| ) | ||
| .bind(question_id) | ||
| .fetch_optional(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))? | ||
| .ok_or(GenericError::Custom(( | ||
| StatusCode::NOT_FOUND, | ||
| "Question was not found", | ||
| )))?; | ||
|
|
||
| admin_question_from_row(row) | ||
| } | ||
|
|
||
| fn admin_question_from_row( | ||
| row: sqlx::postgres::PgRow, | ||
| ) -> Result<AdminDelegateQuestion, GenericError> { | ||
| Ok(AdminDelegateQuestion { | ||
| id: row | ||
| .try_get("id") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| user_id: row | ||
| .try_get("user_id") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| delegate_id: row | ||
| .try_get("delegate_id") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| delegate_name: row | ||
| .try_get("delegate_name") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| recipient_email: row | ||
| .try_get("recipient_email") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| recipient_kind: row | ||
| .try_get("recipient_kind") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| recipient_name: row | ||
| .try_get("recipient_name") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| subject: row | ||
| .try_get("subject") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| body: row | ||
| .try_get("body") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| status: row | ||
| .try_get("status") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| created_at: row | ||
| .try_get("created_at") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| }) | ||
| } | ||
|
|
||
| async fn send_question_mail(pg: &sqlx::PgPool, question_id: i64) -> Result<(), GenericError> { | ||
| let row = sqlx::query( | ||
| " | ||
| SELECT | ||
| q.delegate_id, | ||
| d.name AS delegate_name, | ||
| q.recipient_email, | ||
| q.recipient_kind, | ||
| q.recipient_name, | ||
| q.subject, | ||
| q.body, | ||
| q.outgoing_message_id | ||
| FROM delegate_questions q | ||
| JOIN delegates d ON d.id = q.delegate_id | ||
| WHERE q.id = $1 | ||
| ", | ||
| ) | ||
| .bind(question_id) | ||
| .fetch_optional(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))? | ||
| .ok_or(GenericError::Custom(( | ||
| StatusCode::NOT_FOUND, | ||
| "Question was not found", | ||
| )))?; | ||
|
|
||
| let locked = sqlx::query( | ||
| " | ||
| UPDATE delegate_questions | ||
| SET status = 'sending', | ||
| updated_at = NOW() | ||
| WHERE id = $1 | ||
| AND status IN ('pending', 'failed') | ||
| ", | ||
| ) | ||
| .bind(question_id) | ||
| .execute(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| if locked.rows_affected() == 0 { | ||
| return Err(GenericError::Custom(( | ||
| StatusCode::CONFLICT, | ||
| "Question can not be approved", | ||
| ))); | ||
| } | ||
|
|
||
| let recipient_kind: String = row | ||
| .try_get("recipient_kind") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
| let delivery = match recipient_kind.as_str() { | ||
| "delegate" => QuestionDelivery::Delegate, | ||
| "party" => QuestionDelivery::Party, | ||
| _ => { | ||
| return Err(GenericError::Custom(( | ||
| StatusCode::INTERNAL_SERVER_ERROR, | ||
| "Invalid question recipient kind", | ||
| ))) | ||
| } | ||
| }; | ||
|
|
||
| let delegate = DelegateContact { | ||
| name: row | ||
| .try_get("delegate_name") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| recipient_name: row | ||
| .try_get("recipient_name") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| recipient_email: row | ||
| .try_get("recipient_email") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?, | ||
| delivery, | ||
| }; | ||
| let subject: String = row | ||
| .try_get("subject") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
| let body: String = row | ||
| .try_get("body") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
| let outgoing_message_id: String = row | ||
| .try_get("outgoing_message_id") | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| let mail_subject = format!("Neue Frage über somes.at: {subject}"); | ||
| let mail_content = render_question_mail(&delegate, &subject, &body); | ||
| let recipient_email = delegate.recipient_email.clone(); | ||
|
|
||
| let mail_result = match tokio::task::spawn_blocking(move || { | ||
| send_mail_with_message_id( | ||
| &MAILER, | ||
| &recipient_email, | ||
| &mail_subject, | ||
| mail_content, | ||
| Some(outgoing_message_id), | ||
| ) | ||
| .map_err(|error| error.to_string()) | ||
| }) | ||
| .await | ||
| { | ||
| Ok(result) => result, | ||
| Err(error) => { | ||
| log::error!("delegate question mail task failed: {error}"); | ||
| Err(error.to_string()) | ||
| } | ||
| }; | ||
|
|
||
| match mail_result { | ||
| Ok(()) => set_question_status(pg, question_id, "sent").await, | ||
| Err(error) => { | ||
| log::error!("sending delegate question {question_id} failed: {error}"); | ||
| set_question_status(pg, question_id, "failed").await?; | ||
| Err(GenericError::Custom(( | ||
| StatusCode::BAD_GATEWAY, | ||
| "Could not send question email", | ||
| ))) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async fn create_question( | ||
| pg: &sqlx::PgPool, | ||
| user_id: i32, | ||
| delegate_id: i32, | ||
| recipient_email: &str, | ||
| delivery: QuestionDelivery, | ||
| recipient_name: &str, | ||
| subject: &str, | ||
| body: &str, | ||
| outgoing_message_id: &str, | ||
| ) -> Result<i64, GenericError> { | ||
| let mut transaction: Transaction<'_, sqlx::Postgres> = pg | ||
| .begin() | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| let question_id: i64 = sqlx::query_scalar( | ||
| " | ||
| INSERT INTO delegate_questions | ||
| (user_id, delegate_id, recipient_email, recipient_kind, recipient_name, subject, body, outgoing_message_id) | ||
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8) | ||
| RETURNING id | ||
| ", | ||
| ) | ||
| .bind(user_id) | ||
| .bind(delegate_id) | ||
| .bind(recipient_email) | ||
| .bind(delivery.as_str()) | ||
| .bind(recipient_name) | ||
| .bind(subject) | ||
| .bind(body) | ||
| .bind(outgoing_message_id) | ||
| .fetch_one(&mut *transaction) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| transaction | ||
| .commit() | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| Ok(question_id) | ||
| } | ||
|
|
||
| async fn set_question_status( | ||
| pg: &sqlx::PgPool, | ||
| question_id: i64, | ||
| status: &str, | ||
| ) -> Result<(), GenericError> { | ||
| sqlx::query( | ||
| " | ||
| UPDATE delegate_questions | ||
| SET status = $1, | ||
| sent_at = CASE WHEN $1 = 'sent' THEN NOW() ELSE sent_at END, | ||
| updated_at = NOW() | ||
| WHERE id = $2 | ||
| ", | ||
| ) | ||
| .bind(status) | ||
| .bind(question_id) | ||
| .execute(pg) | ||
| .await | ||
| .map_err(|error| GenericError::SqlFailure(Some(error)))?; | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn render_question_mail(recipient: &DelegateContact, subject: &str, body: &str) -> String { | ||
| let template = match recipient.delivery { | ||
| QuestionDelivery::Delegate => DELEGATE_QUESTION_TEMPLATE, | ||
| QuestionDelivery::Party => PARTY_QUESTION_TEMPLATE, | ||
| }; | ||
|
|
||
| template | ||
| .replace("{*DELEGATE_NAME*}", &escape_html(&recipient.name)) | ||
| .replace("{*PARTY_NAME*}", &escape_html(&recipient.recipient_name)) | ||
| .replace("{*QUESTION_SUBJECT*}", &escape_html(subject)) | ||
| .replace( | ||
| "{*QUESTION_BODY*}", | ||
| &escape_html(body).replace('\n', "<br>"), | ||
| ) | ||
| } | ||
|
|
||
| fn escape_html(value: &str) -> String { | ||
| value | ||
| .replace('&', "&") | ||
| .replace('<', "<") | ||
| .replace('>', ">") | ||
| .replace('"', """) | ||
| .replace('\'', "'") | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{render_question_mail, DelegateContact, QuestionDelivery}; | ||
|
|
||
| #[test] | ||
| fn renders_question_as_safe_html() { | ||
| let rendered = render_question_mail( | ||
| &DelegateContact { | ||
| name: "Max <Mustermann>".to_string(), | ||
| recipient_name: "Max <Mustermann>".to_string(), | ||
| recipient_email: "max@example.com".to_string(), | ||
| delivery: QuestionDelivery::Delegate, | ||
| }, | ||
| "Ist <b>das</b> geplant?", | ||
| "Bitte <script>alert('x')</script>", | ||
| ); | ||
|
|
||
| assert!(rendered.contains("Max <Mustermann>")); | ||
| assert!(rendered.contains("<script>alert('x')</script>")); | ||
| assert!(!rendered.contains("somes-q:")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn renders_party_fallback_mail() { | ||
| let rendered = render_question_mail( | ||
| &DelegateContact { | ||
| name: "Max Mustermann".to_string(), | ||
| recipient_name: "ÖVP-Klub".to_string(), | ||
| recipient_email: "team@example.com".to_string(), | ||
| delivery: QuestionDelivery::Party, | ||
| }, | ||
| "Frage", | ||
| "Bitte um Antwort", | ||
| ); | ||
|
|
||
| assert!(rendered.contains("Sehr geehrtes Team des ÖVP-Klub")); | ||
| assert!(rendered.contains("Max Mustermann")); | ||
| } | ||
| } |
Member
There was a problem hiding this comment.
split up in proper files first
| questions = questions.filter((question) => question.id !== questionId); | ||
| } | ||
|
|
||
| function formatDate(value: string) { |
Member
There was a problem hiding this comment.
don't let chatty implement this EVERY time thanks (library provides this function already)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.