1use std::collections::HashSet;
5use std::time::Duration;
6
7use askama::Template;
8use axum::extract::{Path, State};
9use axum::http::{HeaderName, StatusCode, header};
10use axum::response::{Html, IntoResponse, Redirect, Response};
11use axum::routing::{get, post};
12use axum::{Form, Router};
13use codoseo_core::change::ChangeKind;
14use codoseo_core::plan::PlanLimits;
15use codoseo_notify::{AlertMessage, ChannelKind, ChannelTarget, DeliveryError, deliver};
16use codoseo_store::alert_rules::{self, ALL_KINDS};
17use codoseo_store::channels::{self, ChannelSummary, DeleteOutcome};
18use codoseo_store::events::{self, EventKind};
19use serde::Deserialize;
20use uuid::Uuid;
21
22use crate::auth::CurrentUser;
23use crate::auth::email;
24use crate::config::Mode;
25use crate::error::AppError;
26use crate::layout::{Screen, Shell};
27use crate::render::{Hx, ToastKind, html, toast};
28use crate::state::AppState;
29
30const TEST_TIMEOUT: Duration = Duration::from_secs(10);
32const TEST_LIMIT: i64 = 5;
34
35pub fn routes() -> Router<AppState> {
36 Router::new()
37 .route("/settings/alerts", get(page))
38 .route("/settings/alerts/channels", post(add_channel))
39 .route(
40 "/settings/alerts/channels/{id}/delete",
41 post(delete_channel),
42 )
43 .route("/settings/alerts/channels/{id}/mute", post(mute_channel))
44 .route(
45 "/settings/alerts/channels/{id}/enable",
46 post(enable_channel),
47 )
48 .route("/settings/alerts/channels/{id}/test", post(test_channel))
49 .route("/settings/alerts/rules", post(set_rule))
50}
51
52const UPGRADE: &str = "Slack, Discord and webhook alerts are on Pro and Agency. \
53 Upgrade to Pro to use them.";
54
55fn kind_label(kind: ChannelKind) -> &'static str {
56 match kind {
57 ChannelKind::Email => "Email",
58 ChannelKind::Slack => "Slack",
59 ChannelKind::Discord => "Discord",
60 ChannelKind::Webhook => "Webhook",
61 }
62}
63
64pub async fn default_rules_for_site(state: &AppState, account_id: Uuid, site_id: Uuid) {
68 let result = async {
69 channels::ensure_default_email(&state.pool, &state.channel_key, account_id)
70 .await
71 .map_err(|e| e.to_string())?;
72 alert_rules::create_defaults_for_site(&state.pool, account_id, site_id)
73 .await
74 .map_err(|e| e.to_string())
75 }
76 .await;
77 if let Err(error) = result {
78 tracing::warn!(%site_id, %error, "could not create the default alert rules");
79 }
80}
81
82pub struct ChannelView {
85 pub id: Uuid,
86 pub kind: &'static str,
87 pub name: String,
88 pub target: String,
89 pub status: &'static str,
91 pub status_label: &'static str,
92 pub error: Option<String>,
93 pub muted: bool,
94 pub enabled: bool,
95 pub is_default: bool,
96}
97
98pub struct RuleCell {
99 pub channel: Uuid,
100 pub label: String,
101 pub checked: bool,
102}
103
104pub struct RuleRow {
105 pub slug: &'static str,
106 pub label: &'static str,
107 pub note: &'static str,
108 pub cells: Vec<RuleCell>,
109}
110
111pub struct SiteRules {
112 pub id: Uuid,
113 pub domain: String,
114 pub rows: Vec<RuleRow>,
115}
116
117pub struct ColumnView {
118 pub label: String,
119 pub off: bool,
120}
121
122pub struct NewSecret {
123 pub channel: String,
124 pub secret: String,
125}
126
127#[derive(Template)]
128#[template(path = "settings/alerts_form.html")]
129pub struct AddChannelForm {
130 pub kinds: Vec<(&'static str, &'static str)>,
131 pub kind: String,
132 pub target: String,
133 pub name: String,
134 pub error: Option<String>,
135 pub upgrade: bool,
137 pub upgrade_link: bool,
139}
140
141#[derive(Template)]
142#[template(path = "settings/alerts.html")]
143pub struct AlertsPage {
144 pub shell: Shell,
145 pub channels: Vec<ChannelView>,
146 pub form: AddChannelForm,
147 pub columns: Vec<ColumnView>,
148 pub sites: Vec<SiteRules>,
149 pub secret: Option<NewSecret>,
150}
151
152#[derive(Template)]
153#[template(path = "settings/alerts_test.html")]
154struct TestResult {
155 error: Option<String>,
156}
157
158fn channel_view(c: &ChannelSummary) -> ChannelView {
159 let (status, status_label) = if !c.enabled {
160 ("off", "Turned off")
161 } else if c.muted {
162 ("muted", "Muted")
163 } else {
164 ("active", "Active")
165 };
166 ChannelView {
167 id: c.id,
168 kind: kind_label(c.kind),
169 name: c
170 .name
171 .clone()
172 .unwrap_or_else(|| kind_label(c.kind).to_owned()),
173 target: c.target.clone(),
174 status,
175 status_label,
176 error: if c.enabled {
177 None
178 } else {
179 c.last_error.clone()
180 },
181 muted: c.muted,
182 enabled: c.enabled,
183 is_default: c.is_default,
184 }
185}
186
187fn add_form(state: &AppState, email_only: bool) -> AddChannelForm {
188 let mut kinds = vec![("email", "Email")];
189 if !email_only {
190 kinds.extend([
191 ("slack", "Slack"),
192 ("discord", "Discord"),
193 ("webhook", "Webhook"),
194 ]);
195 }
196 AddChannelForm {
197 kinds,
198 kind: "email".to_owned(),
199 target: String::new(),
200 name: String::new(),
201 error: None,
202 upgrade: email_only,
203 upgrade_link: email_only && state.config.mode == Mode::Cloud,
204 }
205}
206
207fn note(kind: ChangeKind) -> &'static str {
208 match kind {
209 ChangeKind::BecameNoindex => "key pages only",
210 ChangeKind::ErrorSpike => "a burst of new 4xx and 5xx pages",
211 ChangeKind::SitemapShrank => "lost 10% or more",
212 _ => "",
213 }
214}
215
216async fn render_page(
217 state: &AppState,
218 user: &CurrentUser,
219 form: AddChannelForm,
220 secret: Option<NewSecret>,
221) -> Result<Html<String>, AppError> {
222 let pool = &state.pool;
223 let sites = codoseo_store::sites::list_for_account(pool, user.id()).await?;
224 let default = channels::ensure_default_email(pool, &state.channel_key, user.id())
227 .await
228 .map_err(AppError::internal)?;
229 for site in &sites {
230 alert_rules::create_defaults(pool, site.id, default).await?;
231 }
232 let listed = channels::list_for_account(pool, &state.channel_key, user.id())
233 .await
234 .map_err(AppError::internal)?;
235 let on: HashSet<(Uuid, ChangeKind, Uuid)> = alert_rules::grid(pool, user.id())
236 .await?
237 .into_iter()
238 .filter(|c| c.instant)
239 .map(|c| (c.site_id, c.kind, c.channel_id))
240 .collect();
241
242 let column_label = |c: &ChannelSummary| match &c.name {
243 Some(name) => format!("{name} · {}", c.target),
244 None => format!("{} · {}", kind_label(c.kind), c.target),
245 };
246 let sites = sites
247 .iter()
248 .map(|site| SiteRules {
249 id: site.id,
250 domain: site.domain.clone(),
251 rows: ALL_KINDS
252 .iter()
253 .map(|&kind| RuleRow {
254 slug: kind.slug(),
255 label: kind.label(),
256 note: note(kind),
257 cells: listed
258 .iter()
259 .map(|c| RuleCell {
260 channel: c.id,
261 label: format!("{} to {}", kind.label(), column_label(c)),
262 checked: on.contains(&(site.id, kind, c.id)),
263 })
264 .collect(),
265 })
266 .collect(),
267 })
268 .collect();
269 let shell = Shell::load(state, user, None, Screen::Alerts).await?;
270 html(&AlertsPage {
271 shell,
272 channels: listed.iter().map(channel_view).collect(),
273 form,
274 columns: listed
275 .iter()
276 .map(|c| ColumnView {
277 label: column_label(c),
278 off: !c.enabled,
279 })
280 .collect(),
281 sites,
282 secret,
283 })
284}
285
286fn email_only(user: &CurrentUser) -> bool {
287 PlanLimits::for_plan(user.account.plan).email_alerts_only
288}
289
290async fn page(State(state): State<AppState>, user: CurrentUser) -> Result<Response, AppError> {
291 let form = add_form(&state, email_only(&user));
292 Ok(render_page(&state, &user, form, None)
293 .await?
294 .into_response())
295}
296
297#[derive(Deserialize)]
300struct NewChannel {
301 kind: String,
302 #[serde(default)]
303 target: String,
304 #[serde(default)]
305 name: String,
306}
307
308fn new_secret() -> String {
310 let mut bytes = [0u8; 32];
311 rand::fill(&mut bytes);
312 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
313 format!("whsec_{hex}")
314}
315
316enum Refusal {
318 Invalid(String),
319 Limit(String),
320}
321
322async fn add_channel(
323 State(state): State<AppState>,
324 user: CurrentUser,
325 hx: Hx,
326 Form(form): Form<NewChannel>,
327) -> Result<Response, AppError> {
328 let email_only = email_only(&user);
329 match create(&state, &user, &form, email_only).await? {
330 Ok(created) => {
331 if let Some(secret) = created.secret {
332 let page = render_page(
334 &state,
335 &user,
336 add_form(&state, email_only),
337 Some(NewSecret {
338 channel: created.name,
339 secret,
340 }),
341 )
342 .await?;
343 return Ok((
344 [
345 (
346 HeaderName::from_static("hx-replace-url"),
347 "/settings/alerts",
348 ),
349 (header::CACHE_CONTROL, "no-store"),
351 ],
352 page,
353 )
354 .into_response());
355 }
356 Ok(Redirect::to("/settings/alerts").into_response())
357 }
358 Err(refusal) => {
359 let (status, message) = match &refusal {
360 Refusal::Invalid(m) => (StatusCode::BAD_REQUEST, m.clone()),
361 Refusal::Limit(m) => (StatusCode::FORBIDDEN, m.clone()),
362 };
363 if !hx.request {
364 return Err(match refusal {
365 Refusal::Invalid(m) => AppError::BadRequest(m),
366 Refusal::Limit(m) => AppError::Limit(m),
367 });
368 }
369 let mut again = add_form(&state, email_only);
370 again.kind = form.kind.clone();
371 again.target = form.target.trim().to_owned();
372 again.name = form.name.trim().to_owned();
373 again.error = Some(message);
374 Ok((
375 status,
376 [
377 (HeaderName::from_static("hx-retarget"), "#add-channel"),
378 (HeaderName::from_static("hx-reswap"), "outerHTML"),
379 ],
380 html(&again)?,
381 )
382 .into_response())
383 }
384 }
385}
386
387struct Created {
388 name: String,
389 secret: Option<String>,
391}
392
393async fn create(
394 state: &AppState,
395 user: &CurrentUser,
396 form: &NewChannel,
397 email_only: bool,
398) -> Result<Result<Created, Refusal>, AppError> {
399 let Some(kind) = ChannelKind::parse(form.kind.trim()) else {
400 return Ok(Err(Refusal::Invalid(
401 "Pick where alerts should go.".to_owned(),
402 )));
403 };
404 if email_only && kind != ChannelKind::Email {
405 return Ok(Err(Refusal::Limit(UPGRADE.to_owned())));
406 }
407 let held = channels::count_for_account(&state.pool, user.id())
408 .await
409 .map_err(AppError::internal)?;
410 if held >= channels::MAX_CHANNELS_PER_ACCOUNT {
411 return Ok(Err(Refusal::Limit(format!(
412 "You can have up to {} alert channels. Delete one you don't use to add another.",
413 channels::MAX_CHANNELS_PER_ACCOUNT
414 ))));
415 }
416 let raw = form.target.trim();
417 let mut secret = None;
418 let target = match kind {
419 ChannelKind::Email => match email::parse(raw) {
420 Some(address) => ChannelTarget::Email {
421 to: address.to_owned(),
422 },
423 None => {
424 return Ok(Err(Refusal::Invalid(
425 "Enter a valid email address.".to_owned(),
426 )));
427 }
428 },
429 _ => {
430 let url = match state.notify_http.validate_target(kind, raw).await {
431 Ok(url) => url,
432 Err(e) => return Ok(Err(Refusal::Invalid(sentence(&e.to_string())))),
433 };
434 match kind {
435 ChannelKind::Slack => ChannelTarget::Slack { url },
436 ChannelKind::Discord => ChannelTarget::Discord { url },
437 _ => {
438 let s = new_secret();
439 secret = Some(s.clone());
440 ChannelTarget::Webhook { url, secret: s }
441 }
442 }
443 }
444 };
445 let name = form.name.trim();
446 let name = (!name.is_empty()).then(|| name.chars().take(60).collect::<String>());
447 let id = channels::create(
448 &state.pool,
449 &state.channel_key,
450 user.id(),
451 &target,
452 name.as_deref(),
453 false,
454 )
455 .await
456 .map_err(AppError::internal)?;
457 alert_rules::create_defaults_for_account(&state.pool, user.id(), id).await?;
458 Ok(Ok(Created {
459 name: name.unwrap_or_else(|| kind_label(kind).to_owned()),
460 secret,
461 }))
462}
463
464fn sentence(s: &str) -> String {
466 let mut chars = s.chars();
467 let mut out: String = match chars.next() {
468 Some(first) => first.to_uppercase().chain(chars).collect(),
469 None => return String::new(),
470 };
471 if !out.ends_with('.') {
472 out.push('.');
473 }
474 out
475}
476
477async fn owned(
479 state: &AppState,
480 user: &CurrentUser,
481 id: Uuid,
482) -> Result<channels::ChannelState, AppError> {
483 match channels::state(&state.pool, id)
484 .await
485 .map_err(AppError::internal)?
486 {
487 Some(c) if c.account_id == user.id() => Ok(c),
488 _ => Err(AppError::NotFound),
489 }
490}
491
492async fn delete_channel(
493 State(state): State<AppState>,
494 user: CurrentUser,
495 Path(id): Path<Uuid>,
496) -> Result<Response, AppError> {
497 match channels::delete(&state.pool, user.id(), id).await? {
498 DeleteOutcome::Deleted => Ok(Redirect::to("/settings/alerts").into_response()),
499 DeleteOutcome::NotFound => Err(AppError::NotFound),
500 DeleteOutcome::DefaultChannel => Err(AppError::Conflict(
501 "Your own email address is always a channel. Mute it instead of deleting it."
502 .to_owned(),
503 )),
504 }
505}
506
507#[derive(Deserialize)]
508struct MuteForm {
509 muted: String,
510}
511
512async fn mute_channel(
513 State(state): State<AppState>,
514 user: CurrentUser,
515 Path(id): Path<Uuid>,
516 Form(form): Form<MuteForm>,
517) -> Result<Response, AppError> {
518 if !channels::set_muted(&state.pool, user.id(), id, form.muted == "true").await? {
519 return Err(AppError::NotFound);
520 }
521 Ok(Redirect::to("/settings/alerts").into_response())
522}
523
524async fn enable_channel(
525 State(state): State<AppState>,
526 user: CurrentUser,
527 Path(id): Path<Uuid>,
528) -> Result<Response, AppError> {
529 if !channels::reenable(&state.pool, user.id(), id).await? {
530 return Err(AppError::NotFound);
531 }
532 Ok(Redirect::to("/settings/alerts").into_response())
533}
534
535async fn test_channel(
538 State(state): State<AppState>,
539 user: CurrentUser,
540 Path(id): Path<Uuid>,
541) -> Result<Response, AppError> {
542 owned(&state, &user, id).await?;
543 if let Err(minutes) = events::take_allowance(
544 &state.pool,
545 EventKind::ChannelTest,
546 user.id(),
547 TEST_LIMIT,
548 60,
549 )
550 .await?
551 {
552 let message = format!(
553 "Too many test messages — try again in {minutes} minute{}.",
554 if minutes == 1 { "" } else { "s" }
555 );
556 let page = html(&TestResult {
557 error: Some(message),
558 })?;
559 return Ok((StatusCode::TOO_MANY_REQUESTS, page).into_response());
560 }
561 let result =
562 |error: Option<String>| html(&TestResult { error }).map(IntoResponse::into_response);
563 let target = match channels::get_target(&state.pool, &state.channel_key, id).await {
564 Ok(Some(target)) => target,
565 Ok(None) => return Err(AppError::NotFound),
566 Err(e) => return result(Some(format!("The saved address can't be read: {e}"))),
567 };
568 let sites = codoseo_store::sites::list_for_account(&state.pool, user.id()).await?;
569 let (domain, dashboard) = match sites.first() {
570 Some(site) => (
571 site.domain.clone(),
572 state
573 .config
574 .base_url
575 .join(&format!("s/{}/changes", site.id)),
576 ),
577 None => ("your site".to_owned(), Ok(state.config.base_url.clone())),
578 };
579 let dashboard = dashboard.map_err(AppError::internal)?;
580 let message = AlertMessage::test(domain, dashboard);
581 let outcome = tokio::time::timeout(
582 TEST_TIMEOUT,
583 deliver(&state.notify_http, &state.mailer, &target, &message),
584 )
585 .await;
586 match outcome {
587 Ok(Ok(())) => result(None),
588 Ok(Err(DeliveryError::Mail(e))) => {
591 tracing::warn!(channel_id = %id, error = %e, "test email failed");
592 result(Some("Couldn't send the test message.".to_owned()))
593 }
594 Ok(Err(e)) => result(Some(sentence(&e.to_string()))),
595 Err(_) => result(Some("It didn't answer within 10 seconds.".to_owned())),
596 }
597}
598
599#[derive(Deserialize)]
602struct RuleForm {
603 site: Uuid,
604 kind: String,
605 channel: Uuid,
606 #[serde(default)]
608 instant: Option<String>,
609}
610
611async fn set_rule(
612 State(state): State<AppState>,
613 user: CurrentUser,
614 hx: Hx,
615 Form(form): Form<RuleForm>,
616) -> Result<Response, AppError> {
617 let kind: ChangeKind = ALL_KINDS
618 .iter()
619 .copied()
620 .find(|k| k.slug() == form.kind)
621 .ok_or_else(|| AppError::BadRequest("That kind of change doesn't exist.".to_owned()))?;
622 if codoseo_store::sites::get_for_account(&state.pool, user.id(), form.site)
624 .await?
625 .is_none()
626 {
627 return Err(AppError::NotFound);
628 }
629 owned(&state, &user, form.channel).await?;
630 let instant = form
631 .instant
632 .as_deref()
633 .is_some_and(|v| v != "false" && v != "0");
634 if !alert_rules::set(&state.pool, form.site, kind, form.channel, instant).await? {
635 return Err(AppError::NotFound);
636 }
637 if hx.request {
638 let message = if instant {
639 "Instant alerts on"
640 } else {
641 "Moved to the weekly digest"
642 };
643 return Ok((StatusCode::NO_CONTENT, [toast(ToastKind::Ok, message)]).into_response());
644 }
645 Ok(Redirect::to("/settings/alerts").into_response())
646}