attempt at fixing confirmation emails by setting email headers, link for manual confirmation email and cut next release #29

Merged
ben merged 8 commits from ben/Jobboerse:main into main 2022-06-10 15:36:51 +02:00
16 changed files with 165 additions and 115 deletions

3
Cargo.lock generated
View file

@ -1211,7 +1211,7 @@ checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
[[package]]
name = "jobboerse"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"actix-files",
"actix-multipart",
@ -2523,6 +2523,7 @@ dependencies = [
"idna",
"matches",
"percent-encoding",
"serde",
]
[[package]]

View file

@ -3,7 +3,7 @@ members = [".", "packages/*"]
[package]
name = "jobboerse"
version = "0.2.0"
version = "0.2.1"
edition = "2021"
rust-version = "1.58"
repository = "https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse"
@ -48,7 +48,7 @@ tempfile = "3.3.0"
thiserror = "1.0.31"
toml = "0.5.9"
tokio = "1.18.2"
url = "2.2.2"
url = {version = "2.2.2", features = ["serde"]}
[build-dependencies]
cargo-bundle-licenses = { version = "0.5.0", default-features = false }

View file

@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.1] (2022-03-10)
### Add
- reviewer link for manually sending a confirmation email
### Change
- some internal cleanup
### Fix
- confirmation emails now set the UserAgent and Auto-Submitted headers
- the error pages using the generic_error_handler now set the Content-Type header
- prevent error on missing edit action
- occurred for reviewers submitting job offers as the actions are not set for the preview after submission
## [0.2.0] (2022-06-09)
### Add
@ -92,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Overview Page of Dependency licenses
[Unreleased]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/main
[0.2.1]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.2.1
[0.2.0]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.2.0
[0.1.6]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.1.6
[0.1.5]: https://www.fs-infmath.uni-kiel.de/git/FS-InfMath/Jobboerse/src/version-0.1.5

View file

@ -5,7 +5,7 @@ banner = "Hinweis: Die Jobbörse wird aktuall noch evaluiert und befindet sich n
type = 'Development'
[email]
from = "jobs@localhost"
from = "Test-Jobbörse <jobs@localhost>"
subject = "Test"
[[footer_links]]

2
dist/arch/PKGBUILD vendored
View file

@ -9,7 +9,7 @@ _reponame=Jobboerse
_pkgname="${_reponame,,}"
_features=()
pkgname="${_reponame,,}"
pkgver=0.2.0
pkgver=0.2.1
pkgrel=1
pkgdesc="FS-InfMath Job-Offer Page"
arch=('x86_64') # Other architectures may work

90
src/email.rs Normal file
View file

@ -0,0 +1,90 @@
use crate::job_offers::error::EmailError;
use crate::server_config::EmailConfig;
use crate::template;
use handlebars::Handlebars;
use lettre::message::header::{Header, HeaderName, HeaderValue, UserAgent};
use lettre::message::{Mailbox, SinglePart};
use lettre::{Address, AsyncTransport};
use log::warn;
use url::Url;
#[derive(Clone, Debug)]
struct AutoGeneratedHeader(String);
impl Default for AutoGeneratedHeader {
fn default() -> Self {
Self("auto-generated".into())
}
}
impl Header for AutoGeneratedHeader {
fn name() -> HeaderName {
HeaderName::new_from_ascii_str("Auto-Submitted")
}
fn parse(s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Ok(Self(s.into()))
}
fn display(&self) -> HeaderValue {
HeaderValue::new(Self::name(), self.0.clone())
}
}
fn user_agent() -> UserAgent {
String::from("fs-infmath-jobboerse via sendmail").into()
}
#[derive(serde::Serialize)]
pub(crate) struct EmailData {
pub(crate) confirmation_link: Url,
}
pub(crate) async fn send_confirmation_email(
hb: &Handlebars<'_>,
contact_address: Address,
email_config: &EmailConfig,
email_data: &EmailData,
) -> actix_web::Result<(), EmailError> {
// receiver of the confirmation e-mail
let to_mailbox = Mailbox::new(None, contact_address);
let email_body = hb.render(template::EMAIL_PLAIN, &email_data)?;
let message = lettre::Message::builder()
.from(email_config.from.to_owned())
.to(to_mailbox)
.subject(&email_config.subject)
.header(user_agent())
.header(AutoGeneratedHeader::default())
.singlepart(SinglePart::plain(email_body))?;
lettre::AsyncSendmailTransport::new().send(message).await?;
// successfully send a confirmation e-mail now send a notice to our-self
let message = lettre::Message::builder()
.from(email_config.from.to_owned())
.to(email_config.from.to_owned())
.subject(&email_config.subject)
.header(user_agent())
.header(AutoGeneratedHeader::default())
.singlepart(SinglePart::plain(
"Automatischer Hinweis: Eine neue Stellenausschreibung wurde zur Jobbörse eingereicht!"
.to_owned(),
));
match message {
Ok(msg) => {
dbg!(&msg);
if let Err(err) = lettre::AsyncSendmailTransport::new().send(msg).await {
warn!("Failed to send remainder {}", err);
}
}
Err(err) => {
warn!("Failed to construct reminder {}", err)
}
}
Ok(())
}

View file

@ -455,7 +455,7 @@ impl JobOffer<PathBuf> {
.collect::<Result<_, PresentationError>>()?;
let actions = if !is_preview && is_authenticated {
Some(JobOfferActions::new(req, id)?)
Some(JobOfferActions::new(req, id, self)?)
} else {
None
};

View file

@ -1,12 +1,15 @@
use crate::job_offers::{Attachment, JobOfferId, JobOfferStatus, Link};
use crate::route::{
JOBOFFER_DELETION_ROUTE, JOBOFFER_EDIT_ROUTE, JOBOFFER_PUBLISH_ROUTE, JOBOFFER_UNPUBLISH_ROUTE,
use crate::job_offers::{
Attachment, ConfirmationStatus, JobOffer, JobOfferId, JobOfferStatus, Link,
};
use crate::route::{
JOBOFFER_CONFIRM_ROUTE, JOBOFFER_DELETION_ROUTE, JOBOFFER_EDIT_ROUTE, JOBOFFER_PUBLISH_ROUTE,
JOBOFFER_UNPUBLISH_ROUTE,
};
use crate::util::SerializableUrl;
use actix_web::error::UrlGenerationError;
use actix_web::HttpRequest;
use chrono::FixedOffset;
use lettre::Address;
use std::path::PathBuf;
use url::Url;
#[derive(serde::Serialize)]
@ -20,7 +23,7 @@ pub(crate) struct JobOfferEditData {
pub(crate) expiry_date: Option<String>,
pub(crate) submission_date: String,
pub(crate) title: String,
pub(crate) attachments: Vec<Attachment<SerializableUrl>>,
pub(crate) attachments: Vec<Attachment<Url>>,
pub(crate) links: Vec<Option<Link>>,
}
@ -47,20 +50,18 @@ pub(crate) struct JobOfferViewData {
#[derive(serde::Serialize)]
pub struct JobOfferActions {
#[serde(serialize_with = "crate::util::url_as_string")]
publish_url: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
unpublish_url: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
delete_url: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
edit_url: Url,
confirmation_link: Option<Url>,
}
impl JobOfferActions {
pub(super) fn new(
req: &HttpRequest,
id: &JobOfferId,
offer: &JobOffer<PathBuf>,
) -> actix_web::Result<Self, UrlGenerationError> {
let publish_url = req
.url_for(JOBOFFER_PUBLISH_ROUTE, &[id])
@ -78,11 +79,19 @@ impl JobOfferActions {
.url_for(JOBOFFER_EDIT_ROUTE, &[id])
.expect("generation of delete route urls should succeed");
let confirmation_url = match &offer.status.confirmation_status {
ConfirmationStatus::AwaitingConfirmation { token } => {
Some(req.url_for(JOBOFFER_CONFIRM_ROUTE, [id, token])?)
}
ConfirmationStatus::Confirmed => None,
};
Ok(Self {
publish_url,
unpublish_url,
delete_url,
edit_url,
confirmation_link: confirmation_url,
})
}
}

View file

@ -19,6 +19,7 @@ use job_offers::lease::SubmissionLimiter;
use route::error_handler;
mod auth;
mod email;
mod error;
mod job_offers;
mod route;

View file

@ -17,6 +17,7 @@ mod license;
pub(crate) use auth::{LOGIN_ROUTE, LOGOUT_ROUTE};
pub(crate) use job_offer::{
confirmation::JOBOFFER_CONFIRM_ROUTE,
create::JOBOFFER_CREATION_ROUTE,
delete::{JOBOFFER_BULK_DELETE_ROUTE, JOBOFFER_DELETE_EXPIRED_ROUTE, JOBOFFER_DELETION_ROUTE},
edit::JOBOFFER_EDIT_ROUTE,
@ -29,7 +30,6 @@ pub(crate) use license::{LICENSES_ROUTE, LICENSE_BUNDLE};
use crate::error::PresentationError;
use crate::server_config::OperationMode;
use crate::server_config::ServerConfig;
use crate::util::SerializableUrl;
static HTML_CONTENT: HeaderValue = HeaderValue::from_static("text/html");
static JSON_CONTENT: HeaderValue = HeaderValue::from_static("application/json");
@ -60,7 +60,7 @@ struct BaseData<'a> {
title: Cow<'a, str>,
short_lang: Cow<'a, str>,
links: Vec<Link<'a>>,
styles: Vec<SerializableUrl>,
styles: Vec<Url>,
routes: StaticRoutes,
banner: Option<String>,
operation_mode: OperationMode,
@ -70,25 +70,15 @@ struct BaseData<'a> {
#[derive(Serialize)]
struct StaticRoutes {
#[serde(serialize_with = "crate::util::url_as_string")]
licenses: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
login: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
logout: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
sync: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
index: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffer_overview: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffer_create: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffer_summary: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffers_delete_expired: Url,
#[serde(serialize_with = "crate::util::url_as_string")]
joboffers_bulk_delete: Url,
}
@ -155,7 +145,10 @@ fn base<'a>(
.iter()
.map(|elem| {
default_links.retain(|&(title, _)| title != elem.title);
(elem.title.as_str(), elem.url.as_deref())
(
elem.title.as_str(),
elem.url.as_ref().map(|elem| elem.as_str()),
)
})
.collect::<Vec<_>>();

View file

@ -1,7 +1,7 @@
use crate::auth::User;
use crate::error::PresentationError;
use crate::job_offers::error::SaveResponseError;
use crate::route::LOGIN_ROUTE;
use crate::route::{HTML_CONTENT, LOGIN_ROUTE};
use crate::{route, ServerConfig};
use crate::route::job_offer::edit::EditResponseError;
@ -16,7 +16,7 @@ use actix_web::middleware::ErrorHandlerResponse;
use actix_web::web::Data;
use actix_web::{HttpRequest, HttpResponse, ResponseError};
use handlebars::Handlebars;
use http::header::LOCATION;
use http::header::{CONTENT_TYPE, LOCATION};
use http::Method;
use lettre::address::AddressError;
use log::{error, warn};
@ -54,9 +54,14 @@ pub(crate) fn generic_server_error_handler<B>(
.render(template, &data)
.map_err(PresentationError::Render)?;
let response = HttpResponse::with_body(res.status(), body)
let mut response = HttpResponse::with_body(res.status(), body)
.map_into_boxed_body()
.map_into_right_body();
response
.headers_mut()
.insert(CONTENT_TYPE, HTML_CONTENT.clone());
let response = res.into_response(response);
Ok(ErrorHandlerResponse::Response(response))

View file

@ -8,6 +8,7 @@ use handlebars::Handlebars;
use http::header::CONTENT_TYPE;
use serde::{Deserialize, Serialize};
use serde_json::json;
use url::Url;
pub(crate) mod confirmation;
pub(crate) mod create;
@ -24,7 +25,6 @@ use crate::route::job_offer::error::SyncResponseError;
use crate::route::{HTML_CONTENT, JSON_CONTENT};
use crate::server_config::ServerConfig;
use crate::template;
use crate::util::SerializableUrl;
pub fn configure(service: &mut ServiceConfig) {
service
@ -171,13 +171,13 @@ pub(crate) async fn summary(
struct SummaryData {
version: &'static str,
entries: Vec<JobOfferViewData>,
overview: SerializableUrl,
overview: Url,
}
let data = json!(SummaryData {
version: "1",
entries: previews,
overview: SerializableUrl(req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?)
overview: req.url_for_static(JOBOFFER_OVERVIEW_ROUTE)?
});
Ok(HttpResponse::Ok()

View file

@ -7,18 +7,17 @@ use actix_session::Session;
use actix_web::{get, post, web, HttpRequest, HttpResponse, Result};
use futures_util::StreamExt;
use handlebars::Handlebars;
use lettre::message::{Mailbox, SinglePart};
use lettre::{Address, AsyncTransport};
use lettre::Address;
use log::{debug, error, warn};
use rand::distributions::DistString;
use serde::Serialize;
use serde_json::json;
use tempfile::NamedTempFile;
use url::Url;
use crate::auth::User;
use crate::email::EmailData;
use crate::error::{LoginRequired, PresentationError};
use crate::job_offers::error::{EmailError, SaveResponseError};
use crate::job_offers::error::SaveResponseError;
use crate::job_offers::lease::SubmissionLimiter;
use crate::job_offers::{
Attachment, ConfirmationStatus, JobOffer, JobOfferStatus, JobOffers, Link, MutBorrowedJobOffer,
@ -28,9 +27,9 @@ use crate::route::form_constants::{self, UploadLimits};
use crate::route::job_offer::confirmation::JOBOFFER_CONFIRM_ROUTE;
use crate::route::job_offer::error::{FormProcessingError, SubmissionResponseError};
use crate::route::HTML_CONTENT;
use crate::server_config::{EmailConfig, ServerConfig};
use crate::template;
use crate::server_config::ServerConfig;
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
use crate::{email, template};
use multipart_helper::{multi_field, multi_file, once_field};
pub(crate) const JOBOFFER_CREATION_ROUTE: &str = "create_offer";
@ -251,12 +250,12 @@ pub(crate) async fn create_job_offer<'data, 'config>(
if let Some(email_config) = &config.config.email {
let confirm_url = req.url_for(JOBOFFER_CONFIRM_ROUTE, &[created_offer.id(), &token])?;
send_confirmation_email(
email::send_confirmation_email(
hb,
job_offer_form.contact,
email_config,
&EmailData {
confirmation_link: confirm_url,
confirmation_link: confirm_url.into(),
},
)
.await?;
@ -266,52 +265,6 @@ pub(crate) async fn create_job_offer<'data, 'config>(
Ok(created_offer)
}
#[derive(serde::Serialize)]
struct EmailData {
#[serde(serialize_with = "crate::util::url_as_string")]
confirmation_link: Url,
}
async fn send_confirmation_email(
hb: &Handlebars<'_>,
contact_address: Address,
email_config: &EmailConfig,
email_data: &EmailData,
) -> Result<(), EmailError> {
let to_mailbox = Mailbox::new(None, contact_address);
let email_body = hb.render(template::EMAIL_PLAIN, &email_data)?;
let message = lettre::Message::builder()
.from(email_config.from.to_owned())
.to(to_mailbox)
.subject(&email_config.subject)
.singlepart(SinglePart::plain(email_body))?;
lettre::AsyncSendmailTransport::new().send(message).await?;
let message = lettre::Message::builder()
.from(email_config.from.to_owned())
.to(email_config.from.to_owned())
.subject(&email_config.subject)
.singlepart(SinglePart::plain(
"Automatischer Hinweis: Eine neue Stellenausschreibung wurde zur Jobbörse eingereicht!"
.to_owned(),
));
match message {
Ok(msg) => {
if let Err(err) = lettre::AsyncSendmailTransport::new().send(msg).await {
warn!("Failed to send remainder {}", err);
}
}
Err(err) => {
warn!("Failed to construct reminder {}", err)
}
}
Ok(())
}
impl JobOfferSubmitForm {
/// Convert the Multipart struct representing multipart form-data
/// into structured form data

View file

@ -6,6 +6,7 @@ use log::{info, warn};
use serde::Deserialize;
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use url::Url;
use crate::auth::LoginProviderConfig;
use crate::error::ConfigError;
@ -42,7 +43,7 @@ pub(crate) struct ProgramConfig {
pub(crate) struct Link {
pub(crate) title: String,
#[serde(default)]
pub(crate) url: Option<String>,
pub(crate) url: Option<Url>,
}
#[derive(Serialize, Deserialize, Debug)]

View file

@ -1,10 +1,9 @@
use crate::job_offers::{Attachment, Link};
use better_toml_datetime::Offset;
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::{Deserialize, Deserializer};
use std::str::FromStr;
use tempfile::NamedTempFile;
use url::Url;
// we basically don't do any proper error handling here,
// as we mostly expect the format conversion to be infallible
@ -138,30 +137,6 @@ where
<Option<String> as Deserialize>::deserialize(des).map(|option| option.is_some())
}
pub(crate) fn url_as_string<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(url.as_str())
}
#[derive(Serialize)]
pub(crate) struct SerializableUrl(
#[serde(serialize_with = "crate::util::url_as_string")] pub url::Url,
);
impl From<Url> for SerializableUrl {
fn from(url: Url) -> Self {
Self(url)
}
}
impl From<SerializableUrl> for Url {
fn from(SerializableUrl(url): SerializableUrl) -> Self {
url
}
}
pub(crate) fn parse_date(
input: Option<&str>,
) -> Result<Option<better_toml_datetime::Date>, <better_toml_datetime::Date as FromStr>::Err> {

View file

@ -68,6 +68,10 @@
<div>ID: {{job_offer.id}}</div>
<div>Review Status: <span class="{{#unless job_offer.reviewed}}unreviewed{{/unless}}">{{job_offer.status.review_status}}</span></div>
<div>Confirmation Status: <span class="{{#unless job_offer.confirmed}}unconfirmed{{/unless}}">{{job_offer.status.confirmation_status.type}}</span></div>
{{log job_offer.actions}}
{{#if job_offer.actions.confirmation_link}}
<div><a href="mailto:{{job_offer.contact_data}}?body=Confirmation%20Link:%20{{job_offer.actions.confirmation_link}}" >Manual Confirmation Mail</a></div>
{{/if}}
{{/unless}}
{{#if job_offer.actions }}
@ -100,9 +104,12 @@
{{#*inline "formaction"}}{{job_offer.actions.delete_url}}{{/inline}}
{{/confirm-modal}}
{{/if}}
{{#if job_offer.actions.edit_url }}
<a href="{{job_offer.actions.edit_url}}">Bearbeiten</a>
{{/if}}
{{/if}}
<a href="{{job_offer.actions.edit_url}}">Bearbeiten</a>
{{/if}}
</div>
</section>