mirror of
https://github.com/wneessen/go-mail.git
synced 2024-11-23 06:10:48 +01:00
Compare commits
30 commits
e9f7be7dce
...
c01e7964c3
Author | SHA1 | Date | |
---|---|---|---|
|
c01e7964c3 | ||
580d0b0e4e | |||
3f0ac027e2 | |||
0b9a215e7d | |||
4053457020 | |||
f3633e1913 | |||
52061f97c6 | |||
77920be1a1 | |||
f5d4cdafea | |||
19330fc108 | |||
af9915e4e7 | |||
6f869e4efd | |||
44df830348 | |||
4ee11e8406 | |||
bd5a8a40b9 | |||
8dfb121aec | |||
d5437f6b7a | |||
157c138142 | |||
482194b4b3 | |||
b8f0462ce3 | |||
6af6a28f78 | |||
0aa24c6f3a | |||
fbebcf96d8 | |||
|
894936092e | ||
|
41a81966d8 | ||
|
94942ed383 | ||
|
e0a59dba6d | ||
|
6fda661dc7 | ||
|
158c1b0458 | ||
|
07d9654ce7 |
22 changed files with 1164 additions and 47 deletions
2
.github/workflows/codecov.yml
vendored
2
.github/workflows/codecov.yml
vendored
|
@ -36,7 +36,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go: ['1.20', '1.21', '1.22', '1.23']
|
||||
go: ['1.19', '1.20', '1.23']
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
|
||||
|
|
|
@ -7,16 +7,22 @@
|
|||
|
||||
package mail
|
||||
|
||||
import "errors"
|
||||
|
||||
// Send sends out the mail message
|
||||
func (c *Client) Send(messages ...*Msg) error {
|
||||
if err := c.checkConn(); err != nil {
|
||||
return &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
|
||||
}
|
||||
var errs []*SendError
|
||||
for _, message := range messages {
|
||||
for id, message := range messages {
|
||||
if sendErr := c.sendSingleMsg(message); sendErr != nil {
|
||||
messages[id].sendError = sendErr
|
||||
errs = append(errs, sendErr)
|
||||
|
||||
var msgSendErr *SendError
|
||||
if errors.As(sendErr, &msgSendErr) {
|
||||
errs = append(errs, msgSendErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
639
client_test.go
639
client_test.go
|
@ -5,6 +5,7 @@
|
|||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
|
@ -21,11 +22,18 @@ import (
|
|||
"github.com/wneessen/go-mail/smtp"
|
||||
)
|
||||
|
||||
// DefaultHost is used as default hostname for the Client
|
||||
const DefaultHost = "localhost"
|
||||
|
||||
// TestRcpt
|
||||
const TestRcpt = "go-mail@mytrashmailer.com"
|
||||
const (
|
||||
// DefaultHost is used as default hostname for the Client
|
||||
DefaultHost = "localhost"
|
||||
// TestRcpt is a trash mail address to send test mails to
|
||||
TestRcpt = "go-mail@mytrashmailer.com"
|
||||
// TestServerProto is the protocol used for the simple SMTP test server
|
||||
TestServerProto = "tcp"
|
||||
// TestServerAddr is the address the simple SMTP test server listens on
|
||||
TestServerAddr = "127.0.0.1"
|
||||
// TestServerPortBase is the base port for the simple SMTP test server
|
||||
TestServerPortBase = 2025
|
||||
)
|
||||
|
||||
// TestNewClient tests the NewClient() method with its default options
|
||||
func TestNewClient(t *testing.T) {
|
||||
|
@ -1251,6 +1259,475 @@ func TestClient_DialAndSendWithContext_withSendError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorNoEncoding(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
featureSet := "250-AUTH PLAIN\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
serverPort := TestServerPortBase + 1
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("valid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("valid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "Test body")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
message.SetEncoding(NoEncoding)
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrNoUnencoded {
|
||||
t.Errorf("expected ErrNoUnencoded error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
if sendErr.Msg() == nil {
|
||||
t.Errorf("expected message to be set, but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Close(); err != nil {
|
||||
t.Errorf("failed to close server connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorMailFrom(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverPort := TestServerPortBase + 2
|
||||
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("invalid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("valid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "Test body")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrSMTPMailFrom {
|
||||
t.Errorf("expected ErrSMTPMailFrom error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
if sendErr.Msg() == nil {
|
||||
t.Errorf("expected message to be set, but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Close(); err != nil {
|
||||
t.Errorf("failed to close server connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorMailFromReset(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverPort := TestServerPortBase + 3
|
||||
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("invalid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("valid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "Test body")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrSMTPMailFrom {
|
||||
t.Errorf("expected ErrSMTPMailFrom error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
if len(sendErr.errlist) != 2 {
|
||||
t.Errorf("expected 2 errors, but got %d", len(sendErr.errlist))
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(sendErr.errlist[0].Error(), "503 5.1.2 Invalid from: <invalid-from@domain.tld>") {
|
||||
t.Errorf("expected error: %q, but got %q",
|
||||
"503 5.1.2 Invalid from: <invalid-from@domain.tld>", sendErr.errlist[0].Error())
|
||||
}
|
||||
if !strings.EqualFold(sendErr.errlist[1].Error(), "500 5.1.2 Error: reset failed") {
|
||||
t.Errorf("expected error: %q, but got %q",
|
||||
"500 5.1.2 Error: reset failed", sendErr.errlist[1].Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Close(); err != nil {
|
||||
t.Errorf("failed to close server connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorToReset(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverPort := TestServerPortBase + 4
|
||||
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("valid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("invalid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "Test body")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrSMTPRcptTo {
|
||||
t.Errorf("expected ErrSMTPRcptTo error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
if len(sendErr.errlist) != 2 {
|
||||
t.Errorf("expected 2 errors, but got %d", len(sendErr.errlist))
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(sendErr.errlist[0].Error(), "500 5.1.2 Invalid to: <invalid-to@domain.tld>") {
|
||||
t.Errorf("expected error: %q, but got %q",
|
||||
"500 5.1.2 Invalid to: <invalid-to@domain.tld>", sendErr.errlist[0].Error())
|
||||
}
|
||||
if !strings.EqualFold(sendErr.errlist[1].Error(), "500 5.1.2 Error: reset failed") {
|
||||
t.Errorf("expected error: %q, but got %q",
|
||||
"500 5.1.2 Error: reset failed", sendErr.errlist[1].Error())
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Close(); err != nil {
|
||||
t.Errorf("failed to close server connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorDataClose(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverPort := TestServerPortBase + 5
|
||||
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("valid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("valid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "DATA close should fail")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrSMTPDataClose {
|
||||
t.Errorf("expected ErrSMTPDataClose error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Close(); err != nil {
|
||||
t.Errorf("failed to close server connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorDataWrite(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverPort := TestServerPortBase + 6
|
||||
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("valid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("valid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "DATA write should fail")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
message.SetGenHeader("X-Test-Header", "DATA write should fail")
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrSMTPDataClose {
|
||||
t.Errorf("expected ErrSMTPDataClose error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SendErrorReset(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
serverPort := TestServerPortBase + 7
|
||||
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
|
||||
go func() {
|
||||
if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil {
|
||||
t.Errorf("failed to start test server: %s", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
|
||||
message := NewMsg()
|
||||
if err := message.From("valid-from@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set FROM address: %s", err)
|
||||
return
|
||||
}
|
||||
if err := message.To("valid-to@domain.tld"); err != nil {
|
||||
t.Errorf("failed to set TO address: %s", err)
|
||||
return
|
||||
}
|
||||
message.Subject("Test subject")
|
||||
message.SetBodyString(TypeTextPlain, "Test body")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
|
||||
client, err := NewClient(TestServerAddr, WithPort(serverPort),
|
||||
WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain),
|
||||
WithUsername("toni@tester.com"),
|
||||
WithPassword("V3ryS3cr3t+"))
|
||||
if err != nil {
|
||||
t.Errorf("unable to create new client: %s", err)
|
||||
}
|
||||
if err = client.DialWithContext(context.Background()); err != nil {
|
||||
t.Errorf("failed to dial to test server: %s", err)
|
||||
}
|
||||
if err = client.Send(message); err == nil {
|
||||
t.Error("expected Send() to fail but didn't")
|
||||
}
|
||||
|
||||
var sendErr *SendError
|
||||
if !errors.As(err, &sendErr) {
|
||||
t.Errorf("expected *SendError type as returned error, but got %T", sendErr)
|
||||
}
|
||||
if errors.As(err, &sendErr) {
|
||||
if sendErr.IsTemp() {
|
||||
t.Errorf("expected permanent error but IsTemp() returned true")
|
||||
}
|
||||
if sendErr.Reason != ErrSMTPReset {
|
||||
t.Errorf("expected ErrSMTPReset error, but got %s", sendErr.Reason)
|
||||
}
|
||||
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("expected message ID: %q, but got %q", "<this.is.a.message.id>",
|
||||
sendErr.MessageID())
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Close(); err != nil {
|
||||
t.Errorf("failed to close server connection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getTestConnection takes environment variables to establish a connection to a real
|
||||
// SMTP server to test all functionality that requires a connection
|
||||
func getTestConnection(auth bool) (*Client, error) {
|
||||
|
@ -1545,3 +2022,155 @@ func (f faker) RemoteAddr() net.Addr { return nil }
|
|||
func (f faker) SetDeadline(time.Time) error { return nil }
|
||||
func (f faker) SetReadDeadline(time.Time) error { return nil }
|
||||
func (f faker) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
// simpleSMTPServer starts a simple TCP server that resonds to SMTP commands.
|
||||
// The provided featureSet represents in what the server responds to EHLO command
|
||||
// failReset controls if a RSET succeeds
|
||||
func simpleSMTPServer(ctx context.Context, featureSet string, failReset bool, port int) error {
|
||||
listener, err := net.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to listen on %s://%s: %w", TestServerProto, TestServerAddr, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := listener.Close(); err != nil {
|
||||
fmt.Printf("unable to close listener: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
connection, err := listener.Accept()
|
||||
var opErr *net.OpError
|
||||
if err != nil {
|
||||
if errors.As(err, &opErr) && opErr.Temporary() {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("unable to accept connection: %w", err)
|
||||
}
|
||||
handleTestServerConnection(connection, featureSet, failReset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleTestServerConnection(connection net.Conn, featureSet string, failReset bool) {
|
||||
defer func() {
|
||||
if err := connection.Close(); err != nil {
|
||||
fmt.Printf("unable to close connection: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
reader := bufio.NewReader(connection)
|
||||
writer := bufio.NewWriter(connection)
|
||||
|
||||
writeLine := func(data string) error {
|
||||
_, err := writer.WriteString(data + "\r\n")
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to write line: %w", err)
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
writeOK := func() {
|
||||
_ = writeLine("250 2.0.0 OK")
|
||||
}
|
||||
|
||||
if err := writeLine("220 go-mail test server ready ESMTP"); err != nil {
|
||||
fmt.Printf("unable to write to client: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
fmt.Printf("unable to read from connection: %s\n", err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(data, "EHLO") && !strings.HasPrefix(data, "HELO") {
|
||||
fmt.Printf("expected EHLO, got %q", data)
|
||||
return
|
||||
}
|
||||
if err = writeLine("250-localhost.localdomain\r\n" + featureSet); err != nil {
|
||||
fmt.Printf("unable to write to connection: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
data, err = reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
fmt.Println("Error reading data:", err)
|
||||
break
|
||||
}
|
||||
|
||||
var datastring string
|
||||
data = strings.TrimSpace(data)
|
||||
switch {
|
||||
case strings.HasPrefix(data, "MAIL FROM:"):
|
||||
from := strings.TrimPrefix(data, "MAIL FROM:")
|
||||
from = strings.ReplaceAll(from, "BODY=8BITMIME", "")
|
||||
from = strings.ReplaceAll(from, "SMTPUTF8", "")
|
||||
from = strings.TrimSpace(from)
|
||||
if !strings.EqualFold(from, "<valid-from@domain.tld>") {
|
||||
_ = writeLine(fmt.Sprintf("503 5.1.2 Invalid from: %s", from))
|
||||
break
|
||||
}
|
||||
writeOK()
|
||||
case strings.HasPrefix(data, "RCPT TO:"):
|
||||
to := strings.TrimPrefix(data, "RCPT TO:")
|
||||
to = strings.TrimSpace(to)
|
||||
if !strings.EqualFold(to, "<valid-to@domain.tld>") {
|
||||
_ = writeLine(fmt.Sprintf("500 5.1.2 Invalid to: %s", to))
|
||||
break
|
||||
}
|
||||
writeOK()
|
||||
case strings.HasPrefix(data, "AUTH PLAIN"):
|
||||
auth := strings.TrimPrefix(data, "AUTH PLAIN ")
|
||||
if !strings.EqualFold(auth, "AHRvbmlAdGVzdGVyLmNvbQBWM3J5UzNjcjN0Kw==") {
|
||||
_ = writeLine("535 5.7.8 Error: authentication failed")
|
||||
break
|
||||
}
|
||||
_ = writeLine("235 2.7.0 Authentication successful")
|
||||
case strings.EqualFold(data, "DATA"):
|
||||
_ = writeLine("354 End data with <CR><LF>.<CR><LF>")
|
||||
for {
|
||||
ddata, derr := reader.ReadString('\n')
|
||||
if derr != nil {
|
||||
fmt.Printf("failed to read DATA data from connection: %s\n", derr)
|
||||
break
|
||||
}
|
||||
ddata = strings.TrimSpace(ddata)
|
||||
if strings.EqualFold(ddata, "DATA write should fail") {
|
||||
_ = writeLine("500 5.0.0 Error during DATA transmission")
|
||||
break
|
||||
}
|
||||
if ddata == "." {
|
||||
if strings.Contains(datastring, "DATA close should fail") {
|
||||
_ = writeLine("500 5.0.0 Error during DATA closing")
|
||||
break
|
||||
}
|
||||
_ = writeLine("250 2.0.0 Ok: queued as 1234567890")
|
||||
break
|
||||
}
|
||||
datastring += ddata + "\n"
|
||||
}
|
||||
case strings.EqualFold(data, "noop"),
|
||||
strings.EqualFold(data, "vrfy"):
|
||||
writeOK()
|
||||
case strings.EqualFold(data, "rset"):
|
||||
if failReset {
|
||||
_ = writeLine("500 5.1.2 Error: reset failed")
|
||||
break
|
||||
}
|
||||
writeOK()
|
||||
case strings.EqualFold(data, "quit"):
|
||||
_ = writeLine("221 2.0.0 Bye")
|
||||
default:
|
||||
_ = writeLine("500 5.5.2 Error: bad syntax")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
15
encoding.go
15
encoding.go
|
@ -19,6 +19,9 @@ type MIMEVersion string
|
|||
// MIMEType represents the MIME type for the mail
|
||||
type MIMEType string
|
||||
|
||||
// Disposition represents a content disposition for the Msg
|
||||
type Disposition string
|
||||
|
||||
// List of supported encodings
|
||||
const (
|
||||
// EncodingB64 represents the Base64 encoding as specified in RFC 2045.
|
||||
|
@ -149,6 +152,7 @@ const (
|
|||
TypePGPEncrypted ContentType = "application/pgp-encrypted"
|
||||
TypeTextHTML ContentType = "text/html"
|
||||
TypeTextPlain ContentType = "text/plain"
|
||||
typeSMimeSigned ContentType = `application/pkcs7-signature; name="smime.p7s"`
|
||||
)
|
||||
|
||||
// List of MIMETypes
|
||||
|
@ -156,6 +160,12 @@ const (
|
|||
MIMEAlternative MIMEType = "alternative"
|
||||
MIMEMixed MIMEType = "mixed"
|
||||
MIMERelated MIMEType = "related"
|
||||
MIMESMime MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha256`
|
||||
)
|
||||
|
||||
// List of common content disposition
|
||||
const (
|
||||
DispositionSMime Disposition = `attachment; filename="smime.p7s"`
|
||||
)
|
||||
|
||||
// String is a standard method to convert an Charset into a printable format
|
||||
|
@ -172,3 +182,8 @@ func (c ContentType) String() string {
|
|||
func (e Encoding) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
// String is a standard method to convert an Disposition into a printable format
|
||||
func (d Disposition) String() string {
|
||||
return string(d)
|
||||
}
|
||||
|
|
|
@ -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,22 @@ func TestCharset_String(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisposition_String tests the string method of the Disposition object
|
||||
func TestDisposition_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d Disposition
|
||||
want string
|
||||
}{
|
||||
{"Disposition: S/Mime", DispositionSMime, `attachment; filename="smime.p7s"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.d.String() != tt.want {
|
||||
t.Errorf("wrong string for Disposition returned. Expected: %s, got: %s",
|
||||
tt.want, tt.d.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
2
go.mod
2
go.mod
|
@ -5,3 +5,5 @@
|
|||
module github.com/wneessen/go-mail
|
||||
|
||||
go 1.16
|
||||
|
||||
require go.mozilla.org/pkcs7 v0.9.0
|
||||
|
|
2
go.sum
2
go.sum
|
@ -0,0 +1,2 @@
|
|||
go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI=
|
||||
go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
63
msg.go
63
msg.go
|
@ -7,6 +7,8 @@ package mail
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -120,6 +122,9 @@ type Msg struct {
|
|||
|
||||
// noDefaultUserAgent indicates whether the default User Agent will be excluded for the Msg when it's sent.
|
||||
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
|
||||
|
@ -202,6 +207,16 @@ func WithNoDefaultUserAgent() MsgOption {
|
|||
}
|
||||
}
|
||||
|
||||
// SignWithSMime configures the Msg to be signed with S/MIME
|
||||
func (m *Msg) SignWithSMime(privateKey *rsa.PrivateKey, certificate *x509.Certificate) error {
|
||||
sMime, err := NewSMime(privateKey, certificate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.sMime = sMime
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCharset sets the encoding charset of the Msg
|
||||
func (m *Msg) SetCharset(c Charset) {
|
||||
m.charset = c
|
||||
|
@ -467,8 +482,8 @@ func (m *Msg) SetMessageID() {
|
|||
if err != nil {
|
||||
hostname = "localhost.localdomain"
|
||||
}
|
||||
randNumPrimary, _ := randNum(100000000)
|
||||
randNumSecondary, _ := randNum(10000)
|
||||
randNumPrimary := randNum(100000000)
|
||||
randNumSecondary := randNum(10000)
|
||||
randString, _ := randomStringSecure(17)
|
||||
procID := os.Getpid() * randNumSecondary
|
||||
messageID := fmt.Sprintf("%d.%d%d.%s@%s", procID, randNumPrimary, randNumSecondary,
|
||||
|
@ -979,10 +994,47 @@ func (m *Msg) applyMiddlewares(msg *Msg) *Msg {
|
|||
return msg
|
||||
}
|
||||
|
||||
// signMessage sign the Msg with S/MIME
|
||||
func (m *Msg) signMessage(msg *Msg) (*Msg, error) {
|
||||
currentPart := m.GetParts()[0]
|
||||
currentPart.SetEncoding(EncodingUSASCII)
|
||||
currentPart.SetContentType(TypeTextPlain)
|
||||
content, err := currentPart.GetContent()
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to extract content from part")
|
||||
}
|
||||
|
||||
signedContent, err := m.sMime.Sign(content)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to sign message")
|
||||
}
|
||||
|
||||
signedPart := msg.newPart(
|
||||
typeSMimeSigned,
|
||||
WithPartEncoding(EncodingB64),
|
||||
WithContentDisposition(DispositionSMime),
|
||||
)
|
||||
signedPart.SetContent(*signedContent)
|
||||
msg.parts = append(msg.parts, signedPart)
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// WriteTo writes the formated Msg into a give io.Writer and satisfies the io.WriteTo interface
|
||||
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.sMime != nil {
|
||||
signedMsg, err := m.signMessage(msg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
msg = signedMsg
|
||||
}
|
||||
|
||||
mw.writeMsg(msg)
|
||||
|
||||
return mw.bytesWritten, mw.err
|
||||
}
|
||||
|
||||
|
@ -1177,6 +1229,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
|
||||
func (m *Msg) hasRelated() bool {
|
||||
return m.pgptype == 0 && ((len(m.parts) > 0 && len(m.embeds) > 0) || len(m.embeds) > 1)
|
||||
|
|
|
@ -61,3 +61,25 @@ func TestMsg_WriteToSendmail(t *testing.T) {
|
|||
t.Errorf("WriteToSendmail failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMsg_WriteToTempFileFailed(t *testing.T) {
|
||||
m := NewMsg()
|
||||
_ = m.From("Toni Tester <tester@example.com>")
|
||||
_ = m.To("Ellenor Tester <ellinor@example.com>")
|
||||
m.SetBodyString(TypeTextPlain, "This is a test")
|
||||
|
||||
curTmpDir := os.Getenv("TMPDIR")
|
||||
defer func() {
|
||||
if err := os.Setenv("TMPDIR", curTmpDir); err != nil {
|
||||
t.Errorf("failed to set TMPDIR environment variable: %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := os.Setenv("TMPDIR", "/invalid/directory/that/does/not/exist"); err != nil {
|
||||
t.Errorf("failed to set TMPDIR environment variable: %s", err)
|
||||
}
|
||||
_, err := m.WriteToTempFile()
|
||||
if err == nil {
|
||||
t.Errorf("WriteToTempFile() did not fail as expected")
|
||||
}
|
||||
}
|
||||
|
|
38
msg_test.go
38
msg_test.go
|
@ -786,13 +786,11 @@ func TestMsg_SetMessageIDWithValue(t *testing.T) {
|
|||
// TestMsg_SetMessageIDRandomness tests the randomness of Msg.SetMessageID methods
|
||||
func TestMsg_SetMessageIDRandomness(t *testing.T) {
|
||||
var mids []string
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := 0; i < 50_000; i++ {
|
||||
m := NewMsg()
|
||||
m.SetMessageID()
|
||||
mid := m.GetGenHeader(HeaderMessageID)
|
||||
if len(mid) > 0 {
|
||||
mids = append(mids, mid[0])
|
||||
}
|
||||
mid := m.GetMessageID()
|
||||
mids = append(mids, mid)
|
||||
}
|
||||
c := make(map[string]int)
|
||||
for i := range mids {
|
||||
|
@ -3248,6 +3246,36 @@ func TestNewMsgWithNoDefaultUserAgent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestWithSMimeSinging_ValidPrivateKey tests WithSMimeSinging with given privateKey
|
||||
func TestWithSMimeSinging_ValidPrivateKey(t *testing.T) {
|
||||
privateKey, err := getDummyPrivateKey()
|
||||
if err != nil {
|
||||
t.Errorf("failed to load dummy private key: %s", err)
|
||||
}
|
||||
certificate, err := getDummyCertificate(privateKey)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load dummy certificate: %s", err)
|
||||
}
|
||||
|
||||
m := NewMsg()
|
||||
if err := m.SignWithSMime(privateKey, certificate); err != nil {
|
||||
t.Errorf("failed to set sMime. Cause: %v", err)
|
||||
}
|
||||
if m.sMime.privateKey != privateKey {
|
||||
t.Errorf("WithSMimeSinging. Expected %v, got: %v", privateKey, m.sMime.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithSMimeSinging_InvalidPrivateKey tests WithSMimeSinging with given invalid privateKey
|
||||
func TestWithSMimeSinging_InvalidPrivateKey(t *testing.T) {
|
||||
m := NewMsg()
|
||||
|
||||
err := m.SignWithSMime(nil, nil)
|
||||
if !errors.Is(err, ErrInvalidPrivateKey) {
|
||||
t.Errorf("failed to check sMimeAuthConfig values correctly: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fuzzing tests
|
||||
func FuzzMsg_Subject(f *testing.F) {
|
||||
f.Add("Testsubject")
|
||||
|
|
|
@ -88,6 +88,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)
|
||||
|
@ -96,7 +100,7 @@ func (mw *msgWriter) writeMsg(msg *Msg) {
|
|||
mw.startMP(MIMERelated, msg.boundary)
|
||||
mw.writeString(DoubleNewLine)
|
||||
}
|
||||
if msg.hasAlt() {
|
||||
if msg.hasAlt() && !msg.hasSMime() {
|
||||
mw.startMP(MIMEAlternative, msg.boundary)
|
||||
mw.writeString(DoubleNewLine)
|
||||
}
|
||||
|
@ -265,6 +269,9 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) {
|
|||
if part.description != "" {
|
||||
mimeHeader.Add(string(HeaderContentDescription), part.description)
|
||||
}
|
||||
if part.disposition != "" {
|
||||
mimeHeader.Add(string(HeaderContentDisposition), part.disposition.String())
|
||||
}
|
||||
mimeHeader.Add(string(HeaderContentType), contentType)
|
||||
mimeHeader.Add(string(HeaderContentTransferEnc), contentTransferEnc)
|
||||
mw.newPart(mimeHeader)
|
||||
|
|
18
part.go
18
part.go
|
@ -17,6 +17,7 @@ type Part struct {
|
|||
contentType ContentType
|
||||
charset Charset
|
||||
description string
|
||||
disposition Disposition
|
||||
encoding Encoding
|
||||
isDeleted bool
|
||||
writeFunc func(io.Writer) (int64, error)
|
||||
|
@ -56,6 +57,11 @@ func (p *Part) GetDescription() string {
|
|||
return p.description
|
||||
}
|
||||
|
||||
// GetDisposition returns the currently set Content-Disposition of the Part
|
||||
func (p *Part) GetDisposition() Disposition {
|
||||
return p.disposition
|
||||
}
|
||||
|
||||
// SetContent overrides the content of the Part with the given string
|
||||
func (p *Part) SetContent(content string) {
|
||||
buffer := bytes.NewBufferString(content)
|
||||
|
@ -82,6 +88,11 @@ func (p *Part) SetDescription(description string) {
|
|||
p.description = description
|
||||
}
|
||||
|
||||
// SetDisposition overrides the Content-Disposition of the Part
|
||||
func (p *Part) SetDisposition(disposition Disposition) {
|
||||
p.disposition = disposition
|
||||
}
|
||||
|
||||
// SetWriteFunc overrides the WriteFunc of the Part
|
||||
func (p *Part) SetWriteFunc(writeFunc func(io.Writer) (int64, error)) {
|
||||
p.writeFunc = writeFunc
|
||||
|
@ -113,3 +124,10 @@ func WithPartContentDescription(description string) PartOption {
|
|||
p.description = description
|
||||
}
|
||||
}
|
||||
|
||||
// WithContentDisposition overrides the default Part Content-Disposition
|
||||
func WithContentDisposition(disposition Disposition) PartOption {
|
||||
return func(p *Part) {
|
||||
p.disposition = disposition
|
||||
}
|
||||
}
|
||||
|
|
54
part_test.go
54
part_test.go
|
@ -102,6 +102,35 @@ func TestPart_WithPartContentDescription(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestPart_withContentDisposition tests the WithContentDisposition method
|
||||
func TestPart_withContentDisposition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
disposition Disposition
|
||||
}{
|
||||
{"Part disposition: test", "test"},
|
||||
{"Part disposition: empty", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
m := NewMsg()
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
part := m.newPart(TypeTextPlain, WithContentDisposition(tt.disposition), nil)
|
||||
if part == nil {
|
||||
t.Errorf("newPart() WithPartContentDescription() failed: no part returned")
|
||||
return
|
||||
}
|
||||
if part.disposition != tt.disposition {
|
||||
t.Errorf("newPart() WithContentDisposition() failed: expected: %s, got: %s", tt.disposition, part.description)
|
||||
}
|
||||
part.disposition = ""
|
||||
part.SetDisposition(tt.disposition)
|
||||
if part.disposition != tt.disposition {
|
||||
t.Errorf("newPart() SetDisposition() failed: expected: %s, got: %s", tt.disposition, part.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPartContentType tests Part.SetContentType
|
||||
func TestPart_SetContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
@ -323,6 +352,31 @@ func TestPart_SetDescription(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestPart_SetDisposition tests Part.SetDisposition
|
||||
func TestPart_SetDisposition(t *testing.T) {
|
||||
c := "This is a test"
|
||||
d := Disposition("test-disposition")
|
||||
m := NewMsg()
|
||||
m.SetBodyString(TypeTextPlain, c)
|
||||
pl, err := getPartList(m)
|
||||
if err != nil {
|
||||
t.Errorf("failed: %s", err)
|
||||
return
|
||||
}
|
||||
pd := pl[0].GetDisposition()
|
||||
if pd != "" {
|
||||
t.Errorf("Part.GetDisposition failed. Expected empty description but got: %s", pd)
|
||||
}
|
||||
pl[0].SetDisposition(d)
|
||||
if pl[0].disposition != d {
|
||||
t.Errorf("Part.SetDisposition failed. Expected description to be: %s, got: %s", d, pd)
|
||||
}
|
||||
pd = pl[0].GetDisposition()
|
||||
if pd != d {
|
||||
t.Errorf("Part.GetDisposition failed. Expected: %s, got: %s", d, pd)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPart_Delete tests Part.Delete
|
||||
func TestPart_Delete(t *testing.T) {
|
||||
c := "This is a test with ümläutß"
|
||||
|
|
22
random.go
22
random.go
|
@ -7,8 +7,6 @@ package mail
|
|||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -52,23 +50,3 @@ func randomStringSecure(length int) (string, error) {
|
|||
|
||||
return randString.String(), nil
|
||||
}
|
||||
|
||||
// randNum returns a random number with a maximum value of length
|
||||
func randNum(length int) (int, error) {
|
||||
if length <= 0 {
|
||||
return 0, fmt.Errorf("provided number is <= 0: %d", length)
|
||||
}
|
||||
length64 := big.NewInt(int64(length))
|
||||
if !length64.IsUint64() {
|
||||
return 0, fmt.Errorf("big.NewInt() generation returned negative value: %d", length64)
|
||||
}
|
||||
randNum64, err := rand.Int(rand.Reader, length64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
randomNum := int(randNum64.Int64())
|
||||
if randomNum < 0 {
|
||||
return 0, fmt.Errorf("generated random number does not fit as int64: %d", randNum64)
|
||||
}
|
||||
return randomNum, nil
|
||||
}
|
||||
|
|
22
random_119.go
Normal file
22
random_119.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build go1.19 && !go1.20
|
||||
// +build go1.19,!go1.20
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// randNum returns a random number with a maximum value of length
|
||||
func randNum(maxval int) int {
|
||||
if maxval <= 0 {
|
||||
return 0
|
||||
}
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
return rand.Intn(maxval)
|
||||
}
|
20
random_121.go
Normal file
20
random_121.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build go1.20 && !go1.22
|
||||
// +build go1.20,!go1.22
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// randNum returns a random number with a maximum value of length
|
||||
func randNum(maxval int) int {
|
||||
if maxval <= 0 {
|
||||
return 0
|
||||
}
|
||||
return rand.Intn(maxval)
|
||||
}
|
22
random_122.go
Normal file
22
random_122.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build go1.22
|
||||
// +build go1.22
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
// randNum returns a random number with a maximum value of maxval.
|
||||
// go-mail compiled with Go 1.22+ will make use of the novel math/rand/v2 interface
|
||||
// Older versions of Go will use math/rand
|
||||
func randNum(maxval int) int {
|
||||
if maxval <= 0 {
|
||||
return 0
|
||||
}
|
||||
return rand.IntN(maxval)
|
||||
}
|
|
@ -55,16 +55,17 @@ func TestRandomNum(t *testing.T) {
|
|||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
rn, err := randNum(tc.max)
|
||||
if err != nil {
|
||||
t.Errorf("random number generation failed: %s", err)
|
||||
}
|
||||
if rn < 0 {
|
||||
t.Errorf("random number generation failed: %d is smaller than zero", rn)
|
||||
}
|
||||
rn := randNum(tc.max)
|
||||
if rn > tc.max {
|
||||
t.Errorf("random number generation failed: %d is bigger than given value %d", rn, tc.max)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomNumZero(t *testing.T) {
|
||||
rn := randNum(0)
|
||||
if rn != 0 {
|
||||
t.Errorf("random number generation failed: %d is not zero", rn)
|
||||
}
|
||||
}
|
||||
|
|
17
senderror.go
17
senderror.go
|
@ -118,6 +118,23 @@ func (e *SendError) IsTemp() bool {
|
|||
return e.isTemp
|
||||
}
|
||||
|
||||
// MessageID returns the message ID of the affected Msg that caused the error
|
||||
// If no message ID was set for the Msg, an empty string will be returned
|
||||
func (e *SendError) MessageID() string {
|
||||
if e == nil || e.affectedMsg == nil {
|
||||
return ""
|
||||
}
|
||||
return e.affectedMsg.GetMessageID()
|
||||
}
|
||||
|
||||
// Msg returns the pointer to the affected message that caused the error
|
||||
func (e *SendError) Msg() *Msg {
|
||||
if e == nil || e.affectedMsg == nil {
|
||||
return nil
|
||||
}
|
||||
return e.affectedMsg
|
||||
}
|
||||
|
||||
// String implements the Stringer interface for the SendErrReason
|
||||
func (r SendErrReason) String() string {
|
||||
switch r {
|
||||
|
|
|
@ -90,7 +90,89 @@ func TestSendError_IsTempNil(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendError_MessageID(t *testing.T) {
|
||||
var se *SendError
|
||||
err := returnSendError(ErrAmbiguous, false)
|
||||
if !errors.As(err, &se) {
|
||||
t.Errorf("error mismatch, expected error to be of type *SendError")
|
||||
return
|
||||
}
|
||||
if errors.As(err, &se) {
|
||||
if se.MessageID() == "" {
|
||||
t.Errorf("sendError expected message-id, but got empty string")
|
||||
}
|
||||
if !strings.EqualFold(se.MessageID(), "<this.is.a.message.id>") {
|
||||
t.Errorf("sendError message-id expected: %s, but got: %s", "<this.is.a.message.id>",
|
||||
se.MessageID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError_MessageIDNil(t *testing.T) {
|
||||
var se *SendError
|
||||
if se.MessageID() != "" {
|
||||
t.Error("expected empty string on nil-senderror")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError_Msg(t *testing.T) {
|
||||
var se *SendError
|
||||
err := returnSendError(ErrAmbiguous, false)
|
||||
if !errors.As(err, &se) {
|
||||
t.Errorf("error mismatch, expected error to be of type *SendError")
|
||||
return
|
||||
}
|
||||
if errors.As(err, &se) {
|
||||
if se.Msg() == nil {
|
||||
t.Errorf("sendError expected msg pointer, but got nil")
|
||||
}
|
||||
from := se.Msg().GetFromString()
|
||||
if len(from) == 0 {
|
||||
t.Errorf("sendError expected msg from, but got empty string")
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(from[0], "<toni.tester@domain.tld>") {
|
||||
t.Errorf("sendError message from expected: %s, but got: %s", "<toni.tester@domain.tld>",
|
||||
from[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError_MsgNil(t *testing.T) {
|
||||
var se *SendError
|
||||
if se.Msg() != nil {
|
||||
t.Error("expected nil on nil-senderror")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError_IsFail(t *testing.T) {
|
||||
err1 := returnSendError(ErrAmbiguous, false)
|
||||
err2 := returnSendError(ErrSMTPMailFrom, false)
|
||||
if errors.Is(err1, err2) {
|
||||
t.Errorf("error mismatch, ErrAmbiguous should not be equal to ErrSMTPMailFrom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError_ErrorMulti(t *testing.T) {
|
||||
expected := `ambiguous reason, check Msg.SendError for message specific reasons, ` +
|
||||
`affected recipient(s): <email1@domain.tld>, <email2@domain.tld>`
|
||||
err := &SendError{
|
||||
Reason: ErrAmbiguous, isTemp: false, affectedMsg: nil,
|
||||
rcpt: []string{"<email1@domain.tld>", "<email2@domain.tld>"},
|
||||
}
|
||||
if err.Error() != expected {
|
||||
t.Errorf("error mismatch, expected: %s, got: %s", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// returnSendError is a helper method to retunr a SendError with a specific reason
|
||||
func returnSendError(r SendErrReason, t bool) error {
|
||||
return &SendError{Reason: r, isTemp: t}
|
||||
message := NewMsg()
|
||||
_ = message.From("toni.tester@domain.tld")
|
||||
_ = message.To("tina.tester@domain.tld")
|
||||
message.Subject("This is the subject")
|
||||
message.SetBodyString(TypeTextPlain, "This is the message body")
|
||||
message.SetMessageIDWithValue("this.is.a.message.id")
|
||||
|
||||
return &SendError{Reason: r, isTemp: t, affectedMsg: message}
|
||||
}
|
||||
|
|
70
sime.go
Normal file
70
sime.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"go.mozilla.org/pkcs7"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidPrivateKey should be used if private key is invalid
|
||||
ErrInvalidPrivateKey = errors.New("invalid private key")
|
||||
|
||||
// ErrInvalidCertificate should be used if certificate is invalid
|
||||
ErrInvalidCertificate = errors.New("invalid certificate")
|
||||
|
||||
// ErrCouldNotInitialize should be used if the signed data could not initialize
|
||||
ErrCouldNotInitialize = errors.New("could not initialize signed data")
|
||||
|
||||
// ErrCouldNotAddSigner should be used if the signer could not be added
|
||||
ErrCouldNotAddSigner = errors.New("could not add signer message")
|
||||
|
||||
// ErrCouldNotFinishSigning should be used if the signing could not be finished
|
||||
ErrCouldNotFinishSigning = errors.New("could not finish signing")
|
||||
)
|
||||
|
||||
// SMime is used to sign messages with S/MIME
|
||||
type SMime struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
certificate *x509.Certificate
|
||||
}
|
||||
|
||||
// NewSMime construct a new instance of SMime with a provided *rsa.PrivateKey
|
||||
func NewSMime(privateKey *rsa.PrivateKey, certificate *x509.Certificate) (*SMime, error) {
|
||||
if privateKey == nil {
|
||||
return nil, ErrInvalidPrivateKey
|
||||
}
|
||||
|
||||
if certificate == nil {
|
||||
return nil, ErrInvalidCertificate
|
||||
}
|
||||
|
||||
return &SMime{
|
||||
privateKey: privateKey,
|
||||
certificate: certificate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sign the content with the given privateKey of the method NewSMime
|
||||
func (sm *SMime) Sign(content []byte) (*string, error) {
|
||||
toBeSigned, err := pkcs7.NewSignedData(content)
|
||||
|
||||
toBeSigned.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256)
|
||||
if err != nil {
|
||||
return nil, ErrCouldNotInitialize
|
||||
}
|
||||
|
||||
if err = toBeSigned.AddSigner(sm.certificate, sm.privateKey, pkcs7.SignerInfoConfig{}); err != nil {
|
||||
return nil, ErrCouldNotAddSigner
|
||||
}
|
||||
|
||||
signed, err := toBeSigned.Finish()
|
||||
if err != nil {
|
||||
return nil, ErrCouldNotFinishSigning
|
||||
}
|
||||
|
||||
signedData := string(signed)
|
||||
|
||||
return &signedData, nil
|
||||
}
|
41
util_test.go
Normal file
41
util_test.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getDummyPrivateKey() (*rsa.PrivateKey, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func getDummyCertificate(privateKey *rsa.PrivateKey) (*x509.Certificate, error) {
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1234),
|
||||
Subject: pkix.Name{Organization: []string{"My Organization"}},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cert, nil
|
||||
}
|
Loading…
Reference in a new issue