]> git.proxmox.com Git - proxmox-backup.git/blob - src/server/email_notifications.rs
7c047630db2806b8a5a058bb9ad1b6a7ff163ff3
[proxmox-backup.git] / src / server / email_notifications.rs
1 use anyhow::Error;
2 use serde_json::json;
3
4 use handlebars::{Handlebars, Helper, Context, RenderError, RenderContext, Output, HelperResult, TemplateError};
5
6 use proxmox::tools::email::sendmail;
7 use proxmox_lang::try_block;
8 use proxmox_schema::{parse_property_string, ApiType};
9
10 use pbs_api_types::{
11 User, TapeBackupJobSetup, SyncJobConfig, VerificationJobConfig,
12 APTUpdateInfo, GarbageCollectionStatus, HumanByte,
13 Userid, Notify, DatastoreNotify, DataStoreConfig,
14 };
15
16 const GC_OK_TEMPLATE: &str = r###"
17
18 Datastore: {{datastore}}
19 Task ID: {{status.upid}}
20 Index file count: {{status.index-file-count}}
21
22 Removed garbage: {{human-bytes status.removed-bytes}}
23 Removed chunks: {{status.removed-chunks}}
24 Removed bad chunks: {{status.removed-bad}}
25
26 Leftover bad chunks: {{status.still-bad}}
27 Pending removals: {{human-bytes status.pending-bytes}} (in {{status.pending-chunks}} chunks)
28
29 Original Data usage: {{human-bytes status.index-data-bytes}}
30 On-Disk usage: {{human-bytes status.disk-bytes}} ({{relative-percentage status.disk-bytes status.index-data-bytes}})
31 On-Disk chunks: {{status.disk-chunks}}
32
33 Deduplication Factor: {{deduplication-factor}}
34
35 Garbage collection successful.
36
37
38 Please visit the web interface for further details:
39
40 <https://{{fqdn}}:{{port}}/#DataStore-{{datastore}}>
41
42 "###;
43
44
45 const GC_ERR_TEMPLATE: &str = r###"
46
47 Datastore: {{datastore}}
48
49 Garbage collection failed: {{error}}
50
51
52 Please visit the web interface for further details:
53
54 <https://{{fqdn}}:{{port}}/#pbsServerAdministration:tasks>
55
56 "###;
57
58 const VERIFY_OK_TEMPLATE: &str = r###"
59
60 Job ID: {{job.id}}
61 Datastore: {{job.store}}
62
63 Verification successful.
64
65
66 Please visit the web interface for further details:
67
68 <https://{{fqdn}}:{{port}}/#DataStore-{{job.store}}>
69
70 "###;
71
72 const VERIFY_ERR_TEMPLATE: &str = r###"
73
74 Job ID: {{job.id}}
75 Datastore: {{job.store}}
76
77 Verification failed on these snapshots/groups:
78
79 {{#each errors}}
80 {{this~}}
81 {{/each}}
82
83
84 Please visit the web interface for further details:
85
86 <https://{{fqdn}}:{{port}}/#pbsServerAdministration:tasks>
87
88 "###;
89
90 const SYNC_OK_TEMPLATE: &str = r###"
91
92 Job ID: {{job.id}}
93 Datastore: {{job.store}}
94 Remote: {{job.remote}}
95 Remote Store: {{job.remote-store}}
96
97 Synchronization successful.
98
99
100 Please visit the web interface for further details:
101
102 <https://{{fqdn}}:{{port}}/#DataStore-{{job.store}}>
103
104 "###;
105
106 const SYNC_ERR_TEMPLATE: &str = r###"
107
108 Job ID: {{job.id}}
109 Datastore: {{job.store}}
110 Remote: {{job.remote}}
111 Remote Store: {{job.remote-store}}
112
113 Synchronization failed: {{error}}
114
115
116 Please visit the web interface for further details:
117
118 <https://{{fqdn}}:{{port}}/#pbsServerAdministration:tasks>
119
120 "###;
121
122 const PACKAGE_UPDATES_TEMPLATE: &str = r###"
123 Proxmox Backup Server has the following updates available:
124 {{#each updates }}
125 {{Package}}: {{OldVersion}} -> {{Version~}}
126 {{/each }}
127
128 To upgrade visit the web interface:
129
130 <https://{{fqdn}}:{{port}}/#pbsServerAdministration:updates>
131
132 "###;
133
134 const TAPE_BACKUP_OK_TEMPLATE: &str = r###"
135
136 {{#if id ~}}
137 Job ID: {{id}}
138 {{/if~}}
139 Datastore: {{job.store}}
140 Tape Pool: {{job.pool}}
141 Tape Drive: {{job.drive}}
142
143 {{#if snapshot-list ~}}
144 Snapshots included:
145
146 {{#each snapshot-list~}}
147 {{this}}
148 {{/each~}}
149 {{/if}}
150 Duration: {{duration}}
151
152 Tape Backup successful.
153
154
155 Please visit the web interface for further details:
156
157 <https://{{fqdn}}:{{port}}/#DataStore-{{job.store}}>
158
159 "###;
160
161 const TAPE_BACKUP_ERR_TEMPLATE: &str = r###"
162
163 {{#if id ~}}
164 Job ID: {{id}}
165 {{/if~}}
166 Datastore: {{job.store}}
167 Tape Pool: {{job.pool}}
168 Tape Drive: {{job.drive}}
169
170 {{#if snapshot-list ~}}
171 Snapshots included:
172
173 {{#each snapshot-list~}}
174 {{this}}
175 {{/each~}}
176 {{/if}}
177 Tape Backup failed: {{error}}
178
179
180 Please visit the web interface for further details:
181
182 <https://{{fqdn}}:{{port}}/#pbsServerAdministration:tasks>
183
184 "###;
185
186 lazy_static::lazy_static!{
187
188 static ref HANDLEBARS: Handlebars<'static> = {
189 let mut hb = Handlebars::new();
190 let result: Result<(), TemplateError> = try_block!({
191
192 hb.set_strict_mode(true);
193 hb.register_escape_fn(handlebars::no_escape);
194
195 hb.register_helper("human-bytes", Box::new(handlebars_humam_bytes_helper));
196 hb.register_helper("relative-percentage", Box::new(handlebars_relative_percentage_helper));
197
198 hb.register_template_string("gc_ok_template", GC_OK_TEMPLATE)?;
199 hb.register_template_string("gc_err_template", GC_ERR_TEMPLATE)?;
200
201 hb.register_template_string("verify_ok_template", VERIFY_OK_TEMPLATE)?;
202 hb.register_template_string("verify_err_template", VERIFY_ERR_TEMPLATE)?;
203
204 hb.register_template_string("sync_ok_template", SYNC_OK_TEMPLATE)?;
205 hb.register_template_string("sync_err_template", SYNC_ERR_TEMPLATE)?;
206
207 hb.register_template_string("tape_backup_ok_template", TAPE_BACKUP_OK_TEMPLATE)?;
208 hb.register_template_string("tape_backup_err_template", TAPE_BACKUP_ERR_TEMPLATE)?;
209
210 hb.register_template_string("package_update_template", PACKAGE_UPDATES_TEMPLATE)?;
211
212 Ok(())
213 });
214
215 if let Err(err) = result {
216 eprintln!("error during template registration: {}", err);
217 }
218
219 hb
220 };
221 }
222
223 /// Summary of a successful Tape Job
224 #[derive(Default)]
225 pub struct TapeBackupJobSummary {
226 /// The list of snaphots backed up
227 pub snapshot_list: Vec<String>,
228 /// The total time of the backup job
229 pub duration: std::time::Duration,
230 }
231
232 fn send_job_status_mail(
233 email: &str,
234 subject: &str,
235 text: &str,
236 ) -> Result<(), Error> {
237
238 // Note: OX has serious problems displaying text mails,
239 // so we include html as well
240 let html = format!("<html><body><pre>\n{}\n<pre>", handlebars::html_escape(text));
241
242 let nodename = proxmox::tools::nodename();
243
244 let author = format!("Proxmox Backup Server - {}", nodename);
245
246 sendmail(
247 &[email],
248 &subject,
249 Some(&text),
250 Some(&html),
251 None,
252 Some(&author),
253 )?;
254
255 Ok(())
256 }
257
258 pub fn send_gc_status(
259 email: &str,
260 notify: DatastoreNotify,
261 datastore: &str,
262 status: &GarbageCollectionStatus,
263 result: &Result<(), Error>,
264 ) -> Result<(), Error> {
265
266 match notify.gc {
267 None => { /* send notifications by default */ },
268 Some(notify) => {
269 if notify == Notify::Never || (result.is_ok() && notify == Notify::Error) {
270 return Ok(());
271 }
272 }
273 }
274
275 let (fqdn, port) = get_server_url();
276 let mut data = json!({
277 "datastore": datastore,
278 "fqdn": fqdn,
279 "port": port,
280 });
281
282 let text = match result {
283 Ok(()) => {
284 let deduplication_factor = if status.disk_bytes > 0 {
285 (status.index_data_bytes as f64)/(status.disk_bytes as f64)
286 } else {
287 1.0
288 };
289
290 data["status"] = json!(status);
291 data["deduplication-factor"] = format!("{:.2}", deduplication_factor).into();
292
293 HANDLEBARS.render("gc_ok_template", &data)?
294 }
295 Err(err) => {
296 data["error"] = err.to_string().into();
297 HANDLEBARS.render("gc_err_template", &data)?
298 }
299 };
300
301 let subject = match result {
302 Ok(()) => format!(
303 "Garbage Collect Datastore '{}' successful",
304 datastore,
305 ),
306 Err(_) => format!(
307 "Garbage Collect Datastore '{}' failed",
308 datastore,
309 ),
310 };
311
312 send_job_status_mail(email, &subject, &text)?;
313
314 Ok(())
315 }
316
317 pub fn send_verify_status(
318 email: &str,
319 notify: DatastoreNotify,
320 job: VerificationJobConfig,
321 result: &Result<Vec<String>, Error>,
322 ) -> Result<(), Error> {
323
324 let (fqdn, port) = get_server_url();
325 let mut data = json!({
326 "job": job,
327 "fqdn": fqdn,
328 "port": port,
329 });
330
331 let mut result_is_ok = false;
332
333 let text = match result {
334 Ok(errors) if errors.is_empty() => {
335 result_is_ok = true;
336 HANDLEBARS.render("verify_ok_template", &data)?
337 }
338 Ok(errors) => {
339 data["errors"] = json!(errors);
340 HANDLEBARS.render("verify_err_template", &data)?
341 }
342 Err(_) => {
343 // aborted job - do not send any email
344 return Ok(());
345 }
346 };
347
348 match notify.verify {
349 None => { /* send notifications by default */ },
350 Some(notify) => {
351 if notify == Notify::Never || (result_is_ok && notify == Notify::Error) {
352 return Ok(());
353 }
354 }
355 }
356
357 let subject = match result {
358 Ok(errors) if errors.is_empty() => format!(
359 "Verify Datastore '{}' successful",
360 job.store,
361 ),
362 _ => format!(
363 "Verify Datastore '{}' failed",
364 job.store,
365 ),
366 };
367
368 send_job_status_mail(email, &subject, &text)?;
369
370 Ok(())
371 }
372
373 pub fn send_sync_status(
374 email: &str,
375 notify: DatastoreNotify,
376 job: &SyncJobConfig,
377 result: &Result<(), Error>,
378 ) -> Result<(), Error> {
379
380 match notify.sync {
381 None => { /* send notifications by default */ },
382 Some(notify) => {
383 if notify == Notify::Never || (result.is_ok() && notify == Notify::Error) {
384 return Ok(());
385 }
386 }
387 }
388
389 let (fqdn, port) = get_server_url();
390 let mut data = json!({
391 "job": job,
392 "fqdn": fqdn,
393 "port": port,
394 });
395
396 let text = match result {
397 Ok(()) => {
398 HANDLEBARS.render("sync_ok_template", &data)?
399 }
400 Err(err) => {
401 data["error"] = err.to_string().into();
402 HANDLEBARS.render("sync_err_template", &data)?
403 }
404 };
405
406 let subject = match result {
407 Ok(()) => format!(
408 "Sync remote '{}' datastore '{}' successful",
409 job.remote,
410 job.remote_store,
411 ),
412 Err(_) => format!(
413 "Sync remote '{}' datastore '{}' failed",
414 job.remote,
415 job.remote_store,
416 ),
417 };
418
419 send_job_status_mail(email, &subject, &text)?;
420
421 Ok(())
422 }
423
424 pub fn send_tape_backup_status(
425 email: &str,
426 id: Option<&str>,
427 job: &TapeBackupJobSetup,
428 result: &Result<(), Error>,
429 summary: TapeBackupJobSummary,
430 ) -> Result<(), Error> {
431
432 let (fqdn, port) = get_server_url();
433 let duration: proxmox_time::TimeSpan = summary.duration.into();
434 let mut data = json!({
435 "job": job,
436 "fqdn": fqdn,
437 "port": port,
438 "id": id,
439 "snapshot-list": summary.snapshot_list,
440 "duration": duration.to_string(),
441 });
442
443 let text = match result {
444 Ok(()) => {
445 HANDLEBARS.render("tape_backup_ok_template", &data)?
446 }
447 Err(err) => {
448 data["error"] = err.to_string().into();
449 HANDLEBARS.render("tape_backup_err_template", &data)?
450 }
451 };
452
453 let subject = match (result, id) {
454 (Ok(()), Some(id)) => format!(
455 "Tape Backup '{}' datastore '{}' successful",
456 id,
457 job.store,
458 ),
459 (Ok(()), None) => format!(
460 "Tape Backup datastore '{}' successful",
461 job.store,
462 ),
463 (Err(_), Some(id)) => format!(
464 "Tape Backup '{}' datastore '{}' failed",
465 id,
466 job.store,
467 ),
468 (Err(_), None) => format!(
469 "Tape Backup datastore '{}' failed",
470 job.store,
471 ),
472 };
473
474 send_job_status_mail(email, &subject, &text)?;
475
476 Ok(())
477 }
478
479 /// Send email to a person to request a manual media change
480 pub fn send_load_media_email(
481 drive: &str,
482 label_text: &str,
483 to: &str,
484 reason: Option<String>,
485 ) -> Result<(), Error> {
486
487 let subject = format!("Load Media '{}' request for drive '{}'", label_text, drive);
488
489 let mut text = String::new();
490
491 if let Some(reason) = reason {
492 text.push_str(&format!("The drive has the wrong or no tape inserted. Error:\n{}\n\n", reason));
493 }
494
495 text.push_str("Please insert the requested media into the backup drive.\n\n");
496
497 text.push_str(&format!("Drive: {}\n", drive));
498 text.push_str(&format!("Media: {}\n", label_text));
499
500 send_job_status_mail(to, &subject, &text)
501 }
502
503 fn get_server_url() -> (String, usize) {
504
505 // user will surely request that they can change this
506
507 let nodename = proxmox::tools::nodename();
508 let mut fqdn = nodename.to_owned();
509
510 if let Ok(resolv_conf) = crate::api2::node::dns::read_etc_resolv_conf() {
511 if let Some(search) = resolv_conf["search"].as_str() {
512 fqdn.push('.');
513 fqdn.push_str(search);
514 }
515 }
516
517 let port = 8007;
518
519 (fqdn, port)
520 }
521
522 pub fn send_updates_available(
523 updates: &[&APTUpdateInfo],
524 ) -> Result<(), Error> {
525 // update mails always go to the root@pam configured email..
526 if let Some(email) = lookup_user_email(Userid::root_userid()) {
527 let nodename = proxmox::tools::nodename();
528 let subject = format!("New software packages available ({})", nodename);
529
530 let (fqdn, port) = get_server_url();
531
532 let text = HANDLEBARS.render("package_update_template", &json!({
533 "fqdn": fqdn,
534 "port": port,
535 "updates": updates,
536 }))?;
537
538 send_job_status_mail(&email, &subject, &text)?;
539 }
540 Ok(())
541 }
542
543 /// Lookup users email address
544 pub fn lookup_user_email(userid: &Userid) -> Option<String> {
545
546 if let Ok(user_config) = pbs_config::user::cached_config() {
547 if let Ok(user) = user_config.lookup::<User>("user", userid.as_str()) {
548 return user.email;
549 }
550 }
551
552 None
553 }
554
555 /// Lookup Datastore notify settings
556 pub fn lookup_datastore_notify_settings(
557 store: &str,
558 ) -> (Option<String>, DatastoreNotify) {
559
560 let mut email = None;
561
562 let notify = DatastoreNotify { gc: None, verify: None, sync: None };
563
564 let (config, _digest) = match pbs_config::datastore::config() {
565 Ok(result) => result,
566 Err(_) => return (email, notify),
567 };
568
569 let config: DataStoreConfig = match config.lookup("datastore", store) {
570 Ok(result) => result,
571 Err(_) => return (email, notify),
572 };
573
574 email = match config.notify_user {
575 Some(ref userid) => lookup_user_email(userid),
576 None => lookup_user_email(Userid::root_userid()),
577 };
578
579 let notify_str = config.notify.unwrap_or_default();
580
581 if let Ok(value) = parse_property_string(&notify_str, &DatastoreNotify::API_SCHEMA) {
582 if let Ok(notify) = serde_json::from_value(value) {
583 return (email, notify);
584 }
585 }
586
587 (email, notify)
588 }
589
590 // Handlerbar helper functions
591
592 fn handlebars_humam_bytes_helper(
593 h: &Helper,
594 _: &Handlebars,
595 _: &Context,
596 _rc: &mut RenderContext,
597 out: &mut dyn Output
598 ) -> HelperResult {
599 let param = h.param(0).map(|v| v.value().as_u64())
600 .flatten()
601 .ok_or_else(|| RenderError::new("human-bytes: param not found"))?;
602
603 out.write(&HumanByte::from(param).to_string())?;
604
605 Ok(())
606 }
607
608 fn handlebars_relative_percentage_helper(
609 h: &Helper,
610 _: &Handlebars,
611 _: &Context,
612 _rc: &mut RenderContext,
613 out: &mut dyn Output
614 ) -> HelperResult {
615 let param0 = h.param(0).map(|v| v.value().as_f64())
616 .flatten()
617 .ok_or_else(|| RenderError::new("relative-percentage: param0 not found"))?;
618 let param1 = h.param(1).map(|v| v.value().as_f64())
619 .flatten()
620 .ok_or_else(|| RenderError::new("relative-percentage: param1 not found"))?;
621
622 if param1 == 0.0 {
623 out.write("-")?;
624 } else {
625 out.write(&format!("{:.2}%", (param0*100.0)/param1))?;
626 }
627 Ok(())
628 }
629
630 #[test]
631 fn test_template_register() {
632 HANDLEBARS.get_helper("human-bytes").unwrap();
633 HANDLEBARS.get_helper("relative-percentage").unwrap();
634
635 assert!(HANDLEBARS.has_template("gc_ok_template"));
636 assert!(HANDLEBARS.has_template("gc_err_template"));
637
638 assert!(HANDLEBARS.has_template("verify_ok_template"));
639 assert!(HANDLEBARS.has_template("verify_err_template"));
640
641 assert!(HANDLEBARS.has_template("sync_ok_template"));
642 assert!(HANDLEBARS.has_template("sync_err_template"));
643
644 assert!(HANDLEBARS.has_template("tape_backup_ok_template"));
645 assert!(HANDLEBARS.has_template("tape_backup_err_template"));
646
647 assert!(HANDLEBARS.has_template("package_update_template"));
648 }