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