]> git.proxmox.com Git - proxmox.git/blob - proxmox-notify/src/endpoints/smtp.rs
notify: smtp: add `Auto-Submitted` header to email body
[proxmox.git] / proxmox-notify / src / endpoints / smtp.rs
1 use std::time::Duration;
2
3 use lettre::message::header::{HeaderName, HeaderValue};
4 use lettre::message::{Mailbox, MultiPart, SinglePart};
5 use lettre::transport::smtp::client::{Tls, TlsParameters};
6 use lettre::{message::header::ContentType, Message, SmtpTransport, Transport};
7 use serde::{Deserialize, Serialize};
8
9 use proxmox_schema::api_types::COMMENT_SCHEMA;
10 use proxmox_schema::{api, Updater};
11
12 use crate::context::context;
13 use crate::endpoints::common::mail;
14 use crate::renderer::TemplateRenderer;
15 use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA};
16 use crate::{renderer, Content, Endpoint, Error, Notification, Origin};
17
18 pub(crate) const SMTP_TYPENAME: &str = "smtp";
19
20 const SMTP_PORT: u16 = 25;
21 const SMTP_SUBMISSION_STARTTLS_PORT: u16 = 587;
22 const SMTP_SUBMISSION_TLS_PORT: u16 = 465;
23 const SMTP_TIMEOUT: u16 = 5;
24
25 #[api]
26 #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)]
27 #[serde(rename_all = "kebab-case")]
28 /// Connection security
29 pub enum SmtpMode {
30 /// No encryption (insecure), plain SMTP
31 Insecure,
32 /// Upgrade to TLS after connecting
33 #[serde(rename = "starttls")]
34 StartTls,
35 /// Use TLS-secured connection
36 #[default]
37 Tls,
38 }
39
40 #[api(
41 properties: {
42 name: {
43 schema: ENTITY_NAME_SCHEMA,
44 },
45 mailto: {
46 type: Array,
47 items: {
48 schema: EMAIL_SCHEMA,
49 },
50 optional: true,
51 },
52 "mailto-user": {
53 type: Array,
54 items: {
55 schema: USER_SCHEMA,
56 },
57 optional: true,
58 },
59 comment: {
60 optional: true,
61 schema: COMMENT_SCHEMA,
62 },
63 },
64 )]
65 #[derive(Debug, Serialize, Deserialize, Updater, Default)]
66 #[serde(rename_all = "kebab-case")]
67 /// Config for Sendmail notification endpoints
68 pub struct SmtpConfig {
69 /// Name of the endpoint
70 #[updater(skip)]
71 pub name: String,
72 /// Host name or IP of the SMTP relay
73 pub server: String,
74 /// Port to use when connecting to the SMTP relay
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub port: Option<u16>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub mode: Option<SmtpMode>,
79 /// Username for authentication
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub username: Option<String>,
82 /// Mail recipients
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub mailto: Option<Vec<String>>,
85 /// Mail recipients
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub mailto_user: Option<Vec<String>>,
88 /// `From` address for the mail
89 pub from_address: String,
90 /// Author of the mail
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub author: Option<String>,
93 /// Comment
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub comment: Option<String>,
96 /// Disable this target.
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub disable: Option<bool>,
99 /// Origin of this config entry.
100 #[serde(skip_serializing_if = "Option::is_none")]
101 #[updater(skip)]
102 pub origin: Option<Origin>,
103 }
104
105 #[derive(Serialize, Deserialize)]
106 #[serde(rename_all = "kebab-case")]
107 pub enum DeleteableSmtpProperty {
108 Author,
109 Comment,
110 Disable,
111 Mailto,
112 MailtoUser,
113 Password,
114 Port,
115 Username,
116 }
117
118 #[api]
119 #[derive(Serialize, Deserialize, Clone, Updater, Debug)]
120 #[serde(rename_all = "kebab-case")]
121 /// Private configuration for SMTP notification endpoints.
122 /// This config will be saved to a separate configuration file with stricter
123 /// permissions (root:root 0600)
124 pub struct SmtpPrivateConfig {
125 /// Name of the endpoint
126 #[updater(skip)]
127 pub name: String,
128 /// Authentication token
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub password: Option<String>,
131 }
132
133 /// A sendmail notification endpoint.
134 pub struct SmtpEndpoint {
135 pub config: SmtpConfig,
136 pub private_config: SmtpPrivateConfig,
137 }
138
139 impl Endpoint for SmtpEndpoint {
140 fn send(&self, notification: &Notification) -> Result<(), Error> {
141 let tls_parameters = TlsParameters::new(self.config.server.clone())
142 .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))?;
143
144 let (port, tls) = match self.config.mode.unwrap_or_default() {
145 SmtpMode::Insecure => {
146 let port = self.config.port.unwrap_or(SMTP_PORT);
147 (port, Tls::None)
148 }
149 SmtpMode::StartTls => {
150 let port = self.config.port.unwrap_or(SMTP_SUBMISSION_STARTTLS_PORT);
151 (port, Tls::Required(tls_parameters))
152 }
153 SmtpMode::Tls => {
154 let port = self.config.port.unwrap_or(SMTP_SUBMISSION_TLS_PORT);
155 (port, Tls::Wrapper(tls_parameters))
156 }
157 };
158
159 let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.server)
160 .tls(tls)
161 .port(port)
162 .timeout(Some(Duration::from_secs(SMTP_TIMEOUT.into())));
163
164 if let Some(username) = self.config.username.as_deref() {
165 if let Some(password) = self.private_config.password.as_deref() {
166 transport_builder = transport_builder.credentials((username, password).into());
167 } else {
168 return Err(Error::NotifyFailed(
169 self.name().into(),
170 Box::new(Error::Generic(
171 "username is set but no password was provided".to_owned(),
172 )),
173 ));
174 }
175 }
176
177 let transport = transport_builder.build();
178
179 let recipients = mail::get_recipients(
180 self.config.mailto.as_deref(),
181 self.config.mailto_user.as_deref(),
182 );
183 let mail_from = self.config.from_address.clone();
184
185 let parse_address = |addr: &str| -> Result<Mailbox, Error> {
186 addr.parse()
187 .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))
188 };
189
190 let author = self
191 .config
192 .author
193 .clone()
194 .unwrap_or_else(|| context().default_sendmail_author());
195
196 let mut email_builder =
197 Message::builder().from(parse_address(&format!("{author} <{mail_from}>"))?);
198
199 for recipient in recipients {
200 email_builder = email_builder.to(parse_address(&recipient)?);
201 }
202
203 let mut email = match &notification.content {
204 Content::Template {
205 title_template,
206 body_template,
207 data,
208 } => {
209 let subject =
210 renderer::render_template(TemplateRenderer::Plaintext, title_template, data)?;
211 let html_part =
212 renderer::render_template(TemplateRenderer::Html, body_template, data)?;
213 let text_part =
214 renderer::render_template(TemplateRenderer::Plaintext, body_template, data)?;
215
216 email_builder = email_builder.subject(subject);
217
218 email_builder
219 .multipart(
220 MultiPart::alternative()
221 .singlepart(
222 SinglePart::builder()
223 .header(ContentType::TEXT_PLAIN)
224 .body(text_part),
225 )
226 .singlepart(
227 SinglePart::builder()
228 .header(ContentType::TEXT_HTML)
229 .body(html_part),
230 ),
231 )
232 .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))?
233 }
234 #[cfg(feature = "mail-forwarder")]
235 Content::ForwardedMail { ref raw, title, .. } => {
236 use lettre::message::header::ContentTransferEncoding;
237 use lettre::message::Body;
238
239 let parsed_message = mail_parser::Message::parse(raw)
240 .ok_or_else(|| Error::Generic("could not parse forwarded email".to_string()))?;
241
242 let root_part = parsed_message
243 .part(0)
244 .ok_or_else(|| Error::Generic("root message part not present".to_string()))?;
245
246 let raw_body = parsed_message
247 .raw_message()
248 .get(root_part.offset_body..root_part.offset_end)
249 .ok_or_else(|| Error::Generic("could not get raw body content".to_string()))?;
250
251 // We assume that the original message content is already properly
252 // encoded, thus we add the original message body in 'Binary' encoding.
253 // This prohibits lettre from trying to re-encode our raw body data.
254 // lettre will automatically set the `Content-Transfer-Encoding: binary` header,
255 // which we need to remove. The actual transfer encoding is later
256 // copied from the original message headers.
257 let body =
258 Body::new_with_encoding(raw_body.to_vec(), ContentTransferEncoding::Binary)
259 .map_err(|_| Error::Generic("could not create body".into()))?;
260 let mut message = email_builder
261 .subject(title)
262 .body(body)
263 .map_err(|err| Error::NotifyFailed(self.name().into(), Box::new(err)))?;
264 message
265 .headers_mut()
266 .remove_raw("Content-Transfer-Encoding");
267
268 // Copy over all headers that are relevant to display the original body correctly.
269 // Unfortunately this is a bit cumbersome, as we use separate crates for mail parsing (mail-parser)
270 // and creating/sending mails (lettre).
271 // Note: Other MIME-Headers, such as Content-{ID,Description,Disposition} are only used
272 // for body-parts in multipart messages, so we can ignore them for the messages headers.
273 // Since we send the original raw body, the part-headers will be included any way.
274 for header in parsed_message.headers() {
275 let header_name = header.name.as_str();
276 // Email headers are case-insensitive, so convert to lowercase...
277 let value = match header_name.to_lowercase().as_str() {
278 "content-type" => {
279 if let mail_parser::HeaderValue::ContentType(ct) = header.value() {
280 // mail_parser does not give us access to the full decoded and unfolded
281 // header value, so we unfortunately need to reassemble it ourselves.
282 // Meh.
283 let mut value = ct.ctype().to_string();
284 if let Some(subtype) = ct.subtype() {
285 value.push('/');
286 value.push_str(subtype);
287 }
288 if let Some(attributes) = ct.attributes() {
289 use std::fmt::Write;
290
291 for attribute in attributes {
292 let _ = write!(
293 &mut value,
294 "; {}=\"{}\"",
295 attribute.0, attribute.1
296 );
297 }
298 }
299 Some(value)
300 } else {
301 None
302 }
303 }
304 "content-transfer-encoding" | "mime-version" => {
305 if let mail_parser::HeaderValue::Text(text) = header.value() {
306 Some(text.to_string())
307 } else {
308 None
309 }
310 }
311 _ => None,
312 };
313
314 if let Some(value) = value {
315 match HeaderName::new_from_ascii(header_name.into()) {
316 Ok(name) => {
317 let header = HeaderValue::new(name, value);
318 message.headers_mut().insert_raw(header);
319 }
320 Err(e) => log::error!("could not set header: {e}"),
321 }
322 }
323 }
324
325 message
326 }
327 };
328
329 // `Auto-Submitted` is defined in RFC 5436 and describes how
330 // an automatic response (f.e. ooo replies, etc.) should behave on the
331 // emails. When using `Auto-Submitted: auto-generated` (or any value
332 // other than `none`) automatic replies won't be triggered.
333 email.headers_mut().insert_raw(HeaderValue::new(
334 HeaderName::new_from_ascii_str("Auto-Submitted"),
335 "auto-generated;".into(),
336 ));
337
338 transport
339 .send(&email)
340 .map_err(|err| Error::NotifyFailed(self.name().into(), err.into()))?;
341
342 Ok(())
343 }
344
345 fn name(&self) -> &str {
346 &self.config.name
347 }
348
349 /// Check if the endpoint is disabled
350 fn disabled(&self) -> bool {
351 self.config.disable.unwrap_or_default()
352 }
353 }