Skip to main content

max / makenotwork

5.1 KB · 148 lines History Blame Raw
1 //! The creator-facing half of the monthly mail allowance: asking for more.
2 //!
3 //! The cap is a soft maximum, and a soft maximum without a way to ask is a hard
4 //! one with better manners. This is that way (`db::mail_caps`): a creator names
5 //! a number and says why, an operator decides, and a grant writes the
6 //! per-account override.
7 //!
8 //! The explanation is not a formality. It is what separates a growing list from
9 //! a stale one being blasted, which is the distinction the number alone cannot
10 //! carry.
11
12 use axum::{
13 Form,
14 extract::State,
15 response::{Html, IntoResponse, Response},
16 };
17 use serde::Deserialize;
18 use sqlx::PgPool;
19
20 use crate::{
21 auth::AuthUser, config::Config, db, email::EmailClient, error::Result,
22 templates::FormStatusTemplate,
23 };
24
25 /// Where a filed request is announced.
26 ///
27 /// The queue at `/admin/mail-caps` is where a request *lives*; this is what
28 /// tells anybody it arrived. A soft cap's whole defence is that the application
29 /// path works, and an application path nobody is told about is a page somebody
30 /// has to remember to open -- which, for a creator whose sends are being
31 /// refused, is the cap behaving like a hard one after all.
32 ///
33 /// The support address rather than `info@`: role addresses land in one mailbox
34 /// and the sorting is done on the address, so a precise one is the whole
35 /// benefit of having several.
36 const OPERATOR: &str = "support@makenot.work";
37
38 /// Form input for an allowance increase.
39 #[derive(Debug, Deserialize)]
40 pub(crate) struct MailCapRequestForm {
41 pub requested_cap: i32,
42 pub reason: String,
43 }
44
45 /// The smallest reason that can carry an argument. Shorter than this is a number
46 /// with no case attached, and the case is the whole point of asking.
47 const MIN_REASON_CHARS: usize = 20;
48
49 /// An upper bound on the ask, so a typo becomes a validation error rather than
50 /// an operator staring at a request for ten million.
51 const MAX_REQUESTED_CAP: i32 = 1_000_000;
52
53 /// Ask for a bigger monthly mail allowance.
54 #[tracing::instrument(skip_all, name = "users::mail_cap_request")]
55 pub(in crate::routes::api) async fn mail_cap_request(
56 State(db): State<PgPool>,
57 State(email): State<EmailClient>,
58 State(config): State<Config>,
59 AuthUser(user): AuthUser,
60 Form(form): Form<MailCapRequestForm>,
61 ) -> Result<Response> {
62 user.check_not_sandbox()?;
63 user.check_not_suspended()?;
64
65 let refuse = |message: &str| -> Result<Response> {
66 Ok(Html(
67 FormStatusTemplate {
68 success: false,
69 message: message.to_string(),
70 }
71 .render_string()?,
72 )
73 .into_response())
74 };
75
76 if !user.can_create_projects {
77 return refuse("Creator access required");
78 }
79
80 let reason = form.reason.trim();
81 if reason.chars().count() < MIN_REASON_CHARS {
82 return refuse(
83 "Tell us what you are sending and roughly how often, so we can set a number that fits.",
84 );
85 }
86 if reason.chars().count() > 1000 {
87 return refuse("Keep the explanation under 1000 characters.");
88 }
89
90 if form.requested_cap <= 0 || form.requested_cap > MAX_REQUESTED_CAP {
91 return refuse("Ask for a number between 1 and 1,000,000 emails a month.");
92 }
93
94 // Asking for less than the current allowance is almost always a
95 // misunderstanding of what the number means, and granting it would lower
96 // the creator's own cap.
97 let current = db::mail_caps::effective_cap(&db, user.id).await?;
98 if i64::from(form.requested_cap) <= current {
99 return refuse(&format!(
100 "You already have {current} emails a month. Ask for a number above that."
101 ));
102 }
103
104 let filed = db::mail_caps::create_request(&db, user.id, form.requested_cap, reason).await?;
105 if !filed {
106 return refuse(
107 "You already have a request open. We will come back to you on that one rather than \
108 queueing a second.",
109 );
110 }
111
112 tracing::info!(
113 user_id = %user.id, requested_cap = form.requested_cap,
114 "creator asked for a higher monthly mail allowance"
115 );
116
117 // After the row, and never a reason to fail the request. The queue holds
118 // the ask whether or not this arrives, so a mail failure costs a doorbell
119 // and not a creator's application.
120 let announcement = format!(
121 "{} ({}) asks for {} emails a month, up from {current}.\n\nUser ID: {}\n\nWhy:\n{reason}\n\nDecide at {}/admin/mail-caps",
122 user.username, user.email, form.requested_cap, user.id, config.host_url,
123 );
124 if let Err(error) = email
125 .send_alert(
126 OPERATOR,
127 &format!(
128 "[mail-cap] {} asks for {}",
129 user.username, form.requested_cap
130 ),
131 &announcement,
132 )
133 .await
134 {
135 tracing::warn!(error = ?error, user_id = %user.id,
136 "mail allowance request filed but the operator was not emailed");
137 }
138
139 Ok(Html(
140 FormStatusTemplate {
141 success: true,
142 message: "Request sent. We will come back to you.".to_string(),
143 }
144 .render_string()?,
145 )
146 .into_response())
147 }
148