]>
Commit | Line | Data |
---|---|---|
b03c3940 LW |
1 | use std::time::Duration; |
2 | ||
efa607f1 | 3 | use lettre::message::header::{HeaderName, HeaderValue}; |
53627a19 LW |
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}; | |
53627a19 LW |
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; | |
1516cc26 | 14 | use crate::renderer::TemplateType; |
53627a19 | 15 | use crate::schema::{EMAIL_SCHEMA, ENTITY_NAME_SCHEMA, USER_SCHEMA}; |
9bea76c6 | 16 | use crate::{renderer, Content, Endpoint, Error, Notification, Origin}; |
53627a19 LW |
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, | |
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 | |
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 | |
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")] | |
110 | pub 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) | |
135 | pub 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. | |
145 | pub struct SmtpEndpoint { | |
146 | pub config: SmtpConfig, | |
147 | pub private_config: SmtpPrivateConfig, | |
148 | } | |
149 | ||
150 | impl 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 ¬ification.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 | } |