public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
To: Shannon Sterz <s.sterz@proxmox.com>, pbs-devel@lists.proxmox.com
Subject: Re: [PATCH proxmox v3] fix #7814: send-mail: normalize LF line endings to CRLF endings
Date: Wed, 21 Oct 2026 11:38:22 +0200	[thread overview]
Message-ID: <f2842212-6ec9-48ae-a3f0-5407e1167088@proxmox.com> (raw)
In-Reply-To: <20261020143437.333073-1-s.sterz@proxmox.com>

thanks for the update Shannon!

in addition to the previous tests, i tested the new base64 CRLF
normalization and filename sanitization, which passed too.

looks good to me! :)

Tested-by: Samuel Rufinatscha
Reviewed-by: Samuel Rufinatscha

On 10/20/26 4:35 PM, Shannon Sterz wrote:
> RFC5321 Section 2.3.8 requires that mails do not contain bare CR or LF
> bytes. this patch normalizes unix style LF line endings to CRLF line
> endings. it also removes bare CR bytes.
> 
> when text would be base64 encoded, CR bytes will be dropped, to keep
> the behavior for encoded and non-encoded text aligned. to comply with
> rfc 2045 section 6.8 and rfc 2047 section 4.1 line endings are also
> normalized before encoding them in headers and body text [1,2].
> 
> for filenames we now strip all control characters other than space
> (0x20). this should improve compliance with rfc 6266 section 4.3 [3].
> while that section specifies the recipient should do such stripping,
> doing so before sending minimizes compatibility issues and avoids
> transfering useless information.
> 
> also adds tests to encode this behavior.
> 
> [1]: https://datatracker.ietf.org/doc/html/rfc2045#section-6.8
> [2]: https://datatracker.ietf.org/doc/html/rfc2047#section-4.1
> [3]: https://datatracker.ietf.org/doc/html/rfc6266/#section-4.3
> 
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
> 
> Notes:
>      v2: https://lore.proxmox.com/all/20260730140126.444424-1-s.sterz@proxmox.com/
>      
>      changes since v2 (thanks @ Samuel Rufinatscha):
>      
>      * normalize line endings to CRLF and preserve existing CRLF line
>        endings before base64 encoding according to rfc 2045 and 2047.
>      * remove all control characters from filenames not just CR.
>      * improve documentation.
>      
>      v1: https://lore.proxmox.com/all/20260714084719.68076-1-s.sterz@proxmox.com/
>      
>      changes since v1 (thanks @ Lukas Wagner):
>      
>      * fixed a typo (than -> then)
>      * adjusted how the capacity for forwarded messages that need to be
>        normalized is calculated.
> 
>   proxmox-sendmail/src/lib.rs | 280 ++++++++++++++++++++++++++++++++----
>   1 file changed, 256 insertions(+), 24 deletions(-)
> 
> diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs
> index b3ff56e5..45868c0f 100644
> --- a/proxmox-sendmail/src/lib.rs
> +++ b/proxmox-sendmail/src/lib.rs
> @@ -63,6 +63,30 @@ fn encode_filename_formatted(filename: &str) -> String {
>       format_encoded_text(&encoded, format_prefix, ";", 0, true)
>   }
>   
> +/// Normalizes line endings before base64 encoding bytes. Stripping bare CR bytes is necessary
> +/// according to RFC 5321 Section 2.3.8
> +/// (https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8). RFC 2045 Section 6.8
> +/// (https://datatracker.ietf.org/doc/html/rfc2045#section-6.8) and RFC 2047 Section 4.1
> +/// (https://datatracker.ietf.org/doc/html/rfc2047#section-4.1) also specify that line endings
> +/// should be normalized to CRLF prior to base64 encoding in mime body text and header values.
> +fn normalize_line_endings_base64<T: AsRef<[u8]>>(raw: T) -> String {
> +    // this will always have enough capacity, since the worst case is a message consisting only
> +    // of "\n" and that would exactly double in size. messages are also unlikely to be that big
> +    // that the extra allocated space here really matters. so set the capacity to the upper
> +    // bound to avoid more complex capacity calculations that would take more time to run.
> +    let mut normalized: Vec<u8> = Vec::with_capacity(raw.as_ref().len() * 2);
> +
> +    for byte in raw.as_ref() {
> +        match byte {
> +            b'\r' => continue,
> +            b'\n' => normalized.extend_from_slice(b"\r\n"),
> +            b => normalized.push(*b),
> +        }
> +    }
> +
> +    proxmox_base64::encode(&normalized)
> +}
> +
>   /// Helper function used to format text to not exceed line limits imposed by standards such as RFC
>   /// 2045 Section 6.8 that specifies that Base64 content transfer encoded text "must be represented
>   /// in lines of no more than 76 characters each". It also allows taking into account any necessary
> @@ -168,7 +192,7 @@ impl Recipient {
>               if !name.is_ascii() {
>                   format!(
>                       "=?utf-8?B?{}?= <{}>",
> -                    proxmox_base64::encode(name),
> +                    normalize_line_endings_base64(name),
>                       self.email
>                   )
>               } else {
> @@ -199,7 +223,7 @@ impl Attachment<'_> {
>           if self.filename.is_ascii() {
>               let _ = write!(attachment, "{}", &self.filename);
>           } else {
> -            let encoded = proxmox_base64::encode(&self.filename);
> +            let encoded = normalize_line_endings_base64(&self.filename);
>               let prefix_fn = |line| {
>                   if Some(0) == line {
>                       "=?utf-8?B?"
> @@ -325,12 +349,27 @@ impl<'a> Mail<'a> {
>           self
>       }
>   
> -    /// Adds an attachment with a specified file name and mime-type to an email.
> +    /// Adds an attachment with a specified filename and mime-type to an email. The filename will be
> +    /// stripped of control characters for better compatibility.
>       ///
>       /// Note: Adding attachments triggers `multipart/mixed` mode.
>       pub fn add_attachment(&mut self, filename: &str, mime_type: &str, content: &'a [u8]) {
> +        // Since we strip control characters the length of the original filename is an absolute
> +        // upper bound of the size of the final string.
> +        let mut normalized: String = String::with_capacity(filename.len());
> +
> +        // RFC 6266 Section 4.3 (https://datatracker.ietf.org/doc/html/rfc6266/#section-4.3) says
> +        // that recipients should remove things such as control characters from a filename. So since
> +        // these should be stripped anyway, do that here already to avoid encoding them later.
> +        for character in filename.chars() {
> +            match character {
> +                c if c <= '\x1f' || c == '\x7f' => continue,
> +                c => normalized.push(c),
> +            }
> +        }
> +
>           self.attachments.push(Attachment {
> -            filename: filename.to_string(),
> +            filename: normalized,
>               mime: mime_type.to_string(),
>               content,
>           });
> @@ -451,7 +490,10 @@ impl<'a> Mail<'a> {
>   
>       /// Forwards an email message to a given list of recipients.
>       ///
> -    /// `message` must be compatible with ``sendmail`` (the message is piped into stdin unmodified).
> +    /// `message` must be compatible with ``sendmail``. The message is piped into `stdin`
> +    /// unmodified. One exception is stripping bare CR bytes and normalizing LF line endings to
> +    /// CRLF line endings in accordance with RFC 5321 Section 2.3.8
> +    /// (https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8).
>       #[cfg(feature = "mail-forwarder")]
>       pub fn forward(
>           mailto: &[&str],
> @@ -485,11 +527,25 @@ impl<'a> Mail<'a> {
>               .spawn()
>               .with_context(|| "could not spawn sendmail process")?;
>   
> +        // this will always have enough capacity, since the worst case is a message consisting only
> +        // of "\n" and that would exactly double in size. messages are also unlikely to be that big
> +        // that the extra allocated space here really matters. so set the capacity to the upper
> +        // bound to avoid more complex capacity calculations that would take more time to run.
> +        let mut normalized_message: Vec<u8> = Vec::with_capacity(message.len() * 2);
> +
> +        for byte in message {
> +            match byte {
> +                b'\r' => continue,
> +                b'\n' => normalized_message.extend_from_slice(b"\r\n"),
> +                b => normalized_message.push(*b),
> +            }
> +        }
> +
>           sendmail_process
>               .stdin
>               .take()
>               .unwrap()
> -            .write_all(message)
> +            .write_all(&normalized_message)
>               .with_context(|| "couldn't write to sendmail stdin")?;
>   
>           sendmail_process
> @@ -520,7 +576,9 @@ impl<'a> Mail<'a> {
>               write!(mail, "\n--{file_boundary}--")?;
>           }
>   
> -        Ok(mail)
> +        // normalize line endings from `\n` to `\r\n` and drop bare `\r` according to RFC 5321
> +        // 2.3.8: https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8
> +        Ok(mail.replace('\r', "").replace('\n', "\r\n"))
>       }
>   
>       fn format_header(
> @@ -561,7 +619,7 @@ impl<'a> Mail<'a> {
>               writeln!(
>                   header,
>                   "Subject: =?utf-8?B?{}?=",
> -                proxmox_base64::encode(&self.subject)
> +                normalize_line_endings_base64(&self.subject)
>               )?;
>           } else {
>               writeln!(header, "Subject: {}", self.subject)?;
> @@ -571,7 +629,7 @@ impl<'a> Mail<'a> {
>               writeln!(
>                   header,
>                   "From: =?utf-8?B?{}?= <{}>",
> -                proxmox_base64::encode(&self.mail_author),
> +                normalize_line_endings_base64(&self.mail_author),
>                   self.mail_from
>               )?;
>           } else {
> @@ -606,16 +664,18 @@ impl<'a> Mail<'a> {
>           header.push_str("Auto-Submitted: auto-generated;\n");
>   
>           for (key, value) in &self.headers {
> -            // Strip CR/LF so an interpolated key or value cannot inject extra header lines.
> -            let key = key.replace(['\r', '\n'], "");
> -            let value = value.replace(['\r', '\n'], "");
> +            // Strip LF so an interpolated key or value cannot inject extra header lines. Bare CR
> +            // will be stripped for non-ASCII text before encoding and for ASCII text globally
> +            // before finishing formatting.
> +            let key = key.replace('\n', "");
> +            let value = value.replace('\n', "");
>               if value.is_ascii() {
>                   writeln!(header, "{key}: {value}")?;
>               } else {
>                   writeln!(
>                       header,
>                       "{key}: =?utf-8?B?{}?=",
> -                    proxmox_base64::encode(&value)
> +                    normalize_line_endings_base64(&value)
>                   )?;
>               }
>           }
> @@ -653,7 +713,11 @@ impl<'a> Mail<'a> {
>               body.push_str(&self.body_txt);
>           } else {
>               body.push_str("Content-Transfer-Encoding: base64\n\n");
> -            body.push_str(&encode_base64_formatted(&self.body_txt));
> +            // normalize line endings according to RFC 2045 Section 6.8
> +            // (https://datatracker.ietf.org/doc/html/rfc2045#section-6.8).
> +            body.push_str(&encode_base64_formatted(
> +                self.body_txt.replace('\r', "").replace('\n', "\r\n"),
> +            ));
>           }
>   
>           if let Some(html) = &self.body_html {
> @@ -666,7 +730,11 @@ impl<'a> Mail<'a> {
>                   body.push_str(html);
>               } else {
>                   body.push_str("Content-Transfer-Encoding: base64\n\n");
> -                body.push_str(&encode_base64_formatted(html));
> +                // normalize line endings according to RFC 2045 Section 6.8
> +                // (https://datatracker.ietf.org/doc/html/rfc2045#section-6.8).
> +                body.push_str(&encode_base64_formatted(
> +                    html.replace('\r', "").replace('\n', "\r\n"),
> +                ));
>               }
>   
>               write!(body, "\n--{html_boundary}--")?;
> @@ -895,8 +963,8 @@ Content-Type: text/plain;
>   	charset="UTF-8"
>   Content-Transfer-Encoding: base64
>   
> -VGhpcyB1dGYtOCBlbWFpbCBzaG91bGQgaGFuZGxlIGVtb2ppcwrwn6eR8J+TpwphbmQgd2Vp
> -cmQgZ2VybWFuIGNoYXJhY3RlcnM6IMO2w6TDvMOfCmFuZCBtb3JlLg=="#,
> +VGhpcyB1dGYtOCBlbWFpbCBzaG91bGQgaGFuZGxlIGVtb2ppcw0K8J+nkfCfk6cNCmFuZCB3
> +ZWlyZCBnZXJtYW4gY2hhcmFjdGVyczogw7bDpMO8w58NCmFuZCBtb3JlLg=="#,
>           )
>       }
>   
> @@ -1140,10 +1208,10 @@ Content-Transfer-Encoding: 7bit
>           ];
>   
>           let mail = Mail::new(
> -            "Sender Näme",
> -            "from@example.com",
> -            "Subject Linë",
> -            "Lorem Ipsum Dolor Sit\nAmët",
> +            "Sender Nä\rme",
> +            "from@example.\rcom",
> +            "Subject Linë\r",
> +            "Lorem Ipsum Dolor \rSit\nAmët",
>           )
>           .with_recipient_and_name("Receiver Näme", "receiver@example.com")
>           .with_attachment("deadbeef.bin", "application/octet-stream", &bin)
> @@ -1176,14 +1244,14 @@ Content-Type: text/plain;
>   	charset="UTF-8"
>   Content-Transfer-Encoding: base64
>   
> -TG9yZW0gSXBzdW0gRG9sb3IgU2l0CkFtw6t0
> +TG9yZW0gSXBzdW0gRG9sb3IgU2l0DQpBbcOrdA==
>   ------_=_NextPart_002_1732806251
>   Content-Type: text/html;
>   	charset="UTF-8"
>   Content-Transfer-Encoding: base64
>   
> -PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT4KCTxwcmU+CgkJTG9yZW0g
> -SXBzdW0gRMO2bG9yIFNpdCBBbWV0Cgk8L3ByZT4KPC9ib2R5PjwvaHRtbD4=
> +PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT4NCgk8cHJlPg0KCQlMb3Jl
> +bSBJcHN1bSBEw7Zsb3IgU2l0IEFtZXQNCgk8L3ByZT4NCjwvYm9keT48L2h0bWw+
>   ------_=_NextPart_002_1732806251--
>   ------_=_NextPart_001_1732806251
>   Content-Type: application/octet-stream;
> @@ -1344,4 +1412,168 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
>   
>           assert_eq!(suffixed, out);
>       }
> +
> +    #[test]
> +    fn line_endings_are_normalized_when_not_encoded() {
> +        let bin: [u8; 63] = [
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0x0d,
> +        ];
> +
> +        let mail = Mail::new(
> +            "Se\rnder Name",
> +            "fro\rm@example.com",
> +            "Sub\rject Line",
> +            "Lor\rem Ipsum Dolor Sit\nAmet",
> +        )
> +        .with_recipient_and_name("R\receiver Name", "receive\rr@example.com")
> +        .with_attachment("deadb\reef.bin", "application/octet-stream", &bin)
> +        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Si\rt Amet\n\t</pre>\n</body></html>")
> +        .with_header("X-CR-HEADE\r", "value\r");
> +
> +        let body = mail.format_mail(1732806251).expect("could not format mail");
> +
> +        assert!(body.is_ascii());
> +
> +        let expected = r#"Content-Type: multipart/mixed;
> +	boundary="----_=_NextPart_001_1732806251"
> +MIME-Version: 1.0
> +Subject: Subject Line
> +From: Sender Name <from@example.com>
> +To: Receiver Name <receiver@example.com>
> +Date: Thu, 28 Nov 2024 16:04:11 +0100
> +Auto-Submitted: auto-generated;
> +X-CR-HEADE: value
> +
> +This is a multi-part message in MIME format.
> +
> +------_=_NextPart_001_1732806251
> +Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251"
> +MIME-Version: 1.0
> +
> +------_=_NextPart_002_1732806251
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 7bit
> +
> +Lorem Ipsum Dolor Sit
> +Amet
> +------_=_NextPart_002_1732806251
> +Content-Type: text/html;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 7bit
> +
> +<html lang="de-at"><head></head><body>
> +	<pre>
> +		Lorem Ipsum Dolor Sit Amet
> +	</pre>
> +</body></html>
> +------_=_NextPart_002_1732806251--
> +------_=_NextPart_001_1732806251
> +Content-Type: application/octet-stream;
> +	name="deadbeef.bin"
> +Content-Disposition: attachment;
> +	filename*0*=UTF-8''deadbeef.bin
> +Content-Transfer-Encoding: base64
> +
> +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
> +3q2+796tvu8N
> +------_=_NextPart_001_1732806251--"#;
> +
> +        // manually compare here to ensure we split specifically only on `\r\n` line endings
> +
> +        let lines1 = body.split("\r\n");
> +        let lines2 = expected.split("\n");
> +
> +        for ((i, line1), line2) in lines1.enumerate().zip(lines2) {
> +            if !(line1.starts_with("Date:") && line2.starts_with("Date:")) {
> +                assert_eq!(line1, line2, "lines at {i} do not match");
> +            }
> +        }
> +
> +        assert_eq!(body.lines().count(), expected.lines().count());
> +    }
> +
> +    #[test]
> +    fn line_endings_are_normalized_when_encoded() {
> +        let bin: [u8; 63] = [
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0x0d,
> +        ];
> +
> +        let mail = Mail::new(
> +            "Se\rnder Näme",
> +            "fro\rm@example.com",
> +            "Sub\rject Linë",
> +            "Lor\rem Ipsum \r\nDolor Sit\nAmët",
> +        )
> +        .with_recipient_and_name("R\receiver Näme", "receive\rr@example.com")
> +        .with_attachment("🐄💀.\x7fb\rin", "ima\rge/bmp", &bin)
> +        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\r\n\t\tLorem Ipsum Dölor Si\rt Amet\n\t</pre>\n</body></html>")
> +        .with_header("X-CR-HEADE\r", "valüe\r");
> +
> +        let body = mail.format_mail(1732806251).expect("could not format mail");
> +
> +        assert!(body.is_ascii());
> +
> +        let expected = r#"Content-Type: multipart/mixed;
> +	boundary="----_=_NextPart_001_1732806251"
> +MIME-Version: 1.0
> +Subject: =?utf-8?B?U3ViamVjdCBMaW7Dqw==?=
> +From: =?utf-8?B?U2VuZGVyIE7DpG1l?= <from@example.com>
> +To: =?utf-8?B?UmVjZWl2ZXIgTsOkbWU=?= <receiver@example.com>
> +Date: Thu, 28 Nov 2024 16:04:11 +0100
> +Auto-Submitted: auto-generated;
> +X-CR-HEADE: =?utf-8?B?dmFsw7xl?=
> +
> +This is a multi-part message in MIME format.
> +
> +------_=_NextPart_001_1732806251
> +Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251"
> +MIME-Version: 1.0
> +
> +------_=_NextPart_002_1732806251
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: base64
> +
> +TG9yZW0gSXBzdW0gDQpEb2xvciBTaXQNCkFtw6t0
> +------_=_NextPart_002_1732806251
> +Content-Type: text/html;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: base64
> +
> +PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT4NCgk8cHJlPg0KCQlMb3Jl
> +bSBJcHN1bSBEw7Zsb3IgU2l0IEFtZXQNCgk8L3ByZT4NCjwvYm9keT48L2h0bWw+
> +------_=_NextPart_002_1732806251--
> +------_=_NextPart_001_1732806251
> +Content-Type: image/bmp;
> +	name="=?utf-8?B?8J+QhPCfkoAuYmlu?="
> +Content-Disposition: attachment;
> +	filename*0*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin
> +Content-Transfer-Encoding: base64
> +
> +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
> +3q2+796tvu8N
> +------_=_NextPart_001_1732806251--"#;
> +
> +        // manually compare here to ensure we split specifically only on `\r\n` line endings
> +
> +        let lines1 = body.split("\r\n");
> +        let lines2 = expected.split("\n");
> +
> +        for ((i, line1), line2) in lines1.enumerate().zip(lines2) {
> +            if !(line1.starts_with("Date:") && line2.starts_with("Date:")) {
> +                assert_eq!(line1, line2, "lines at {i} do not match");
> +            }
> +        }
> +
> +        assert_eq!(body.lines().count(), expected.lines().count());
> +    }
>   }





      reply	other threads:[~2026-10-21  9:39 UTC|newest]

Thread overview: 2+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-20 14:34 [PATCH proxmox v3] fix #7814: send-mail: normalize LF line endings to CRLF endings Shannon Sterz
2026-10-21  9:38 ` Samuel Rufinatscha [this message]

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=f2842212-6ec9-48ae-a3f0-5407e1167088@proxmox.com \
    --to=s.rufinatscha@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    --cc=s.sterz@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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal