//! The creator-facing half of the monthly mail allowance: asking for more. //! //! The cap is a soft maximum, and a soft maximum without a way to ask is a hard //! one with better manners. This is that way (`db::mail_caps`): a creator names //! a number and says why, an operator decides, and a grant writes the //! per-account override. //! //! The explanation is not a formality. It is what separates a growing list from //! a stale one being blasted, which is the distinction the number alone cannot //! carry. use axum::{ Form, extract::State, response::{Html, IntoResponse, Response}, }; use serde::Deserialize; use sqlx::PgPool; use crate::{ auth::AuthUser, config::Config, db, email::EmailClient, error::Result, templates::FormStatusTemplate, }; /// Where a filed request is announced. /// /// The queue at `/admin/mail-caps` is where a request *lives*; this is what /// tells anybody it arrived. A soft cap's whole defence is that the application /// path works, and an application path nobody is told about is a page somebody /// has to remember to open -- which, for a creator whose sends are being /// refused, is the cap behaving like a hard one after all. /// /// The support address rather than `info@`: role addresses land in one mailbox /// and the sorting is done on the address, so a precise one is the whole /// benefit of having several. const OPERATOR: &str = "support@makenot.work"; /// Form input for an allowance increase. #[derive(Debug, Deserialize)] pub(crate) struct MailCapRequestForm { pub requested_cap: i32, pub reason: String, } /// The smallest reason that can carry an argument. Shorter than this is a number /// with no case attached, and the case is the whole point of asking. const MIN_REASON_CHARS: usize = 20; /// An upper bound on the ask, so a typo becomes a validation error rather than /// an operator staring at a request for ten million. const MAX_REQUESTED_CAP: i32 = 1_000_000; /// Ask for a bigger monthly mail allowance. #[tracing::instrument(skip_all, name = "users::mail_cap_request")] pub(in crate::routes::api) async fn mail_cap_request( State(db): State, State(email): State, State(config): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; let refuse = |message: &str| -> Result { Ok(Html( FormStatusTemplate { success: false, message: message.to_string(), } .render_string()?, ) .into_response()) }; if !user.can_create_projects { return refuse("Creator access required"); } let reason = form.reason.trim(); if reason.chars().count() < MIN_REASON_CHARS { return refuse( "Tell us what you are sending and roughly how often, so we can set a number that fits.", ); } if reason.chars().count() > 1000 { return refuse("Keep the explanation under 1000 characters."); } if form.requested_cap <= 0 || form.requested_cap > MAX_REQUESTED_CAP { return refuse("Ask for a number between 1 and 1,000,000 emails a month."); } // Asking for less than the current allowance is almost always a // misunderstanding of what the number means, and granting it would lower // the creator's own cap. let current = db::mail_caps::effective_cap(&db, user.id).await?; if i64::from(form.requested_cap) <= current { return refuse(&format!( "You already have {current} emails a month. Ask for a number above that." )); } let filed = db::mail_caps::create_request(&db, user.id, form.requested_cap, reason).await?; if !filed { return refuse( "You already have a request open. We will come back to you on that one rather than \ queueing a second.", ); } tracing::info!( user_id = %user.id, requested_cap = form.requested_cap, "creator asked for a higher monthly mail allowance" ); // After the row, and never a reason to fail the request. The queue holds // the ask whether or not this arrives, so a mail failure costs a doorbell // and not a creator's application. let announcement = format!( "{} ({}) asks for {} emails a month, up from {current}.\n\nUser ID: {}\n\nWhy:\n{reason}\n\nDecide at {}/admin/mail-caps", user.username, user.email, form.requested_cap, user.id, config.host_url, ); if let Err(error) = email .send_alert( OPERATOR, &format!( "[mail-cap] {} asks for {}", user.username, form.requested_cap ), &announcement, ) .await { tracing::warn!(error = ?error, user_id = %user.id, "mail allowance request filed but the operator was not emailed"); } Ok(Html( FormStatusTemplate { success: true, message: "Request sent. We will come back to you.".to_string(), } .render_string()?, ) .into_response()) }