From: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox 4/5] notify: smtp: add OAuth2/XOAUTH2 authentication support
Date: Wed, 4 Feb 2026 17:13:43 +0100 [thread overview]
Message-ID: <20260204161354.458814-5-a.bied-charreton@proxmox.com> (raw)
In-Reply-To: <20260204161354.458814-1-a.bied-charreton@proxmox.com>
Add Google & Microsoft OAuth2 support for SMTP notification targets. The
SmtpEndpoint implements refresh_state() to allow proactively refreshing
tokens through pveupdate.
The SMTP API functions are updated to handle OAuth2 state. The refresh
token initially comes from the frontend, and it is more state than it is
configuration, therefore it is passed as a single parameter in
{add,update}_endpoint and persisted separately.
Signed-off-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---
proxmox-notify/src/api/smtp.rs | 144 ++++++++++++++----
proxmox-notify/src/endpoints/smtp.rs | 210 +++++++++++++++++++++++++--
2 files changed, 315 insertions(+), 39 deletions(-)
diff --git a/proxmox-notify/src/api/smtp.rs b/proxmox-notify/src/api/smtp.rs
index 470701bf..9bd4826d 100644
--- a/proxmox-notify/src/api/smtp.rs
+++ b/proxmox-notify/src/api/smtp.rs
@@ -1,11 +1,14 @@
+use std::time::{SystemTime, UNIX_EPOCH};
+
use proxmox_http_error::HttpError;
use crate::api::{http_bail, http_err};
+use crate::context::context;
use crate::endpoints::smtp::{
DeleteableSmtpProperty, SmtpConfig, SmtpConfigUpdater, SmtpPrivateConfig,
- SmtpPrivateConfigUpdater, SMTP_TYPENAME,
+ SmtpPrivateConfigUpdater, SmtpState, SMTP_TYPENAME,
};
-use crate::Config;
+use crate::{Config, State};
/// Get a list of all smtp endpoints.
///
@@ -30,6 +33,30 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result<SmtpConfig, HttpError
.map_err(|_| http_err!(NOT_FOUND, "endpoint '{name}' not found"))
}
+/// Update the state for the endpoint `name`, and persist it at `context().state_file_path()`.
+fn update_state(name: &str, entry: Option<SmtpState>) -> Result<(), HttpError> {
+ let mut state = State::from_path(context().state_file_path()).unwrap_or_default();
+
+ match entry {
+ Some(entry) => state.set(name, &entry).map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not update state for endpoint '{}': {e}",
+ name
+ )
+ })?,
+ None => state.remove(name),
+ }
+
+ state.persist(context().state_file_path()).map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "could not update state for endpoint '{}': {e}",
+ name
+ )
+ })
+}
+
/// Add a new smtp endpoint.
///
/// The caller is responsible for any needed permission checks.
@@ -38,10 +65,15 @@ pub fn get_endpoint(config: &Config, name: &str) -> Result<SmtpConfig, HttpError
/// - an entity with the same name already exists (`400 Bad request`)
/// - the configuration could not be saved (`500 Internal server error`)
/// - mailto *and* mailto_user are both set to `None`
+///
+/// `oauth2_refresh_token` is initially passed through the API when an OAuth2
+/// endpoint is created/updated, however its state is not managed through a
+/// config, which is why it is passed separately.
pub fn add_endpoint(
config: &mut Config,
endpoint_config: SmtpConfig,
private_endpoint_config: SmtpPrivateConfig,
+ oauth2_refresh_token: Option<String>,
) -> Result<(), HttpError> {
if endpoint_config.name != private_endpoint_config.name {
// Programming error by the user of the crate, thus we panic
@@ -64,6 +96,17 @@ pub fn add_endpoint(
&endpoint_config.name,
)?;
+ update_state(
+ &endpoint_config.name,
+ Some(SmtpState {
+ oauth2_refresh_token,
+ last_refreshed: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ }),
+ )?;
+
config
.config
.set_data(&endpoint_config.name, SMTP_TYPENAME, &endpoint_config)
@@ -76,6 +119,28 @@ pub fn add_endpoint(
})
}
+/// Apply `updater` to the private config identified by `name`, and set
+/// the private config entry afterwards.
+pub fn update_private_config(
+ config: &mut Config,
+ name: &str,
+ updater: impl FnOnce(&mut SmtpPrivateConfig),
+) -> Result<(), HttpError> {
+ let mut private_config: SmtpPrivateConfig = config
+ .private_config
+ .lookup(SMTP_TYPENAME, name)
+ .map_err(|e| {
+ http_err!(
+ INTERNAL_SERVER_ERROR,
+ "no private config found for SMTP endpoint: {e}"
+ )
+ })?;
+
+ updater(&mut private_config);
+
+ super::set_private_config_entry(config, private_config, SMTP_TYPENAME, name)
+}
+
/// Update existing smtp endpoint
///
/// The caller is responsible for any needed permission checks.
@@ -83,11 +148,16 @@ pub fn add_endpoint(
/// Returns a `HttpError` if:
/// - the configuration could not be saved (`500 Internal server error`)
/// - mailto *and* mailto_user are both set to `None`
+///
+/// `oauth2_refresh_token` is initially passed through the API when an OAuth2
+/// endpoint is created/updated, however its state is not managed through a
+/// config, which is why it is passed separately.
pub fn update_endpoint(
config: &mut Config,
name: &str,
updater: SmtpConfigUpdater,
private_endpoint_config_updater: SmtpPrivateConfigUpdater,
+ oauth2_refresh_token: Option<String>,
delete: Option<&[DeleteableSmtpProperty]>,
digest: Option<&[u8]>,
) -> Result<(), HttpError> {
@@ -103,20 +173,20 @@ pub fn update_endpoint(
DeleteableSmtpProperty::Disable => endpoint.disable = None,
DeleteableSmtpProperty::Mailto => endpoint.mailto.clear(),
DeleteableSmtpProperty::MailtoUser => endpoint.mailto_user.clear(),
- DeleteableSmtpProperty::Password => super::set_private_config_entry(
- config,
- SmtpPrivateConfig {
- name: name.to_string(),
- password: None,
- },
- SMTP_TYPENAME,
- name,
- )?,
+ DeleteableSmtpProperty::Password => {
+ update_private_config(config, name, |c| c.password = None)?
+ }
+ DeleteableSmtpProperty::AuthMethod => endpoint.auth_method = None,
+ DeleteableSmtpProperty::OAuth2ClientId => endpoint.oauth2_client_id = None,
+ DeleteableSmtpProperty::OAuth2ClientSecret => {
+ update_private_config(config, name, |c| c.oauth2_client_secret = None)?
+ }
+ DeleteableSmtpProperty::OAuth2TenantId => endpoint.oauth2_tenant_id = None,
DeleteableSmtpProperty::Port => endpoint.port = None,
DeleteableSmtpProperty::Username => endpoint.username = None,
}
}
- }
+ };
if let Some(mailto) = updater.mailto {
endpoint.mailto = mailto;
@@ -139,29 +209,24 @@ pub fn update_endpoint(
if let Some(mode) = updater.mode {
endpoint.mode = Some(mode);
}
- if let Some(password) = private_endpoint_config_updater.password {
- super::set_private_config_entry(
- config,
- SmtpPrivateConfig {
- name: name.into(),
- password: Some(password),
- },
- SMTP_TYPENAME,
- name,
- )?;
+ if let Some(auth_method) = updater.auth_method {
+ endpoint.auth_method = Some(auth_method);
}
-
if let Some(author) = updater.author {
endpoint.author = Some(author);
}
-
if let Some(comment) = updater.comment {
endpoint.comment = Some(comment);
}
-
if let Some(disable) = updater.disable {
endpoint.disable = Some(disable);
}
+ if let Some(oauth2_client_id) = updater.oauth2_client_id {
+ endpoint.oauth2_client_id = Some(oauth2_client_id);
+ }
+ if let Some(oauth2_tenant_id) = updater.oauth2_tenant_id {
+ endpoint.oauth2_tenant_id = Some(oauth2_tenant_id);
+ }
if endpoint.mailto.is_empty() && endpoint.mailto_user.is_empty() {
http_bail!(
@@ -170,6 +235,25 @@ pub fn update_endpoint(
);
}
+ let private_config = SmtpPrivateConfig {
+ name: name.into(),
+ password: private_endpoint_config_updater.password,
+ oauth2_client_secret: private_endpoint_config_updater.oauth2_client_secret,
+ };
+
+ super::set_private_config_entry(config, private_config, SMTP_TYPENAME, name)?;
+
+ update_state(
+ name,
+ Some(SmtpState {
+ oauth2_refresh_token,
+ last_refreshed: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ }),
+ )?;
+
config
.config
.set_data(name, SMTP_TYPENAME, &endpoint)
@@ -196,6 +280,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError>
super::remove_private_config_entry(config, name)?;
config.config.sections.remove(name);
+ update_state(name, None)?;
Ok(())
}
@@ -204,7 +289,7 @@ pub fn delete_endpoint(config: &mut Config, name: &str) -> Result<(), HttpError>
pub mod tests {
use super::*;
use crate::api::test_helpers::*;
- use crate::endpoints::smtp::SmtpMode;
+ use crate::endpoints::smtp::{SmtpAuthMethod, SmtpMode};
pub fn add_smtp_endpoint_for_test(config: &mut Config, name: &str) -> Result<(), HttpError> {
add_endpoint(
@@ -217,6 +302,7 @@ pub mod tests {
author: Some("root".into()),
comment: Some("Comment".into()),
mode: Some(SmtpMode::StartTls),
+ auth_method: Some(SmtpAuthMethod::Plain),
server: "localhost".into(),
port: Some(555),
username: Some("username".into()),
@@ -225,7 +311,9 @@ pub mod tests {
SmtpPrivateConfig {
name: name.into(),
password: Some("password".into()),
+ oauth2_client_secret: None,
},
+ None,
)?;
assert!(get_endpoint(config, name).is_ok());
@@ -256,6 +344,7 @@ pub mod tests {
Default::default(),
None,
None,
+ None,
)
.is_err());
@@ -273,6 +362,7 @@ pub mod tests {
Default::default(),
Default::default(),
None,
+ None,
Some(&[0; 32]),
)
.is_err());
@@ -304,6 +394,7 @@ pub mod tests {
},
Default::default(),
None,
+ None,
Some(&digest),
)?;
@@ -327,6 +418,7 @@ pub mod tests {
"smtp-endpoint",
Default::default(),
Default::default(),
+ None,
Some(&[
DeleteableSmtpProperty::Author,
DeleteableSmtpProperty::MailtoUser,
diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/endpoints/smtp.rs
index 69c4048c..b7194fff 100644
--- a/proxmox-notify/src/endpoints/smtp.rs
+++ b/proxmox-notify/src/endpoints/smtp.rs
@@ -1,12 +1,15 @@
use std::borrow::Cow;
-use std::time::Duration;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
use lettre::message::header::{HeaderName, HeaderValue};
use lettre::message::{Mailbox, MultiPart, SinglePart};
+use lettre::transport::smtp::authentication::{Credentials, Mechanism};
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{message::header::ContentType, Message, SmtpTransport, Transport};
use serde::{Deserialize, Serialize};
+use oauth2::{ClientId, ClientSecret, RefreshToken};
+
use proxmox_schema::api_types::COMMENT_SCHEMA;
use proxmox_schema::{api, Updater};
@@ -80,11 +83,21 @@ pub struct SmtpConfig {
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<SmtpMode>,
+ /// Method to be used for authentication.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub auth_method: Option<SmtpAuthMethod>,
/// Username to use during authentication.
/// If no username is set, no authentication will be performed.
/// The PLAIN and LOGIN authentication methods are supported
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
+ /// Client ID for XOAUTH2 authentication method.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_client_id: Option<String>,
+ /// Tenant ID for XOAUTH2 authentication method. Only required for
+ /// Microsoft Exchange Online OAuth2.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_tenant_id: Option<String>,
/// Mail address to send a mail to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[updater(serde(skip_serializing_if = "Option::is_none"))]
@@ -131,12 +144,39 @@ pub enum DeleteableSmtpProperty {
MailtoUser,
/// Delete `password`
Password,
+ /// Delete `auth_method`
+ AuthMethod,
+ /// Delete `oauth2_client_id`
+ #[serde(rename = "oauth2-client-id")]
+ OAuth2ClientId,
+ /// Delete `oauth2_client_secret`
+ #[serde(rename = "oauth2-client-secret")]
+ OAuth2ClientSecret,
+ /// Delete `oauth2_tenant_id`
+ #[serde(rename = "oauth2-tenant-id")]
+ OAuth2TenantId,
/// Delete `port`
Port,
/// Delete `username`
Username,
}
+/// Authentication mode to use for SMTP.
+#[api]
+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum SmtpAuthMethod {
+ /// Username + password
+ #[default]
+ Plain,
+ /// Google OAuth2
+ #[serde(rename = "google-oauth2")]
+ GoogleOAuth2,
+ /// Microsoft OAuth2
+ #[serde(rename = "microsoft-oauth2")]
+ MicrosoftOAuth2,
+}
+
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "kebab-case")]
pub struct SmtpState {
@@ -157,9 +197,14 @@ pub struct SmtpPrivateConfig {
/// Name of the endpoint
#[updater(skip)]
pub name: String,
+
/// The password to use during authentication.
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
+
+ /// OAuth2 client secret
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub oauth2_client_secret: Option<String>,
}
/// A sendmail notification endpoint.
@@ -168,6 +213,60 @@ pub struct SmtpEndpoint {
pub private_config: SmtpPrivateConfig,
}
+impl SmtpEndpoint {
+ fn get_access_token(
+ &self,
+ refresh_token: &str,
+ auth_method: &SmtpAuthMethod,
+ ) -> Result<xoauth2::TokenExchangeResult, Error> {
+ let client_id = ClientId::new(
+ self.config
+ .oauth2_client_id
+ .as_ref()
+ .ok_or_else(|| Error::Generic("oauth2-client-id not set".into()))?
+ .to_string(),
+ );
+ let client_secret = ClientSecret::new(
+ self.private_config
+ .oauth2_client_secret
+ .as_ref()
+ .ok_or_else(|| Error::Generic("oauth2-client-secret not set".into()))?
+ .to_string(),
+ );
+ let refresh_token = RefreshToken::new(refresh_token.into());
+
+ match auth_method {
+ SmtpAuthMethod::GoogleOAuth2 => {
+ xoauth2::get_google_token(client_id, client_secret, refresh_token)
+ }
+ SmtpAuthMethod::MicrosoftOAuth2 => xoauth2::get_microsoft_token(
+ client_id,
+ client_secret,
+ &self.config.oauth2_tenant_id.as_ref().ok_or(Error::Generic(
+ "tenant ID not set, required for Microsoft OAuth2".into(),
+ ))?,
+ refresh_token,
+ ),
+ _ => Err(Error::Generic("OAuth2 not configured".into())),
+ }
+ }
+
+ /// Infer the auth method based on the presence of a password field in the private config.
+ ///
+ /// This is required for backwards compatibility for configs created before the `auth_method`
+ /// field was added, i.e., the presence of a password implicitly meant plain authentication
+ /// was to be used.
+ fn auth_method(&self) -> Option<SmtpAuthMethod> {
+ self.config.auth_method.clone().or_else(|| {
+ if self.private_config.password.is_some() {
+ Some(SmtpAuthMethod::Plain)
+ } else {
+ None
+ }
+ })
+ }
+}
+
impl Endpoint for SmtpEndpoint {
fn send(&self, notification: &Notification, state: &mut State) -> Result<(), Error> {
let mut endpoint_state = state.get_or_default::<SmtpState>(self.name())?;
@@ -190,23 +289,58 @@ impl Endpoint for SmtpEndpoint {
}
};
- let mut transport_builder = SmtpTransport::builder_dangerous(&self.config.server)
+ let transport_builder = SmtpTransport::builder_dangerous(&self.config.server)
.tls(tls)
.port(port)
.timeout(Some(Duration::from_secs(SMTP_TIMEOUT.into())));
- if let Some(username) = self.config.username.as_deref() {
- if let Some(password) = self.private_config.password.as_deref() {
- transport_builder = transport_builder.credentials((username, password).into());
- } else {
- return Err(Error::NotifyFailed(
- self.name().into(),
- Box::new(Error::Generic(
- "username is set but no password was provided".to_owned(),
- )),
- ));
+ let transport_builder = match &self.auth_method() {
+ None => transport_builder,
+ Some(SmtpAuthMethod::Plain) => match (
+ self.config.username.as_deref(),
+ self.private_config.password.as_deref(),
+ ) {
+ (Some(username), Some(password)) => {
+ transport_builder.credentials((username, password).into())
+ }
+ (Some(_), None) => {
+ return Err(Error::NotifyFailed(
+ self.name().into(),
+ Box::new(Error::Generic(
+ "username is set but no password was provided".to_owned(),
+ )),
+ ))
+ }
+ _ => transport_builder,
+ },
+ Some(method) => {
+ let token_exchange_result = self.get_access_token(
+ endpoint_state
+ .oauth2_refresh_token
+ .as_ref()
+ .ok_or(Error::NotifyFailed(
+ self.name().into(),
+ Box::new(Error::Generic("no refresh token found for endpoint".into())),
+ ))?,
+ method,
+ )?;
+
+ if let Some(new_refresh_token) = token_exchange_result.refresh_token {
+ endpoint_state.oauth2_refresh_token = Some(new_refresh_token.into_secret());
+ }
+ endpoint_state.last_refreshed = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+
+ transport_builder
+ .credentials(Credentials::new(
+ self.config.from_address.clone(),
+ token_exchange_result.access_token.into_secret(),
+ ))
+ .authentication(vec![Mechanism::Xoauth2])
}
- }
+ };
let transport = transport_builder.build();
@@ -298,6 +432,56 @@ impl Endpoint for SmtpEndpoint {
fn disabled(&self) -> bool {
self.config.disable.unwrap_or_default()
}
+
+ fn refresh_state(&self, state: &mut State) -> Result<bool, Error> {
+ let endpoint_state = match state.get::<SmtpState>(self.name())? {
+ None => return Ok(false),
+ Some(s) => s,
+ };
+
+ let Some(refresh_token) = endpoint_state.oauth2_refresh_token else {
+ return Ok(false);
+ };
+
+ // The refresh job is configured in pveupdate, which runs once for each node.
+ // Don't refresh if we already did it recently.
+ if SystemTime::now()
+ .duration_since(UNIX_EPOCH + Duration::from_secs(endpoint_state.last_refreshed))
+ .map_err(|e| Error::Generic(e.to_string()))?
+ < Duration::from_secs(60 * 60 * 12)
+ {
+ return Ok(false);
+ }
+
+ let Some(auth_method) = self.config.auth_method.as_ref() else {
+ return Ok(false);
+ };
+
+ let new_state = match self
+ .get_access_token(&refresh_token, auth_method)?
+ .refresh_token
+ {
+ // Microsoft OAuth2, new token was returned
+ Some(new_refresh_token) => SmtpState {
+ oauth2_refresh_token: Some(new_refresh_token.into_secret()),
+ last_refreshed: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ },
+ // Google OAuth2, refresh token's lifetime was extended
+ None => SmtpState {
+ oauth2_refresh_token: Some(refresh_token),
+ last_refreshed: SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs(),
+ },
+ };
+
+ state.set(self.name(), &new_state)?;
+ Ok(true)
+ }
}
/// Construct a lettre `Message` from a raw email message.
--
2.47.3
next prev parent reply other threads:[~2026-02-04 16:14 UTC|newest]
Thread overview: 16+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-02-04 16:13 [RFC cluster/docs/manager/proxmox{,-perl-rs,-widget-toolkit} 00/15] fix #7238: Add XOAUTH2 authentication support for SMTP notification targets Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH proxmox 1/5] notify: Introduce xoauth2 module Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH proxmox 2/5] notify: Add state file handling Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH proxmox 3/5] notify: Update Endpoint trait and Bus to use State Arthur Bied-Charreton
2026-02-04 16:13 ` Arthur Bied-Charreton [this message]
2026-02-04 16:13 ` [PATCH proxmox 5/5] notify: Add test for State Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH proxmox-perl-rs 1/1] notify: update bindings with new OAuth2 parameters Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH proxmox-widget-toolkit 1/2] utils: Add OAuth2 flow handlers Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH proxmox-widget-toolkit 2/2] notifications: Add opt-in OAuth2 support for SMTP targets Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-manager 1/5] notifications: Add OAuth2 parameters to schema and add/update endpoints Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-manager 2/5] notifications: Add refresh-targets endpoint Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-manager 3/5] notifications: Trigger notification target refresh in pveupdate Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-manager 4/5] notifications: Handle OAuth2 callback in login handler Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-manager 5/5] notifications: Opt into OAuth2 authentication Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-cluster 1/1] notifications: Add refresh_targets subroutine to PVE::Notify Arthur Bied-Charreton
2026-02-04 16:13 ` [PATCH pve-docs 1/1] notifications: Add section about OAuth2 to SMTP targets docs Arthur Bied-Charreton
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260204161354.458814-5-a.bied-charreton@proxmox.com \
--to=a.bied-charreton@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox