diff --git a/README.md b/README.md
index 3a67d0d..9ab4303 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ Here are some highlights of go-mail's featureset:
* [X] Custom error types for delivery errors
* [X] Custom dial-context functions for more control over the connection (proxing, DNS hooking, etc.)
* [X] Output a go-mail message as EML file and parse EML file into a go-mail message
+* [X] S/MIME singed messages
go-mail works like a programatic email client and provides lots of methods and functionalities you would consider
standard in a MUA.
diff --git a/dummy-chain-cert-ecdsa.pem b/dummy-chain-cert-ecdsa.pem
new file mode 100644
index 0000000..5f9953c
--- /dev/null
+++ b/dummy-chain-cert-ecdsa.pem
@@ -0,0 +1,26 @@
+-----BEGIN CERTIFICATE-----
+MIICAzCCAaqgAwIBAgIUKNWGvPrlzuYHnP4m6nGe60LalYEwCgYIKoZIzj0EAwIw
+ZTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MRcw
+FQYDVQQKDA5JbnRlcm1lZGlhdGVDQTEeMBwGA1UEAwwVSW50ZXJtZWRpYXRlIEVD
+RFNBIENBMB4XDTI0MTAxNzEzMDg0N1oXDTI2MTAxNzEzMDg0N1owWzELMAkGA1UE
+BhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MRIwEAYDVQQKDAlF
+bmRFbnRpdHkxGTAXBgNVBAMMEEVuZCBFbnRpdHkgRUNEU0EwWTATBgcqhkjOPQIB
+BggqhkjOPQMBBwNCAATMkQg2RhaDYOWs+Ik/NKgZdwxm4BzagwVou6R73uiwXsRi
+QgJfhBxp01S3JI4E8AiboFf4uPRnRh0HGl9cUR+po0IwQDAdBgNVHQ4EFgQUz247
+eAeMuM2We19rA5HnLzyLZEUwHwYDVR0jBBgwFoAU6oYLh690kT1bIB3DUA/SGRim
+dXswCgYIKoZIzj0EAwIDRwAwRAIgI7cIpGzoiU1IoTYniEGXtK+WXq4Luv8k3BJQ
+W16RAVsCICnuLaRyH/5nA3mmciAiF5R9PKDzyBnJcPGuCM1tmEpN
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIB/zCCAaSgAwIBAgIURGCUhG09dwZ2DwtWHjQxGTnNT3UwCgYIKoZIzj0EAwIw
+VTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MQ8w
+DQYDVQQKDAZSb290Q0ExFjAUBgNVBAMMDVJvb3QgRUNEU0EgQ0EwHhcNMjQxMDE3
+MTMwODMwWhcNMjkxMDE2MTMwODMwWjBlMQswCQYDVQQGEwJVUzEOMAwGA1UECAwF
+U3RhdGUxDTALBgNVBAcMBENpdHkxFzAVBgNVBAoMDkludGVybWVkaWF0ZUNBMR4w
+HAYDVQQDDBVJbnRlcm1lZGlhdGUgRUNEU0EgQ0EwWTATBgcqhkjOPQIBBggqhkjO
+PQMBBwNCAASYjGGvlMFZwnqUc8+jt9I7qRT8IP5gYLPZ7oiV/oaGNinmtzG7UXC/
+2PEDvdqpMPNw65IaP0d8z+c5lUxneE70o0IwQDAdBgNVHQ4EFgQU6oYLh690kT1b
+IB3DUA/SGRimdXswHwYDVR0jBBgwFoAUGVvIlDmd/tCejujJcy4xTvgkw3IwCgYI
+KoZIzj0EAwIDSQAwRgIhAO0rw5JvMu5y8JO958/4FThdjwOsg/IDGryQ3QQM0tw1
+AiEA07W1o81WIOVLZzwyTJAN8SnpSRIXgV/+ccst2T7s7Zs=
+-----END CERTIFICATE-----
diff --git a/dummy-chain-cert-ecdsa.pem.license b/dummy-chain-cert-ecdsa.pem.license
new file mode 100644
index 0000000..615ea2b
--- /dev/null
+++ b/dummy-chain-cert-ecdsa.pem.license
@@ -0,0 +1,3 @@
+// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
diff --git a/dummy-chain-cert-rsa.pem b/dummy-chain-cert-rsa.pem
new file mode 100644
index 0000000..4daceaa
--- /dev/null
+++ b/dummy-chain-cert-rsa.pem
@@ -0,0 +1,62 @@
+-----BEGIN CERTIFICATE-----
+MIIFWjCCA0KgAwIBAgIUAi7P4JOR4g8b5DMERUtZQEtw+igwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA5MjYxNDU0MDlaFw0yNTA5
+MjYxNDU0MDlaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB
+AQUAA4ICDwAwggIKAoICAQCiLh0JJTRRBhmUyiMKALHtTOK7T20Bwy+fG0SO6RlB
+c+hSuuX/n6znXcNgOBlQ2Gg3+p1on/bmcKnGN/SCiVBLpROiwxg3blQbZ7B7Jors
+/MopGk0LIBOXHPtAuYbF4J6ND5Ol6sgeGjMnomwRjZlfeuBlHY345MqvcwH/lPhO
+lKme+tWD+bsFh08NGS+3NdQGP6dA2bRVrPXhLXStHEmqfKO9EMVLWv+77tYhZESD
+6XgnA8pWjbdr9jajCsrQWrCG3jqHtzHNxtwf7xfRwwgoUhLEvue6SBVokZGVmDhv
+WdRt2sjtLcWWJCI3p7M+NXRt5qf6iu24wLBdzIDuWfgooWu5vBzNZjSTh2if6R1a
+s9BdwASwy1n2HMvqpzgA+f/rXDFbvVc7WIKiuGzfWApBrL0qTCQBuSyepH0G4rQ2
+sJtI5U8QOKBO76nQJq5WDQgefBX4GDI8aJ6qX+teQq1AqERUmLWx4WlTwxSo5X+d
+1jY9I8f61CRKVfIRgMvAZUhm2h6RnoVIgq7G7W3HdC3RT/f758njI7oIv5bhiyqp
+gyKr3cYhmn0enjP+YtjY85m51q005qzRLfaTYiwMR4qyJW4ZOEPntPs20CD+e+Pi
+2JLONpRdcsSrkqZusVjm5PFy2e5RyNFXTupUH2KVrgTRHL3GG2KWF5PmBdkhQfGG
+1QIDAQABo0IwQDAdBgNVHQ4EFgQUS8+HouVQ94SdgI3kV3L8Jm53P6YwHwYDVR0j
+BBgwFoAUY1u7KerT8m01BvAg77PUaot2S0EwDQYJKoZIhvcNAQELBQADggIBAE14
+YBa/stYwrsy/1iQ44NeQyYMPMdOC8TI1xrbW/9u1FllipECnFEGDK1N6mZ7xDEfG
+un5dQ3jXQ7156Ge672374yUsN7FQ37mTyZos3Q2N/mOpVOnYJt5mIukx2MXBU3r3
+UP1Jpnf9rB4kdtWXa7b1CSTkM4kraige3wZhPELwESnm4t8C34MIzHBWPbHpft05
+WheDv9Zizfw+0pbJ+WNGnHF4PjR/wq9ymkLf89cqsbS9mOdPpWva8i0e7pqKnxzo
+iz2ueQB4Z2Tbgp0G9ResA+2Zxk1iIQPbhtqNUZv6ROPiLAWdiVRysFJJf/19V+nZ
+LIC0xw+amF6P51/fA95EGqElO4OLJTIGY27H761g7+FhTwfryLMHKknSxcfk7xoq
+BMyBr7ARYnmpjee7yKOBUgSdpxb6YUcdGZwjCUIiIHlBII83DzcNILa5QvUkzMCh
+xHYmPvvftJOjF8hwMfjA9MDFML9yWVm+CBNraNPh25U5uMOuIuyUBtSB5yEdPRhY
+BJGrZEew0lLAWAqqASmGPDaWNBaA0HYqO70g4IyqBwIGNnaHSLVr/vT23BFRMyXf
+wh5mtJmyyR3+c0po3vDX39mkIAZ2gZWprWa3Jw0dVs6cEujcVNdqeZlQw0RCa9wm
+xioBxb1md2AplUQ2fG/KHnu2ZuxHA6MNYcwMJNgv
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIFazCCA1OgAwIBAgIUKU+ta2rnJE/79L2Uxg4vFoF0RxYwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA5MjYxNDUzMzlaFw0zNDA5
+MjQxNDUzMzlaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB
+AQUAA4ICDwAwggIKAoICAQDSOuYqxNsP4Gm/EDauon/SAVWj8PIKFB1OpuguHm13
+b2l2G6hRuNnAmR6ewP22H/YoyYz1qRchH2qw1uizwnnSS0OY74CsJhd0PV6f4XRR
+Y+6PotGDPu1fJJM4XI3HjWGdBkJSawZNWjP1dQRJPUHNRttSOrPsG3XT7TjfLjoK
+jOelTqgwHUGE2n0AtQP7ZFQVn7LLBrukve8zMgivwEL1JFSlKppWFf0SUgpmQVE6
+3jTAQPPrI/B5z5Ys1j2jv7mJt3/UATcTGmvPTNv94SUrO6nC3TJxKHtR30MALteo
+EgH/s2O0Ax44iDENgm9p6eb+GCyTWS/eHBAJ6SU76PRymiE57/0GOqyYewuEOuIU
+FYd6+gglCMe+ayfhI20njHP6RTiQpRjFy+DM8+bkcS89q0sfFSFHR5oFNbAgUgyI
+bGiaWb+DmUCwSFnS0HusSU2AECqzuwiyObD3rkoqBQMj+xl6SnJU6TTcB+WD/Z5G
+tqu1zTMXpo3VRts3AQSGUuSaqbeG/S+38LX+fbjeTLa6SEGJfB7/H2s64vCO/0hR
+M0KEXAaTyjx0PnNKYSlCIJyA9lYea21oByNc31tkUXQjmUQpSXYayrDwzR2JAXVY
+rFJLNu1Q6sZ0WqT9fj06oTas7g1g3gcl18tIapeael2jJohth0RizBxKYuLYc6Pv
+1QIDAQABo1MwUTAdBgNVHQ4EFgQUY1u7KerT8m01BvAg77PUaot2S0EwHwYDVR0j
+BBgwFoAUY1u7KerT8m01BvAg77PUaot2S0EwDwYDVR0TAQH/BAUwAwEB/zANBgkq
+hkiG9w0BAQsFAAOCAgEAB8BpmI3v2cNlXUjhf+rldaqDfq/IJ7Ri23kCBJW4VaoW
+c0UrtvLC+K5m61I1iWSUYEK85/bPw2K5dn1e8w3Q2J460Yvc7/ZT7mucZlXQxfl3
+V7yqqQI7OMsY6FochYUL3+c32WQg5jllsLPlHAHBJlagf3uEqmVrvSExHNBQOVyE
+/cs1i9DcTJF2A8JNPKilIObvRT103Qp2eFnW+EY9OUBb+TdQvPjxroLfK1SuOAe6
+bLPBxdgvA/0raHuXeDTNsNRICIU1X5eBfZwCXKe9lRVJpIsKTYeHDN/rEmfTtehB
+vz8/KkCWqwPDn/YFkNAdg3TRjqW4oW2wZ+XqbTlR2qA7szE7oMAfHxNkintxMnNm
+vD2/AAP6RUw16HZk0najFWPIG9gc+O1gSks6hwn9JilAPy8mn40H2D7cedU6Ew+T
+CQ02+dw2+2FLKYr1eiYPlIELsAu8kmbrjwvwy2sCf3L4fxLtPRqXFuXB2Uer9zvy
+tn+RK5hJkKo/YY37I9Y9x57rpCqUfFIeYWBub07x1620ujRkL1pJPxfRNBfyh42t
+beuk/XQGIvPcIkbPnmsb4gGaiRMuw+mZ7isJDoQwHUmfqL1EpOYb5mLYHkIqKaCz
+8t8wTdkimIVIFSxedy7cJCCWdQ/BCyTJoQpXD69PLPzxEi/YK9pB9S8qBtfefu4=
+-----END CERTIFICATE-----
diff --git a/dummy-chain-cert-rsa.pem.license b/dummy-chain-cert-rsa.pem.license
new file mode 100644
index 0000000..615ea2b
--- /dev/null
+++ b/dummy-chain-cert-rsa.pem.license
@@ -0,0 +1,3 @@
+// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
diff --git a/dummy-child-key-ecdsa.pem b/dummy-child-key-ecdsa.pem
new file mode 100644
index 0000000..d2a1185
--- /dev/null
+++ b/dummy-child-key-ecdsa.pem
@@ -0,0 +1,8 @@
+-----BEGIN EC PARAMETERS-----
+BggqhkjOPQMBBw==
+-----END EC PARAMETERS-----
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIG20X4lI7vocwK2W3TYOW+XtQv/bwRWYknvAf2OK8WFJoAoGCCqGSM49
+AwEHoUQDQgAEzJEINkYWg2DlrPiJPzSoGXcMZuAc2oMFaLuke97osF7EYkICX4Qc
+adNUtySOBPAIm6BX+Lj0Z0YdBxpfXFEfqQ==
+-----END EC PRIVATE KEY-----
\ No newline at end of file
diff --git a/dummy-child-key-ecdsa.pem.license b/dummy-child-key-ecdsa.pem.license
new file mode 100644
index 0000000..615ea2b
--- /dev/null
+++ b/dummy-child-key-ecdsa.pem.license
@@ -0,0 +1,3 @@
+// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
diff --git a/dummy-child-key-rsa.pem b/dummy-child-key-rsa.pem
new file mode 100644
index 0000000..2a8639b
--- /dev/null
+++ b/dummy-child-key-rsa.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+S++xGnGHEUSEl7cWnO8k0RyJzzc=
+-----END PRIVATE KEY-----
diff --git a/dummy-child-key-rsa.pem.license b/dummy-child-key-rsa.pem.license
new file mode 100644
index 0000000..615ea2b
--- /dev/null
+++ b/dummy-child-key-rsa.pem.license
@@ -0,0 +1,3 @@
+// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
diff --git a/encoding.go b/encoding.go
index 9ed5666..8c38cea 100644
--- a/encoding.go
+++ b/encoding.go
@@ -171,17 +171,20 @@ const (
// TypeTextPlain represents the MIME type for plain text content.
TypeTextPlain ContentType = "text/plain"
+
+ // typeSMimeSigned represents the MIME type for S/MIME singed messages.
+ typeSMimeSigned ContentType = `application/pkcs7-signature; name="smime.p7s"`
)
const (
// MIMEAlternative MIMEType represents a MIME multipart/alternative type, used for emails with multiple versions.
MIMEAlternative MIMEType = "alternative"
-
// MIMEMixed MIMEType represents a MIME multipart/mixed type used for emails containing different types of content.
MIMEMixed MIMEType = "mixed"
-
// MIMERelated MIMEType represents a MIME multipart/related type, used for emails with related content entities.
MIMERelated MIMEType = "related"
+ // MIMESMime MIMEType represents a MIME multipart/signed type, used for siging emails with S/MIME.
+ MIMESMime MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha-256`
)
// String satisfies the fmt.Stringer interface for the Charset type.
@@ -219,3 +222,8 @@ func (c ContentType) String() string {
func (e Encoding) String() string {
return string(e)
}
+
+// String is a standard method to convert an MIMEType into a printable format
+func (e MIMEType) String() string {
+ return string(e)
+}
diff --git a/encoding_test.go b/encoding_test.go
index 14711b7..4c7778a 100644
--- a/encoding_test.go
+++ b/encoding_test.go
@@ -61,6 +61,11 @@ func TestContentType_String(t *testing.T) {
"ContentType: application/pgp-encrypted", TypePGPEncrypted,
"application/pgp-encrypted",
},
+
+ {
+ "ContentType: pkcs7-signature", typeSMimeSigned,
+ `application/pkcs7-signature; name="smime.p7s"`,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -121,3 +126,24 @@ func TestCharset_String(t *testing.T) {
})
}
}
+
+// TestContentType_String tests the mime type method of the MIMEType object
+func TestMimeType_String(t *testing.T) {
+ tests := []struct {
+ mt MIMEType
+ want string
+ }{
+ {MIMEAlternative, "alternative"},
+ {MIMEMixed, "mixed"},
+ {MIMERelated, "related"},
+ {MIMESMime, `signed; protocol="application/pkcs7-signature"; micalg=sha-256`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.mt.String(), func(t *testing.T) {
+ if tt.mt.String() != tt.want {
+ t.Errorf("wrong string for mime type returned. Expected: %s, got: %s",
+ tt.want, tt.mt.String())
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index 4fb64a8..84fc4bd 100644
--- a/go.mod
+++ b/go.mod
@@ -9,4 +9,4 @@ go 1.16
require (
golang.org/x/crypto v0.28.0
golang.org/x/text v0.19.0
-)
+)
\ No newline at end of file
diff --git a/go.sum b/go.sum
index 8e6bffc..2eb65e0 100644
--- a/go.sum
+++ b/go.sum
@@ -63,4 +63,4 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
\ No newline at end of file
diff --git a/msg.go b/msg.go
index 61feda1..7f5bbd9 100644
--- a/msg.go
+++ b/msg.go
@@ -7,6 +7,10 @@ package mail
import (
"bytes"
"context"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
"embed"
"errors"
"fmt"
@@ -24,7 +28,7 @@ import (
)
var (
- // ErrNoFromAddress indicates that the FROM address is not set, which is required.
+ // ErrNoFromAddress should be used when a FROM address is requested but not set
ErrNoFromAddress = errors.New("no FROM address set")
// ErrNoRcptAddresses indicates that no recipient addresses have been set.
@@ -144,6 +148,9 @@ type Msg struct {
//
// This can be useful in scenarios where headers are conditionally passed based on receipt - i. e. SMTP proxies.
noDefaultUserAgent bool
+
+ // SMime represents a middleware used to sign messages with S/MIME
+ sMime *SMime
}
// SendmailPath is the default system path to the sendmail binary - at least on standard Unix-like OS.
@@ -333,8 +340,50 @@ func WithNoDefaultUserAgent() MsgOption {
}
}
-// SetCharset sets or overrides the currently set encoding charset of the Msg.
-//
+// SignWithSMimeRSA configures the Msg to be signed with S/MIME
+func (m *Msg) SignWithSMimeRSA(privateKey *rsa.PrivateKey, certificate *x509.Certificate, intermediateCertificate *x509.Certificate) error {
+ sMime, err := newSMimeWithRSA(privateKey, certificate, intermediateCertificate)
+ if err != nil {
+ return err
+ }
+ m.sMime = sMime
+ return nil
+}
+
+// SignWithSMimeECDSA configures the Msg to be signed with S/MIME
+func (m *Msg) SignWithSMimeECDSA(privateKey *ecdsa.PrivateKey, certificate *x509.Certificate, intermediateCertificate *x509.Certificate) error {
+ sMime, err := newSMimeWithECDSA(privateKey, certificate, intermediateCertificate)
+ if err != nil {
+ return err
+ }
+ m.sMime = sMime
+ return nil
+}
+
+// SignWithTLSCertificate signs the Msg with the provided *tls.certificate.
+func (m *Msg) SignWithTLSCertificate(keyPairTlS *tls.Certificate) error {
+ intermediateCertificate, err := x509.ParseCertificate(keyPairTlS.Certificate[1])
+ if err != nil {
+ return fmt.Errorf("failed to parse intermediate certificate: %w", err)
+ }
+
+ switch keyPairTlS.PrivateKey.(type) {
+ case *rsa.PrivateKey:
+ if intermediateCertificate == nil {
+ return m.SignWithSMimeRSA(keyPairTlS.PrivateKey.(*rsa.PrivateKey), keyPairTlS.Leaf, nil)
+ }
+ return m.SignWithSMimeRSA(keyPairTlS.PrivateKey.(*rsa.PrivateKey), keyPairTlS.Leaf, intermediateCertificate)
+
+ case *ecdsa.PrivateKey:
+ if intermediateCertificate == nil {
+ return m.SignWithSMimeECDSA(keyPairTlS.PrivateKey.(*ecdsa.PrivateKey), keyPairTlS.Leaf, nil)
+ }
+ return m.SignWithSMimeECDSA(keyPairTlS.PrivateKey.(*ecdsa.PrivateKey), keyPairTlS.Leaf, intermediateCertificate)
+ default:
+ return fmt.Errorf("unsupported private key type: %T", keyPairTlS.PrivateKey)
+ }
+}
+
// This method allows you to specify a character set for the email message. The charset is
// important for ensuring that the content of the message is correctly interpreted by
// mail clients. Common charset values include UTF-8, ISO-8859-1, and others. If a charset
@@ -2115,6 +2164,38 @@ func (m *Msg) applyMiddlewares(msg *Msg) *Msg {
return msg
}
+// signMessage sign the Msg with S/MIME
+func (m *Msg) signMessage(msg *Msg) (*Msg, error) {
+ signedPart := msg.GetParts()[0]
+ body, err := signedPart.GetContent()
+ if err != nil {
+ return nil, err
+ }
+
+ signaturePart, err := m.createSignaturePart(signedPart.GetEncoding(), signedPart.GetContentType(), signedPart.GetCharset(), body)
+ if err != nil {
+ return nil, err
+ }
+
+ m.parts = append(m.parts, signaturePart)
+
+ return m, err
+}
+
+// createSignaturePart creates an additional part that be used for storing the S/MIME signature of the given body
+func (m *Msg) createSignaturePart(encoding Encoding, contentType ContentType, charSet Charset, body []byte) (*Part, error) {
+ message := m.sMime.prepareMessage(encoding, contentType, charSet, body)
+ signedMessage, err := m.sMime.signMessage(message)
+ if err != nil {
+ return nil, err
+ }
+
+ signaturePart := m.newPart(typeSMimeSigned, WithPartEncoding(EncodingB64), WithSMimeSinging())
+ signaturePart.SetContent(*signedMessage)
+
+ return signaturePart, nil
+}
+
// WriteTo writes the formatted Msg into the given io.Writer and satisfies the io.WriterTo interface.
//
// This method writes the email message, including its headers, body, and attachments, to the provided
@@ -2132,7 +2213,18 @@ func (m *Msg) applyMiddlewares(msg *Msg) *Msg {
// - https://datatracker.ietf.org/doc/html/rfc5322
func (m *Msg) WriteTo(writer io.Writer) (int64, error) {
mw := &msgWriter{writer: writer, charset: m.charset, encoder: m.encoder}
- mw.writeMsg(m.applyMiddlewares(m))
+ msg := m.applyMiddlewares(m)
+
+ if m.hasSMime() {
+ signedMsg, err := m.signMessage(msg)
+ if err != nil {
+ return 0, err
+ }
+ msg = signedMsg
+ }
+
+ mw.writeMsg(msg)
+
return mw.bytesWritten, mw.err
}
@@ -2496,7 +2588,7 @@ func (m *Msg) hasAlt() bool {
count++
}
}
- return count > 1 && m.pgptype == 0
+ return count > 1 && m.pgptype == 0 && !m.hasSMime()
}
// hasMixed returns true if the Msg has mixed parts.
@@ -2514,6 +2606,11 @@ func (m *Msg) hasMixed() bool {
return m.pgptype == 0 && ((len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1)
}
+// hasSMime returns true if the Msg has should be signed with S/MIME
+func (m *Msg) hasSMime() bool {
+ return m.sMime != nil
+}
+
// hasRelated returns true if the Msg has related parts.
//
// This method checks whether the message contains related parts, such as inline embedded files
diff --git a/msg_test.go b/msg_test.go
index 7fcbd99..aa46f94 100644
--- a/msg_test.go
+++ b/msg_test.go
@@ -1907,6 +1907,39 @@ func TestMsg_hasAlt(t *testing.T) {
}
}
+// TestMsg_hasAlt tests the hasAlt() method of the Msg with active S/MIME
+func TestMsg_hasAltWithSMime(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ m := NewMsg()
+ m.SetBodyString(TypeTextPlain, "Plain")
+ m.AddAlternativeString(TypeTextHTML, "HTML")
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to init smime configuration")
+ }
+ if m.hasAlt() {
+ t.Errorf("mail has alternative parts and S/MIME is active, but hasAlt() returned true")
+ }
+}
+
+// TestMsg_hasSMime tests the hasSMime() method of the Msg
+func TestMsg_hasSMime(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ m := NewMsg()
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to init smime configuration")
+ }
+ m.SetBodyString(TypeTextPlain, "Plain")
+ if !m.hasSMime() {
+ t.Errorf("mail has smime configured but hasSMime() returned true")
+ }
+}
+
// TestMsg_hasRelated tests the hasRelated() method of the Msg
func TestMsg_hasRelated(t *testing.T) {
m := NewMsg()
@@ -1974,6 +2007,70 @@ func TestMsg_WriteToSkipMiddleware(t *testing.T) {
}
}
+// TestMsg_WriteToWithSMIME tests the WriteTo() method of the Msg
+func TestMsg_WriteToWithSMIME(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+
+ m := NewMsg()
+ m.Subject("This is a test")
+ m.SetBodyString(TypeTextPlain, "Plain")
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to init smime configuration")
+ }
+
+ wbuf := bytes.Buffer{}
+ if _, err = m.WriteTo(&wbuf); err != nil {
+ t.Errorf("WriteTo() failed: %s", err)
+ }
+
+ result := wbuf.String()
+ boundary := result[strings.LastIndex(result, "--")-60 : strings.LastIndex(result, "--")]
+ if strings.Count(result, boundary) != 4 {
+ t.Errorf("WriteTo() failed. False number of boundaries found")
+ }
+
+ parts := strings.Split(result, fmt.Sprintf("--%s", boundary))
+ if len(parts) != 4 {
+ t.Errorf("WriteTo() failed. False number of parts found")
+ }
+
+ preamble := parts[0]
+ if !strings.Contains(preamble, "MIME-Version: 1.0") {
+ t.Errorf("WriteTo() failed. Unable to find MIME-Version")
+ }
+ if !strings.Contains(preamble, "Subject: This is a test") {
+ t.Errorf("WriteTo() failed. Unable to find subject")
+ }
+ if !strings.Contains(preamble, fmt.Sprintf("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256;\r\n boundary=%s", boundary)) {
+ t.Errorf("WriteTo() failed. Unable to find Content-Type")
+ }
+
+ signedData := parts[1]
+ if !strings.Contains(signedData, "Content-Transfer-Encoding: quoted-printable") {
+ t.Errorf("WriteTo() failed. Unable to find Content-Transfer-Encoding")
+ }
+ if !strings.Contains(signedData, "Content-Type: text/plain; charset=UTF-8") {
+ t.Errorf("WriteTo() failed. Unable to find Content-Type")
+ }
+ if !strings.Contains(signedData, "Plain") {
+ t.Errorf("WriteTo() failed. Unable to find Content")
+ }
+
+ signature := parts[2]
+ if !strings.Contains(signature, "Content-Transfer-Encoding: base64") {
+ t.Errorf("WriteTo() failed. Unable to find Content-Transfer-Encoding")
+ }
+ if !strings.Contains(signature, `application/pkcs7-signature; name="smime.p7s"`) {
+ t.Errorf("WriteTo() failed. Unable to find Content-Type")
+ }
+ if strings.Contains(signature, "Plain") {
+ t.Errorf("WriteTo() failed. Unable to find signature")
+ }
+}
+
// TestMsg_WriteTo_fails tests the WriteTo() method of the Msg but with a failing body writer function
func TestMsg_WriteTo_fails(t *testing.T) {
m := NewMsg()
@@ -3246,6 +3343,66 @@ func TestNewMsgWithNoDefaultUserAgent(t *testing.T) {
}
}
+// TestSignWithSMime_ValidRSAKeyPair tests WithSMimeSinging with given rsa key pair
+func TestSignWithSMime_ValidRSAKeyPair(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ m := NewMsg()
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to set sMime. Cause: %v", err)
+ }
+ if m.sMime.privateKey.rsa == nil {
+ t.Errorf("WithSMimeSinging() - no private key is given")
+ }
+ if m.sMime.certificate == nil {
+ t.Errorf("WithSMimeSinging() - no certificate is given")
+ }
+}
+
+// TestSignWithSMime_ValidRSAKeyPair tests WithSMimeSinging with given ecdsa key pair
+func TestSignWithSMime_ValidECDSAKeyPair(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyECDSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ m := NewMsg()
+ if err := m.SignWithSMimeECDSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to set sMime. Cause: %v", err)
+ }
+ if m.sMime.privateKey.ecdsa == nil {
+ t.Errorf("WithSMimeSinging() - no private key is given")
+ }
+ if m.sMime.certificate == nil {
+ t.Errorf("WithSMimeSinging() - no certificate is given")
+ }
+}
+
+// TestSignWithSMime_InvalidPrivateKey tests WithSMimeSinging with given invalid private key
+func TestSignWithSMime_InvalidPrivateKey(t *testing.T) {
+ m := NewMsg()
+
+ err := m.SignWithSMimeRSA(nil, nil, nil)
+ if !errors.Is(err, ErrInvalidPrivateKey) {
+ t.Errorf("failed to pre-check SignWithSMime method values correctly: %s", err)
+ }
+}
+
+// TestSignWithSMime_InvalidCertificate tests WithSMimeSinging with given invalid certificate
+func TestSignWithSMime_InvalidCertificate(t *testing.T) {
+ privateKey, _, _, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ m := NewMsg()
+
+ err = m.SignWithSMimeRSA(privateKey, nil, nil)
+ if !errors.Is(err, ErrInvalidCertificate) {
+ t.Errorf("failed to pre-check SignWithSMime method values correctly: %s", err)
+ }
+}
+
// Fuzzing tests
func FuzzMsg_Subject(f *testing.F) {
f.Add("Testsubject")
@@ -3273,3 +3430,84 @@ func FuzzMsg_From(f *testing.F) {
m.Reset()
})
}
+
+// TestMsg_createSignaturePart tests the Msg.createSignaturePart method
+func TestMsg_createSignaturePart(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ m := NewMsg()
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to init smime configuration")
+ }
+ body := []byte("This is the body")
+ part, err := m.createSignaturePart(EncodingQP, TypeTextPlain, CharsetUTF7, body)
+ if err != nil {
+ t.Errorf("createSignaturePart() method failed.")
+ }
+
+ if part.GetEncoding() != EncodingB64 {
+ t.Errorf("createSignaturePart() method failed. Expected encoding: %s, got: %s", EncodingB64, part.GetEncoding())
+ }
+ if part.GetContentType() != typeSMimeSigned {
+ t.Errorf("createSignaturePart() method failed. Expected content type: %s, got: %s", typeSMimeSigned, part.GetContentType())
+ }
+ if part.GetCharset() != CharsetUTF8 {
+ t.Errorf("createSignaturePart() method failed. Expected charset: %s, got: %s", CharsetUTF8, part.GetCharset())
+ }
+ if content, err := part.GetContent(); err != nil || len(content) == len(body) {
+ t.Errorf("createSignaturePart() method failed. Expected content should not be equal: %s, got: %s", body, part.GetEncoding())
+ }
+}
+
+// TestMsg_signMessage tests the Msg.signMessage method
+func TestMsg_signMessage(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+ body := []byte("This is the body")
+ m := NewMsg()
+ m.SetBodyString(TypeTextPlain, string(body))
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to init smime configuration")
+ }
+ msg, err := m.signMessage(m)
+ if err != nil {
+ t.Errorf("createSignaturePart() method failed.")
+ }
+
+ parts := msg.GetParts()
+ if len(parts) != 2 {
+ t.Errorf("createSignaturePart() method failed. Expected 2 parts, got: %d", len(parts))
+ }
+
+ signedPart := parts[0]
+ if signedPart.GetEncoding() != EncodingQP {
+ t.Errorf("createSignaturePart() method failed. Expected encoding: %s, got: %s", EncodingB64, signedPart.GetEncoding())
+ }
+ if signedPart.GetContentType() != TypeTextPlain {
+ t.Errorf("createSignaturePart() method failed. Expected content type: %s, got: %s", typeSMimeSigned, signedPart.GetContentType())
+ }
+ if signedPart.GetCharset() != CharsetUTF8 {
+ t.Errorf("createSignaturePart() method failed. Expected charset: %s, got: %s", CharsetUTF8, signedPart.GetCharset())
+ }
+ if content, err := signedPart.GetContent(); err != nil || len(content) != len(body) {
+ t.Errorf("createSignaturePart() method failed. Expected content should be equal: %s, got: %s", body, content)
+ }
+
+ signaturePart := parts[1]
+ if signaturePart.GetEncoding() != EncodingB64 {
+ t.Errorf("createSignaturePart() method failed. Expected encoding: %s, got: %s", EncodingB64, signaturePart.GetEncoding())
+ }
+ if signaturePart.GetContentType() != typeSMimeSigned {
+ t.Errorf("createSignaturePart() method failed. Expected content type: %s, got: %s", typeSMimeSigned, signaturePart.GetContentType())
+ }
+ if signaturePart.GetCharset() != CharsetUTF8 {
+ t.Errorf("createSignaturePart() method failed. Expected charset: %s, got: %s", CharsetUTF8, signaturePart.GetCharset())
+ }
+ if content, err := signaturePart.GetContent(); err != nil || len(content) == len(body) {
+ t.Errorf("createSignaturePart() method failed. Expected content should not be equal: %s, got: %s", body, signaturePart.GetEncoding())
+ }
+}
diff --git a/msgwriter.go b/msgwriter.go
index ed2b41e..331138c 100644
--- a/msgwriter.go
+++ b/msgwriter.go
@@ -128,6 +128,10 @@ func (mw *msgWriter) writeMsg(msg *Msg) {
}
}
+ if msg.hasSMime() {
+ mw.startMP(MIMESMime, msg.boundary)
+ mw.writeString(DoubleNewLine)
+ }
if msg.hasMixed() {
mw.startMP(MIMEMixed, msg.boundary)
mw.writeString(DoubleNewLine)
@@ -174,6 +178,10 @@ func (mw *msgWriter) writeMsg(msg *Msg) {
if msg.hasMixed() {
mw.stopMP()
}
+
+ if msg.hasSMime() {
+ mw.stopMP()
+ }
}
// writeGenHeader writes out all generic headers to the msgWriter.
@@ -314,7 +322,7 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
}
if mw.err == nil {
- mw.writeBody(file.Writer, encoding)
+ mw.writeBody(file.Writer, encoding, false)
}
}
}
@@ -347,7 +355,12 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) {
if partCharset.String() == "" {
partCharset = charset
}
- contentType := fmt.Sprintf("%s; charset=%s", part.contentType, partCharset)
+
+ contentType := part.contentType.String()
+ if !part.IsSMimeSigned() {
+ contentType = strings.Join([]string{contentType, "; charset=", partCharset.String()}, "")
+ }
+
contentTransferEnc := part.encoding.String()
if mw.depth == 0 {
mw.writeHeader(HeaderContentType, contentType)
@@ -363,7 +376,7 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) {
mimeHeader.Add(string(HeaderContentTransferEnc), contentTransferEnc)
mw.newPart(mimeHeader)
}
- mw.writeBody(part.writeFunc, part.encoding)
+ mw.writeBody(part.writeFunc, part.encoding, part.smime)
}
// writeString writes a string into the msgWriter's io.Writer interface.
@@ -438,7 +451,8 @@ func (mw *msgWriter) writeHeader(key Header, values ...string) {
// Parameters:
// - writeFunc: A function that writes the body content to the given io.Writer.
// - encoding: The encoding type to use when writing the content (e.g., base64, quoted-printable).
-func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encoding Encoding) {
+// - singingWithSMime: Whether the msg should be signed with S/MIME or not.
+func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encoding Encoding, singingWithSMime bool) {
var writer io.Writer
var encodedWriter io.WriteCloser
var n int64
@@ -453,12 +467,11 @@ func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encodin
lineBreaker := Base64LineBreaker{}
lineBreaker.out = &writeBuffer
- switch encoding {
- case EncodingQP:
+ if encoding == EncodingQP {
encodedWriter = quotedprintable.NewWriter(&writeBuffer)
- case EncodingB64:
+ } else if encoding == EncodingB64 && !singingWithSMime {
encodedWriter = base64.NewEncoder(base64.StdEncoding, &lineBreaker)
- case NoEncoding:
+ } else if encoding == NoEncoding || singingWithSMime {
_, err = writeFunc(&writeBuffer)
if err != nil {
mw.err = fmt.Errorf("bodyWriter function: %w", err)
@@ -471,7 +484,7 @@ func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encodin
mw.bytesWritten += n
}
return
- default:
+ } else {
encodedWriter = quotedprintable.NewWriter(writer)
}
diff --git a/msgwriter_test.go b/msgwriter_test.go
index a41e5d3..0379cd7 100644
--- a/msgwriter_test.go
+++ b/msgwriter_test.go
@@ -154,3 +154,42 @@ func TestMsgWriter_writeMsg_PGP(t *testing.T) {
t.Errorf("writeMsg failed. Expected PGP encoding header but didn't find it in message output")
}
}
+
+// TestMsgWriter_writeMsg_SMime tests the writeMsg method of the msgWriter with S/MIME types set
+func TestMsgWriter_writeMsg_SMime(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("failed to laod dummy crypto material. Cause: %v", err)
+ }
+
+ m := NewMsg()
+ if err := m.SignWithSMimeRSA(privateKey, certificate, intermediateCertificate); err != nil {
+ t.Errorf("failed to init smime configuration")
+ }
+ _ = m.From(`"Toni Tester" `)
+ _ = m.To(`"Toni Receiver" `)
+ m.Subject("This is a subject")
+ m.SetBodyString(TypeTextPlain, "This is the body")
+ buf := bytes.Buffer{}
+ mw := &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding}
+ mw.writeMsg(m)
+ ms := buf.String()
+
+ if !strings.Contains(ms, "MIME-Version: 1.0") {
+ t.Errorf("writeMsg failed. Unable to find MIME-Version")
+ }
+ if !strings.Contains(ms, "Subject: This is a subject") {
+ t.Errorf("writeMsg failed. Unable to find subject")
+ }
+ if !strings.Contains(ms, "From: \"Toni Tester\" ") {
+ t.Errorf("writeMsg failed. Unable to find transmitter")
+ }
+ if !strings.Contains(ms, "To: \"Toni Receiver\" ") {
+ t.Errorf("writeMsg failed. Unable to find receiver")
+ }
+
+ boundary := ms[strings.LastIndex(ms, "--")-60 : strings.LastIndex(ms, "--")]
+ if !strings.Contains(ms, fmt.Sprintf("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256;\r\n boundary=%s", boundary)) {
+ t.Errorf("writeMsg failed. Unable to find Content-Type")
+ }
+}
diff --git a/part.go b/part.go
index 5be8059..3fab652 100644
--- a/part.go
+++ b/part.go
@@ -24,6 +24,7 @@ type Part struct {
encoding Encoding
isDeleted bool
writeFunc func(io.Writer) (int64, error)
+ smime bool
}
// GetContent executes the WriteFunc of the Part and returns the content as a byte slice.
@@ -94,6 +95,11 @@ func (p *Part) GetDescription() string {
return p.description
}
+// IsSMimeSigned returns true if the Part should be singed with S/MIME
+func (p *Part) IsSMimeSigned() bool {
+ return p.smime
+}
+
// SetContent overrides the content of the Part with the given string.
//
// This function sets the content of the Part by creating a new writeFunc that writes the
@@ -146,6 +152,11 @@ func (p *Part) SetDescription(description string) {
p.description = description
}
+// SetIsSMimeSigned sets the flag for signing the Part with S/MIME
+func (p *Part) SetIsSMimeSigned(smime bool) {
+ p.smime = smime
+}
+
// SetWriteFunc overrides the WriteFunc of the Part.
//
// This function sets a new WriteFunc for the Part, replacing the existing one. The WriteFunc
@@ -213,3 +224,10 @@ func WithPartContentDescription(description string) PartOption {
p.description = description
}
}
+
+// WithSMimeSinging overrides the flag for signing the Part with S/MIME
+func WithSMimeSinging() PartOption {
+ return func(p *Part) {
+ p.smime = true
+ }
+}
diff --git a/part_test.go b/part_test.go
index a196986..c1a8ac7 100644
--- a/part_test.go
+++ b/part_test.go
@@ -102,6 +102,24 @@ func TestPart_WithPartContentDescription(t *testing.T) {
}
}
+// TestPart_WithSMimeSinging tests the WithSMimeSinging method
+func TestPart_WithSMimeSinging(t *testing.T) {
+ m := NewMsg()
+ part := m.newPart(TypeTextPlain, WithSMimeSinging())
+ if part == nil {
+ t.Errorf("newPart() WithSMimeSinging() failed: no part returned")
+ return
+ }
+ if part.smime != true {
+ t.Errorf("newPart() WithSMimeSinging() failed: expected: %v, got: %v", true, part.smime)
+ }
+ part.smime = true
+ part.SetIsSMimeSigned(false)
+ if part.smime != false {
+ t.Errorf("newPart() SetIsSMimeSigned() failed: expected: %v, got: %v", false, part.smime)
+ }
+}
+
// TestPartContentType tests Part.SetContentType
func TestPart_SetContentType(t *testing.T) {
tests := []struct {
@@ -245,6 +263,33 @@ func TestPart_GetContentBroken(t *testing.T) {
}
}
+// TestPart_IsSMimeSigned tests Part.IsSMimeSigned
+func TestPart_IsSMimeSigned(t *testing.T) {
+ tests := []struct {
+ name string
+ want bool
+ }{
+ {"smime:", true},
+ {"smime:", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := NewMsg()
+ m.SetBodyString(TypeTextPlain, "This is a body!")
+ pl, err := getPartList(m)
+ if err != nil {
+ t.Errorf("failed: %s", err)
+ return
+ }
+ pl[0].SetIsSMimeSigned(tt.want)
+ smime := pl[0].IsSMimeSigned()
+ if smime != tt.want {
+ t.Errorf("SetContentType failed. Got: %v, expected: %v", smime, tt.want)
+ }
+ })
+ }
+}
+
// TestPart_SetWriteFunc tests Part.SetWriteFunc
func TestPart_SetWriteFunc(t *testing.T) {
c := "This is a test with ümläutß"
diff --git a/pkcs7.go b/pkcs7.go
new file mode 100644
index 0000000..70c2c24
--- /dev/null
+++ b/pkcs7.go
@@ -0,0 +1,388 @@
+// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "bytes"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "errors"
+ "fmt"
+ "math/big"
+ "sort"
+ "time"
+
+ _ "crypto/sha1" // for crypto.SHA1
+)
+
+// PKCS7 Represents a PKCS7 structure
+type PKCS7 struct {
+ Content []byte
+ Certificates []*x509.Certificate
+ CRLs []x509.RevocationList
+ Signers []signerInfo
+ raw interface{}
+}
+
+type contentInfo struct {
+ ContentType asn1.ObjectIdentifier
+ Content asn1.RawValue `asn1:"explicit,optional,tag:0"`
+}
+
+var (
+ oidData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}
+ oidSignedData = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
+ oidAttributeContentType = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3}
+ oidAttributeMessageDigest = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4}
+ oidAttributeSigningTime = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 5}
+)
+
+type signedData struct {
+ Version int `asn1:"default:1"`
+ DigestAlgorithmIdentifiers []pkix.AlgorithmIdentifier `asn1:"set"`
+ ContentInfo contentInfo
+ Certificates rawCertificates `asn1:"optional,tag:0"`
+ CRLs []x509.RevocationList `asn1:"optional,tag:1"`
+ SignerInfos []signerInfo `asn1:"set"`
+}
+
+type rawCertificates struct {
+ Raw asn1.RawContent
+}
+
+type attribute struct {
+ Type asn1.ObjectIdentifier
+ Value asn1.RawValue `asn1:"set"`
+}
+
+type issuerAndSerial struct {
+ IssuerName asn1.RawValue
+ SerialNumber *big.Int
+}
+
+// MessageDigestMismatchError is returned when the signer data digest does not
+// match the computed digest for the contained content
+type MessageDigestMismatchError struct {
+ ExpectedDigest []byte
+ ActualDigest []byte
+}
+
+func (err *MessageDigestMismatchError) Error() string {
+ return fmt.Sprintf("pkcs7: Message digest mismatch\n\tExpected: %X\n\tActual : %X", err.ExpectedDigest, err.ActualDigest)
+}
+
+type signerInfo struct {
+ Version int `asn1:"default:1"`
+ IssuerAndSerialNumber issuerAndSerial
+ DigestAlgorithm pkix.AlgorithmIdentifier
+ AuthenticatedAttributes []attribute `asn1:"optional,tag:0"`
+ DigestEncryptionAlgorithm pkix.AlgorithmIdentifier
+ EncryptedDigest []byte
+ UnauthenticatedAttributes []attribute `asn1:"optional,tag:1"`
+}
+
+func (raw rawCertificates) Parse() ([]*x509.Certificate, error) {
+ if len(raw.Raw) == 0 {
+ return nil, nil
+ }
+
+ var val asn1.RawValue
+ if _, err := asn1.Unmarshal(raw.Raw, &val); err != nil {
+ return nil, err
+ }
+
+ return x509.ParseCertificates(val.Bytes)
+}
+
+func marshalAttributes(attrs []attribute) ([]byte, error) {
+ encodedAttributes, err := asn1.Marshal(struct {
+ A []attribute `asn1:"set"`
+ }{A: attrs})
+ if err != nil {
+ return nil, err
+ }
+
+ // Remove the leading sequence octets
+ var raw asn1.RawValue
+ if _, err := asn1.Unmarshal(encodedAttributes, &raw); err != nil {
+ return nil, err
+ }
+ return raw.Bytes, nil
+}
+
+var (
+ oidDigestAlgorithmSHA1 = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26}
+ oidSignatureSHA1WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 5}
+)
+
+func getCertFromCertsByIssuerAndSerial(certs []*x509.Certificate, ias issuerAndSerial) *x509.Certificate {
+ for _, cert := range certs {
+ if isCertMatchForIssuerAndSerial(cert, ias) {
+ return cert
+ }
+ }
+ return nil
+}
+
+// GetOnlySigner returns an x509.Certificate for the first signer of the signed
+// data payload. If there are more or less than one signer, nil is returned
+func (p7 *PKCS7) GetOnlySigner() *x509.Certificate {
+ if len(p7.Signers) != 1 {
+ return nil
+ }
+ signer := p7.Signers[0]
+ return getCertFromCertsByIssuerAndSerial(p7.Certificates, signer.IssuerAndSerialNumber)
+}
+
+// ErrUnsupportedAlgorithm tells you when our quick dev assumptions have failed
+var ErrUnsupportedAlgorithm = errors.New("pkcs7: cannot decrypt data: only RSA, DES, DES-EDE3, AES-256-CBC and AES-128-GCM supported")
+
+func isCertMatchForIssuerAndSerial(cert *x509.Certificate, ias issuerAndSerial) bool {
+ return cert.SerialNumber.Cmp(ias.SerialNumber) == 0 && bytes.Equal(cert.RawIssuer, ias.IssuerName.FullBytes)
+}
+
+func unmarshalAttribute(attrs []attribute, attributeType asn1.ObjectIdentifier, out interface{}) error {
+ for _, attr := range attrs {
+ if attr.Type.Equal(attributeType) {
+ _, err := asn1.Unmarshal(attr.Value.Bytes, out)
+ return err
+ }
+ }
+ return errors.New("pkcs7: attribute type not in attributes")
+}
+
+// UnmarshalSignedAttribute decodes a single attribute from the signer info
+func (p7 *PKCS7) UnmarshalSignedAttribute(attributeType asn1.ObjectIdentifier, out interface{}) error {
+ sd, ok := p7.raw.(signedData)
+ if !ok {
+ return errors.New("pkcs7: payload is not signedData content")
+ }
+ if len(sd.SignerInfos) < 1 {
+ return errors.New("pkcs7: payload has no signers")
+ }
+ attributes := sd.SignerInfos[0].AuthenticatedAttributes
+ return unmarshalAttribute(attributes, attributeType, out)
+}
+
+// SignedData is an opaque data structure for creating signed data payloads
+type SignedData struct {
+ sd signedData
+ certs []*x509.Certificate
+ messageDigest []byte
+}
+
+// Attribute represents a key value pair attribute. Value must be marshalable byte
+// `encoding/asn1`
+type Attribute struct {
+ Type asn1.ObjectIdentifier
+ Value interface{}
+}
+
+// SignerInfoConfig are optional values to include when adding a signer
+type SignerInfoConfig struct {
+ ExtraSignedAttributes []Attribute
+}
+
+// newSignedData initializes a SignedData with content
+func newSignedData(data []byte) (*SignedData, error) {
+ content, err := asn1.Marshal(data)
+ if err != nil {
+ return nil, err
+ }
+ ci := contentInfo{
+ ContentType: oidData,
+ Content: asn1.RawValue{Class: 2, Tag: 0, Bytes: content, IsCompound: true},
+ }
+ digAlg := pkix.AlgorithmIdentifier{
+ Algorithm: oidDigestAlgorithmSHA1,
+ }
+ h := crypto.SHA1.New()
+ h.Write(data)
+ md := h.Sum(nil)
+ sd := signedData{
+ ContentInfo: ci,
+ Version: 1,
+ DigestAlgorithmIdentifiers: []pkix.AlgorithmIdentifier{digAlg},
+ }
+ return &SignedData{sd: sd, messageDigest: md}, nil
+}
+
+type attributes struct {
+ types []asn1.ObjectIdentifier
+ values []interface{}
+}
+
+// Add adds the attribute, maintaining insertion order
+func (attrs *attributes) Add(attrType asn1.ObjectIdentifier, value interface{}) {
+ attrs.types = append(attrs.types, attrType)
+ attrs.values = append(attrs.values, value)
+}
+
+type sortableAttribute struct {
+ SortKey []byte
+ Attribute attribute
+}
+
+type attributeSet []sortableAttribute
+
+func (sa attributeSet) Len() int {
+ return len(sa)
+}
+
+func (sa attributeSet) Less(i, j int) bool {
+ return bytes.Compare(sa[i].SortKey, sa[j].SortKey) < 0
+}
+
+func (sa attributeSet) Swap(i, j int) {
+ sa[i], sa[j] = sa[j], sa[i]
+}
+
+func (sa attributeSet) attributes() []attribute {
+ attrs := make([]attribute, len(sa))
+ for i, attr := range sa {
+ attrs[i] = attr.Attribute
+ }
+ return attrs
+}
+
+func (attrs *attributes) forMarshaling() ([]attribute, error) {
+ sortables := make(attributeSet, len(attrs.types))
+ for i := range sortables {
+ attrType := attrs.types[i]
+ attrValue := attrs.values[i]
+ asn1Value, err := asn1.Marshal(attrValue)
+ if err != nil {
+ return nil, err
+ }
+ attr := attribute{
+ Type: attrType,
+ Value: asn1.RawValue{Tag: 17, IsCompound: true, Bytes: asn1Value}, // 17 == SET tag
+ }
+ encoded, err := asn1.Marshal(attr)
+ if err != nil {
+ return nil, err
+ }
+ sortables[i] = sortableAttribute{
+ SortKey: encoded,
+ Attribute: attr,
+ }
+ }
+ sort.Sort(sortables)
+ return sortables.attributes(), nil
+}
+
+// addSigner signs attributes about the content and adds certificate to payload
+func (sd *SignedData) addSigner(cert *x509.Certificate, pkey crypto.PrivateKey, config SignerInfoConfig) error {
+ attrs := &attributes{}
+ attrs.Add(oidAttributeContentType, sd.sd.ContentInfo.ContentType)
+ attrs.Add(oidAttributeMessageDigest, sd.messageDigest)
+ attrs.Add(oidAttributeSigningTime, time.Now())
+ for _, attr := range config.ExtraSignedAttributes {
+ attrs.Add(attr.Type, attr.Value)
+ }
+ finalAttrs, err := attrs.forMarshaling()
+ if err != nil {
+ return err
+ }
+ signature, err := signAttributes(finalAttrs, pkey, crypto.SHA1)
+ if err != nil {
+ return err
+ }
+
+ ias, err := cert2issuerAndSerial(cert)
+ if err != nil {
+ return err
+ }
+
+ signer := signerInfo{
+ AuthenticatedAttributes: finalAttrs,
+ DigestAlgorithm: pkix.AlgorithmIdentifier{Algorithm: oidDigestAlgorithmSHA1},
+ DigestEncryptionAlgorithm: pkix.AlgorithmIdentifier{Algorithm: oidSignatureSHA1WithRSA},
+ IssuerAndSerialNumber: ias,
+ EncryptedDigest: signature,
+ Version: 1,
+ }
+ // create signature of signed attributes
+ sd.certs = append(sd.certs, cert)
+ sd.sd.SignerInfos = append(sd.sd.SignerInfos, signer)
+ return nil
+}
+
+// addCertificate adds the certificate to the payload. Useful for parent certificates
+func (sd *SignedData) addCertificate(cert *x509.Certificate) {
+ sd.certs = append(sd.certs, cert)
+}
+
+// detach removes content from the signed data struct to make it a detached signature.
+// This must be called right before Finish()
+func (sd *SignedData) detach() {
+ sd.sd.ContentInfo = contentInfo{ContentType: oidData}
+}
+
+// finish marshals the content and its signers
+func (sd *SignedData) finish() ([]byte, error) {
+ sd.sd.Certificates = marshalCertificates(sd.certs)
+ inner, err := asn1.Marshal(sd.sd)
+ if err != nil {
+ return nil, err
+ }
+ outer := contentInfo{
+ ContentType: oidSignedData,
+ Content: asn1.RawValue{Class: 2, Tag: 0, Bytes: inner, IsCompound: true},
+ }
+ return asn1.Marshal(outer)
+}
+
+func cert2issuerAndSerial(cert *x509.Certificate) (issuerAndSerial, error) {
+ var ias issuerAndSerial
+ // The issuer RDNSequence has to match exactly the sequence in the certificate
+ // We cannot use cert.Issuer.ToRDNSequence() here since it mangles the sequence
+ ias.IssuerName = asn1.RawValue{FullBytes: cert.RawIssuer}
+ ias.SerialNumber = cert.SerialNumber
+
+ return ias, nil
+}
+
+// signs the DER encoded form of the attributes with the private key
+func signAttributes(attrs []attribute, pkey crypto.PrivateKey, hash crypto.Hash) ([]byte, error) {
+ attrBytes, err := marshalAttributes(attrs)
+ if err != nil {
+ return nil, err
+ }
+ h := hash.New()
+ h.Write(attrBytes)
+ hashed := h.Sum(nil)
+ switch priv := pkey.(type) {
+ case *rsa.PrivateKey:
+ return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA1, hashed)
+ }
+ return nil, ErrUnsupportedAlgorithm
+}
+
+// concats and wraps the certificates in the RawValue structure
+func marshalCertificates(certs []*x509.Certificate) rawCertificates {
+ var buf bytes.Buffer
+ for _, cert := range certs {
+ buf.Write(cert.Raw)
+ }
+ rawCerts, _ := marshalCertificateBytes(buf.Bytes())
+ return rawCerts
+}
+
+// Even though, the tag & length are stripped out during marshalling the
+// RawContent, we have to encode it into the RawContent. If its missing,
+// then `asn1.Marshal()` will strip out the certificate wrapper instead.
+func marshalCertificateBytes(certs []byte) (rawCertificates, error) {
+ val := asn1.RawValue{Bytes: certs, Class: 2, Tag: 0, IsCompound: true}
+ b, err := asn1.Marshal(val)
+ if err != nil {
+ return rawCertificates{}, err
+ }
+ return rawCertificates{Raw: b}, nil
+}
diff --git a/pkcs7_test.go b/pkcs7_test.go
new file mode 100644
index 0000000..e84f8c1
--- /dev/null
+++ b/pkcs7_test.go
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "os"
+ "testing"
+ "time"
+)
+
+// TestSign_E2E tests S/MIME singing as e2e
+func TestSign_E2E(t *testing.T) {
+ cert, err := createTestCertificate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ content := []byte("Hello World")
+ for _, testDetach := range []bool{false, true} {
+ toBeSigned, err := newSignedData(content)
+ if err != nil {
+ t.Fatalf("Cannot initialize signed data: %s", err)
+ }
+ if err := toBeSigned.addSigner(cert.Certificate, cert.PrivateKey, SignerInfoConfig{}); err != nil {
+ t.Fatalf("Cannot add signer: %s", err)
+ }
+ if testDetach {
+ t.Log("Testing detached signature")
+ toBeSigned.detach()
+ } else {
+ t.Log("Testing attached signature")
+ }
+ signed, err := toBeSigned.finish()
+ if err != nil {
+ t.Fatalf("Cannot finish signing data: %s", err)
+ }
+ if err := pem.Encode(os.Stdout, &pem.Block{Type: "PKCS7", Bytes: signed}); err != nil {
+ t.Fatalf("Cannot write signed data: %s", err)
+ }
+ }
+}
+
+type certKeyPair struct {
+ Certificate *x509.Certificate
+ PrivateKey *rsa.PrivateKey
+}
+
+func createTestCertificate() (*certKeyPair, error) {
+ signer, err := createTestCertificateByIssuer("Eddard Stark", nil)
+ if err != nil {
+ return nil, err
+ }
+ fmt.Println("Created root cert")
+ if err := pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: signer.Certificate.Raw}); err != nil {
+ return nil, err
+ }
+ pair, err := createTestCertificateByIssuer("Jon Snow", signer)
+ if err != nil {
+ return nil, err
+ }
+ fmt.Println("Created signer cert")
+ if err := pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate.Raw}); err != nil {
+ return nil, err
+ }
+ return pair, nil
+}
+
+func createTestCertificateByIssuer(name string, issuer *certKeyPair) (*certKeyPair, error) {
+ priv, err := rsa.GenerateKey(rand.Reader, 1024)
+ if err != nil {
+ return nil, err
+ }
+ serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 32)
+ serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+ if err != nil {
+ return nil, err
+ }
+
+ template := x509.Certificate{
+ SerialNumber: serialNumber,
+ SignatureAlgorithm: x509.SHA256WithRSA,
+ Subject: pkix.Name{
+ CommonName: name,
+ Organization: []string{"Acme Co"},
+ },
+ NotBefore: time.Now(),
+ NotAfter: time.Now().AddDate(1, 0, 0),
+ KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection},
+ }
+ var issuerCert *x509.Certificate
+ var issuerKey crypto.PrivateKey
+ if issuer != nil {
+ issuerCert = issuer.Certificate
+ issuerKey = issuer.PrivateKey
+ } else {
+ template.IsCA = true
+ template.KeyUsage |= x509.KeyUsageCertSign
+ issuerCert = &template
+ issuerKey = priv
+ }
+ cert, err := x509.CreateCertificate(rand.Reader, &template, issuerCert, priv.Public(), issuerKey)
+ if err != nil {
+ return nil, err
+ }
+ leaf, err := x509.ParseCertificate(cert)
+ if err != nil {
+ return nil, err
+ }
+ return &certKeyPair{
+ Certificate: leaf,
+ PrivateKey: priv,
+ }, nil
+}
diff --git a/smime.go b/smime.go
new file mode 100644
index 0000000..da09713
--- /dev/null
+++ b/smime.go
@@ -0,0 +1,192 @@
+// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "bytes"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+var (
+ // ErrInvalidPrivateKey should be used if private key is invalid
+ ErrInvalidPrivateKey = errors.New("invalid private key")
+
+ // ErrInvalidCertificate should be used if the certificate is invalid
+ ErrInvalidCertificate = errors.New("invalid certificate")
+)
+
+// privateKeyHolder is the representation of a private key
+type privateKeyHolder struct {
+ ecdsa *ecdsa.PrivateKey
+ rsa *rsa.PrivateKey
+}
+
+// get returns the private key of the privateKeyHolder
+func (p privateKeyHolder) get() crypto.PrivateKey {
+ if p.ecdsa != nil {
+ return p.ecdsa
+ }
+ return p.rsa
+}
+
+// SMime is used to sign messages with S/MIME
+type SMime struct {
+ privateKey privateKeyHolder
+ certificate *x509.Certificate
+ intermediateCertificate *x509.Certificate
+}
+
+// newSMimeWithRSA construct a new instance of SMime with provided parameters
+// privateKey as *rsa.PrivateKey
+// certificate as *x509.Certificate
+// intermediateCertificate (optional) as *x509.Certificate
+func newSMimeWithRSA(privateKey *rsa.PrivateKey, certificate *x509.Certificate, intermediateCertificate *x509.Certificate) (*SMime, error) {
+ if privateKey == nil {
+ return nil, ErrInvalidPrivateKey
+ }
+
+ if certificate == nil {
+ return nil, ErrInvalidCertificate
+ }
+
+ return &SMime{
+ privateKey: privateKeyHolder{rsa: privateKey},
+ certificate: certificate,
+ intermediateCertificate: intermediateCertificate,
+ }, nil
+}
+
+// newSMimeWithECDSA construct a new instance of SMime with provided parameters
+// privateKey as *ecdsa.PrivateKey
+// certificate as *x509.Certificate
+// intermediateCertificate (optional) as *x509.Certificate
+func newSMimeWithECDSA(privateKey *ecdsa.PrivateKey, certificate *x509.Certificate, intermediateCertificate *x509.Certificate) (*SMime, error) {
+ if privateKey == nil {
+ return nil, ErrInvalidPrivateKey
+ }
+
+ if certificate == nil {
+ return nil, ErrInvalidCertificate
+ }
+
+ return &SMime{
+ privateKey: privateKeyHolder{ecdsa: privateKey},
+ certificate: certificate,
+ intermediateCertificate: intermediateCertificate,
+ }, nil
+}
+
+// signMessage signs the message with S/MIME
+func (sm *SMime) signMessage(message string) (*string, error) {
+ lines := parseLines([]byte(message))
+ toBeSigned := lines.bytesFromLines([]byte("\r\n"))
+
+ signedData, err := newSignedData(toBeSigned)
+ if err != nil || signedData == nil {
+ return nil, fmt.Errorf("could not initialize signed data: %w", err)
+ }
+
+ if err = signedData.addSigner(sm.certificate, sm.privateKey.get(), SignerInfoConfig{}); err != nil {
+ return nil, fmt.Errorf("could not add signer message: %w", err)
+ }
+
+ if sm.intermediateCertificate != nil {
+ signedData.addCertificate(sm.intermediateCertificate)
+ }
+
+ signedData.detach()
+
+ signatureDER, err := signedData.finish()
+ if err != nil {
+ return nil, fmt.Errorf("could not finish signing: %w", err)
+ }
+
+ pemMsg, err := encodeToPEM(signatureDER)
+ if err != nil {
+ return nil, fmt.Errorf("could not encode to PEM: %w", err)
+ }
+
+ return pemMsg, nil
+}
+
+// createMessage prepares the message that will be used for the sign method later
+func (sm *SMime) prepareMessage(encoding Encoding, contentType ContentType, charset Charset, body []byte) string {
+ return fmt.Sprintf("Content-Transfer-Encoding: %v\r\nContent-Type: %v; charset=%v\r\n\r\n%v", encoding, contentType, charset, string(body))
+}
+
+// encodeToPEM uses the method pem.Encode from the standard library but cuts the typical PEM preamble
+func encodeToPEM(msg []byte) (*string, error) {
+ block := &pem.Block{Bytes: msg}
+
+ var arrayBuffer bytes.Buffer
+ if err := pem.Encode(&arrayBuffer, block); err != nil {
+ return nil, err
+ }
+
+ r := arrayBuffer.String()
+ r = strings.TrimPrefix(r, "-----BEGIN -----")
+ r = strings.Trim(r, "\n")
+ r = strings.TrimSuffix(r, "-----END -----")
+ r = strings.Trim(r, "\n")
+
+ return &r, nil
+}
+
+// line is the representation of one line of the message that will be used for signing purposes
+type line struct {
+ line []byte
+ endOfLine []byte
+}
+
+// lines is the representation of a message that will be used for signing purposes
+type lines []line
+
+// bytesFromLines creates the line representation with the given endOfLine char
+func (ls lines) bytesFromLines(sep []byte) []byte {
+ var raw []byte
+ for i := range ls {
+ raw = append(raw, ls[i].line...)
+ if len(ls[i].endOfLine) != 0 && sep != nil {
+ raw = append(raw, sep...)
+ } else {
+ raw = append(raw, ls[i].endOfLine...)
+ }
+ }
+ return raw
+}
+
+// parseLines constructs the lines representation of a given message
+func parseLines(raw []byte) lines {
+ oneLine := line{raw, nil}
+ lines := lines{oneLine}
+ lines = lines.splitLine([]byte("\r\n"))
+ lines = lines.splitLine([]byte("\r"))
+ lines = lines.splitLine([]byte("\n"))
+ return lines
+}
+
+// splitLine uses the given endOfLine to split the given line
+func (ls lines) splitLine(sep []byte) lines {
+ nl := lines{}
+ for _, l := range ls {
+ split := bytes.Split(l.line, sep)
+ if len(split) > 1 {
+ for i := 0; i < len(split)-1; i++ {
+ nl = append(nl, line{split[i], sep})
+ }
+ nl = append(nl, line{split[len(split)-1], l.endOfLine})
+ } else {
+ nl = append(nl, l)
+ }
+ }
+ return nl
+}
diff --git a/smime_test.go b/smime_test.go
new file mode 100644
index 0000000..d6fae44
--- /dev/null
+++ b/smime_test.go
@@ -0,0 +1,244 @@
+// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "bytes"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestGet_RSA(t *testing.T) {
+ p := privateKeyHolder{
+ ecdsa: nil,
+ rsa: &rsa.PrivateKey{},
+ }
+
+ if p.get() == nil {
+ t.Errorf("get() did not return the correct private key")
+ }
+}
+
+func TestGet_ECDSA(t *testing.T) {
+ p := privateKeyHolder{
+ ecdsa: &ecdsa.PrivateKey{},
+ rsa: nil,
+ }
+
+ if p.get() == nil {
+ t.Errorf("get() did not return the correct private key")
+ }
+}
+
+// TestNewSMimeWithRSA tests the newSMime method with RSA crypto material
+func TestNewSMimeWithRSA(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("Error getting dummy crypto material: %s", err)
+ }
+
+ sMime, err := newSMimeWithRSA(privateKey, certificate, intermediateCertificate)
+ if err != nil {
+ t.Errorf("Error creating new SMime from keyPair: %s", err)
+ }
+
+ if sMime.privateKey.rsa != privateKey {
+ t.Errorf("NewSMime() did not return the same private key")
+ }
+ if sMime.certificate != certificate {
+ t.Errorf("NewSMime() did not return the same certificate")
+ }
+ if sMime.intermediateCertificate != intermediateCertificate {
+ t.Errorf("NewSMime() did not return the same intermedidate certificate")
+ }
+}
+
+// TestNewSMimeWithECDSA tests the newSMime method with ECDSA crypto material
+func TestNewSMimeWithECDSA(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyECDSACryptoMaterial()
+ if err != nil {
+ t.Errorf("Error getting dummy crypto material: %s", err)
+ }
+
+ sMime, err := newSMimeWithECDSA(privateKey, certificate, intermediateCertificate)
+ if err != nil {
+ t.Errorf("Error creating new SMime from keyPair: %s", err)
+ }
+
+ if sMime.privateKey.ecdsa != privateKey {
+ t.Errorf("NewSMime() did not return the same private key")
+ }
+ if sMime.certificate != certificate {
+ t.Errorf("NewSMime() did not return the same certificate")
+ }
+ if sMime.intermediateCertificate != intermediateCertificate {
+ t.Errorf("NewSMime() did not return the same intermedidate certificate")
+ }
+}
+
+// TestSign tests the sign method
+func TestSign(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("Error getting dummy crypto material: %s", err)
+ }
+
+ sMime, err := newSMimeWithRSA(privateKey, certificate, intermediateCertificate)
+ if err != nil {
+ t.Errorf("Error creating new SMime from keyPair: %s", err)
+ }
+
+ message := "This is a test message"
+ singedMessage, err := sMime.signMessage(message)
+ if err != nil {
+ t.Errorf("Error creating singed message: %s", err)
+ }
+
+ if *singedMessage == message {
+ t.Errorf("Sign() did not work")
+ }
+}
+
+// TestPrepareMessage tests the createMessage method
+func TestPrepareMessage(t *testing.T) {
+ privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
+ if err != nil {
+ t.Errorf("Error getting dummy crypto material: %s", err)
+ }
+
+ sMime, err := newSMimeWithRSA(privateKey, certificate, intermediateCertificate)
+ if err != nil {
+ t.Errorf("Error creating new SMime from keyPair: %s", err)
+ }
+
+ encoding := EncodingB64
+ contentType := TypeTextPlain
+ charset := CharsetUTF8
+ body := []byte("This is the body!")
+ result := sMime.prepareMessage(encoding, contentType, charset, body)
+
+ if !strings.Contains(result, encoding.String()) {
+ t.Errorf("createMessage() did not return the correct encoding")
+ }
+ if !strings.Contains(result, contentType.String()) {
+ t.Errorf("createMessage() did not return the correct contentType")
+ }
+ if !strings.Contains(result, string(body)) {
+ t.Errorf("createMessage() did not return the correct body")
+ }
+ if result != fmt.Sprintf("Content-Transfer-Encoding: %s\r\nContent-Type: %s; charset=%s\r\n\r\n%s", encoding, contentType, charset, string(body)) {
+ t.Errorf("createMessage() did not sucessfully create the message")
+ }
+}
+
+// TestEncodeToPEM tests the encodeToPEM method
+func TestEncodeToPEM(t *testing.T) {
+ message := []byte("This is a test message")
+
+ pemMessage, err := encodeToPEM(message)
+ if err != nil {
+ t.Errorf("Error encoding message: %s", err)
+ }
+
+ base64Encoded := base64.StdEncoding.EncodeToString(message)
+ if *pemMessage != base64Encoded {
+ t.Errorf("encodeToPEM() did not work")
+ }
+}
+
+// TestBytesFromLines tests the bytesFromLines method
+func TestBytesFromLines(t *testing.T) {
+ ls := lines{
+ {line: []byte("Hello"), endOfLine: []byte("\n")},
+ {line: []byte("World"), endOfLine: []byte("\n")},
+ }
+ expected := []byte("Hello\nWorld\n")
+
+ result := ls.bytesFromLines([]byte("\n"))
+ if !bytes.Equal(result, expected) {
+ t.Errorf("Expected %s, but got %s", expected, result)
+ }
+}
+
+// FuzzBytesFromLines tests the bytesFromLines method with fuzzing
+func FuzzBytesFromLines(f *testing.F) {
+ f.Add([]byte("Hello"), []byte("\n"))
+ f.Fuzz(func(t *testing.T, lineData, sep []byte) {
+ ls := lines{
+ {line: lineData, endOfLine: sep},
+ }
+ _ = ls.bytesFromLines(sep)
+ })
+}
+
+// TestParseLines tests the parseLines method
+func TestParseLines(t *testing.T) {
+ input := []byte("Hello\r\nWorld\nHello\rWorld")
+ expected := lines{
+ {line: []byte("Hello"), endOfLine: []byte("\r\n")},
+ {line: []byte("World"), endOfLine: []byte("\n")},
+ {line: []byte("Hello"), endOfLine: []byte("\r")},
+ {line: []byte("World"), endOfLine: []byte("")},
+ }
+
+ result := parseLines(input)
+ if len(result) != len(expected) {
+ t.Errorf("Expected %d lines, but got %d", len(expected), len(result))
+ }
+
+ for i := range result {
+ if !bytes.Equal(result[i].line, expected[i].line) || !bytes.Equal(result[i].endOfLine, expected[i].endOfLine) {
+ t.Errorf("Line %d mismatch. Expected line: %s, endOfLine: %s, got line: %s, endOfLine: %s",
+ i, expected[i].line, expected[i].endOfLine, result[i].line, result[i].endOfLine)
+ }
+ }
+}
+
+// FuzzParseLines tests the parseLines method with fuzzing
+func FuzzParseLines(f *testing.F) {
+ f.Add([]byte("Hello\nWorld\r\nAnother\rLine"))
+ f.Fuzz(func(t *testing.T, input []byte) {
+ _ = parseLines(input)
+ })
+}
+
+// TestSplitLine tests the splitLine method
+func TestSplitLine(t *testing.T) {
+ ls := lines{
+ {line: []byte("Hello\r\nWorld\r\nAnotherLine"), endOfLine: []byte("")},
+ }
+ expected := lines{
+ {line: []byte("Hello"), endOfLine: []byte("\r\n")},
+ {line: []byte("World"), endOfLine: []byte("\r\n")},
+ {line: []byte("AnotherLine"), endOfLine: []byte("")},
+ }
+
+ result := ls.splitLine([]byte("\r\n"))
+ if len(result) != len(expected) {
+ t.Errorf("Expected %d lines, but got %d", len(expected), len(result))
+ }
+
+ for i := range result {
+ if !bytes.Equal(result[i].line, expected[i].line) || !bytes.Equal(result[i].endOfLine, expected[i].endOfLine) {
+ t.Errorf("Line %d mismatch. Expected line: %s, endOfLine: %s, got line: %s, endOfLine: %s",
+ i, expected[i].line, expected[i].endOfLine, result[i].line, result[i].endOfLine)
+ }
+ }
+}
+
+// FuzzSplitLine tests the parseLsplitLineines method with fuzzing
+func FuzzSplitLine(f *testing.F) {
+ f.Add([]byte("Hello\r\nWorld"), []byte("\r\n"))
+ f.Fuzz(func(t *testing.T, input, sep []byte) {
+ ls := lines{
+ {line: input, endOfLine: []byte("")},
+ }
+ _ = ls.splitLine(sep)
+ })
+}
diff --git a/util_test.go b/util_test.go
new file mode 100644
index 0000000..7640f62
--- /dev/null
+++ b/util_test.go
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
+//
+// SPDX-License-Identifier: MIT
+
+package mail
+
+import (
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+)
+
+const (
+ certRSAFilePath = "dummy-chain-cert-rsa.pem"
+ keyRSAFilePath = "dummy-child-key-rsa.pem"
+
+ certECDSAFilePath = "dummy-chain-cert-ecdsa.pem"
+ keyECDSAFilePath = "dummy-child-key-ecdsa.pem"
+)
+
+// getDummyRSACryptoMaterial loads a certificate (RSA) and the associated private key (ECDSA) form local disk for testing purposes
+func getDummyRSACryptoMaterial() (*rsa.PrivateKey, *x509.Certificate, *x509.Certificate, error) {
+ keyPair, err := tls.LoadX509KeyPair(certRSAFilePath, keyRSAFilePath)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ privateKey := keyPair.PrivateKey.(*rsa.PrivateKey)
+
+ certificate, err := x509.ParseCertificate(keyPair.Certificate[0])
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ intermediateCertificate, err := x509.ParseCertificate(keyPair.Certificate[1])
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ return privateKey, certificate, intermediateCertificate, nil
+}
+
+// getDummyECDSACryptoMaterial loads a certificate (ECDSA) and the associated private key (ECDSA) form local disk for testing purposes
+func getDummyECDSACryptoMaterial() (*ecdsa.PrivateKey, *x509.Certificate, *x509.Certificate, error) {
+ keyPair, err := tls.LoadX509KeyPair(certECDSAFilePath, keyECDSAFilePath)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ privateKey := keyPair.PrivateKey.(*ecdsa.PrivateKey)
+
+ certificate, err := x509.ParseCertificate(keyPair.Certificate[0])
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ intermediateCertificate, err := x509.ParseCertificate(keyPair.Certificate[1])
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ return privateKey, certificate, intermediateCertificate, nil
+}