Compare commits
19 Commits
bbf75783e7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 537fbeebd9 | |||
| 34f9238cf0 | |||
| e04ed76fee | |||
| b57eb95497 | |||
| 9844ebc65d | |||
| 2028190971 | |||
| 85ca7512c6 | |||
| dca0236b06 | |||
| 5b9abc5af1 | |||
| 2dd66cbaac | |||
| aca3105039 | |||
| 529b7dd153 | |||
| 83ef9c2076 | |||
| 624a269454 | |||
| 1371889d6d | |||
| a946f769ac | |||
| 9eb969a4c3 | |||
| fc005a74c3 | |||
| 1bbdbf751b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env*
|
||||
.env*
|
||||
|
||||
coverage
|
||||
79
.golangci.yml
Executable file
79
.golangci.yml
Executable file
@@ -0,0 +1,79 @@
|
||||
run:
|
||||
tests: false
|
||||
timeout: 3m
|
||||
skip-dirs:
|
||||
- cmd/local
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unused
|
||||
- unconvert
|
||||
- gocritic
|
||||
- exportloopref
|
||||
- whitespace
|
||||
- misspell
|
||||
- thelper
|
||||
- revive
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*github.com/gin-gonic/gin.Context).AbortWithError
|
||||
- (*github.com/gin-gonic/gin.Context).Error
|
||||
- (github.com/gin-gonic/gin.ResponseWriter).WriteString
|
||||
- (net/http.ResponseWriter).Write
|
||||
- fmt.Fprintf
|
||||
- fmt.Fprintln
|
||||
- (*github.com/jlaffaye/ftp.Response).Close
|
||||
- (*github.com/jlaffaye/ftp.ServerConn).Quit
|
||||
- (golang.org/x/crypto/ssh.Conn).Close
|
||||
- (*github.com/pkg/sftp.File).Close
|
||||
- (*github.com/pkg/sftp.clientConn).Close
|
||||
- (*compress/gzip.Reader).Close
|
||||
- (io.Closer).Close
|
||||
- (*os.File).Close
|
||||
- (io/fs.File).Close
|
||||
- (*github.com/gocraft/work.Enqueuer).Enqueue
|
||||
- (*encoding/xml.Encoder).EncodeToken
|
||||
- (*encoding/xml.Encoder).EncodeElement
|
||||
- (*encoding/xml.Encoder).Flush
|
||||
- (*encoding/xml.Encoder).Encode
|
||||
- (io.Writer).Write
|
||||
- (*encoding/csv.Writer).Write
|
||||
- os.Remove
|
||||
- (*os.File).Seek
|
||||
- (*os.File).WriteString
|
||||
- (*go.uber.org/zap.Logger).Sync
|
||||
- io.Copy
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: var-naming
|
||||
severity: error
|
||||
disabled: false
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- style
|
||||
- performance
|
||||
disabled-checks:
|
||||
- singleCaseSwitch
|
||||
- unnecessaryBlock
|
||||
- unnamedResult
|
||||
- paramTypeCombine
|
||||
- emptyStringTest
|
||||
- regexpSimplify
|
||||
- preferStringWriter
|
||||
- badRegexp
|
||||
- emptyFallthrough
|
||||
- unlabelStmt
|
||||
- nestingReduce
|
||||
- hugeParam
|
||||
# TODO: enable after testing
|
||||
- rangeValCopy
|
||||
13
Makefile
Normal file
13
Makefile
Normal file
@@ -0,0 +1,13 @@
|
||||
COVERAGE_DIR=coverage
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
goreportcard:
|
||||
goreportcard-cli -v
|
||||
test:
|
||||
go test ./...
|
||||
test-coverage:
|
||||
rm -rf ${COVERAGE_DIR}
|
||||
mkdir ${COVERAGE_DIR}
|
||||
go test -v -coverprofile ${COVERAGE_DIR}/cover.out ./...
|
||||
go tool cover -html ${COVERAGE_DIR}/cover.out -o ${COVERAGE_DIR}/cover.html
|
||||
42
README.md
42
README.md
@@ -1,8 +1,8 @@
|
||||
# Mail Sender
|
||||
# Email Sender
|
||||
|
||||
## Description
|
||||
|
||||
`mail-sender` is a simple Go library designed to send emails with optional attachments. It's built on top of the standard Go `net/smtp` library with additional support for sending HTML emails and handling multiple attachments.
|
||||
`email-sender` is a simple Go library designed to send emails with optional attachments. It's built on top of the standard Go `net/smtp` library with additional support for sending HTML emails and handling multiple attachments.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -15,45 +15,13 @@
|
||||
|
||||
Clone this repository:
|
||||
|
||||
```
|
||||
git clone https://gitea.urkob.com/urko/mail-sender.git
|
||||
```bash
|
||||
git clone https://gitea.urkob.com/urko/emailsender.git
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Here's a basic example on how to use the `mail-sender`:
|
||||
|
||||
1. **Initialize the Email Service**
|
||||
|
||||
```go
|
||||
config := email.MailServiceConfig{
|
||||
Auth: smtp.PlainAuth("", "your@email.com", "your-password", "smtp.youremail.com"),
|
||||
Host: "smtp.youremail.com",
|
||||
Port: "587",
|
||||
From: "your@email.com",
|
||||
}
|
||||
mailService := email.NewMailService(config)
|
||||
```
|
||||
|
||||
2. **Send an Email with an Attachment**
|
||||
|
||||
```go
|
||||
emailData := email.EmailMessage{
|
||||
To: "receiver@email.com",
|
||||
Subject: "Test Email",
|
||||
Body: "<h1>Hello!</h1><p>This is a test email.</p>",
|
||||
Attachments: []email.EmailAttachment{
|
||||
{
|
||||
File: attachmentFile, // This is an io.Reader
|
||||
Title: "document.pdf",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := mailService.SendEmail(emailData)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
Check examples in [examples](https://gitea.urkob.com/urko/emailsender/examples)
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
220
email.go
220
email.go
@@ -1,220 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
const (
|
||||
mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
|
||||
delimeter = "**=myohmy689407924327"
|
||||
)
|
||||
|
||||
type smtpClient interface {
|
||||
StartTLS(*tls.Config) error
|
||||
Auth(a smtp.Auth) error
|
||||
Close() error
|
||||
Data() (io.WriteCloser, error)
|
||||
Mail(from string) error
|
||||
Quit() error
|
||||
Rcpt(to string) error
|
||||
}
|
||||
|
||||
type SmtpDialFn func(hostPort string) (smtpClient, error)
|
||||
|
||||
type EmailService struct {
|
||||
auth smtp.Auth
|
||||
host string
|
||||
port string
|
||||
from string
|
||||
tlsconfig *tls.Config
|
||||
dial SmtpDialFn
|
||||
}
|
||||
|
||||
type SMTPClient struct {
|
||||
client smtp.Client
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Auth(a smtp.Auth) error {
|
||||
return c.client.Auth(a)
|
||||
}
|
||||
func (c *SMTPClient) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
func (c *SMTPClient) Data() (io.WriteCloser, error) {
|
||||
return c.client.Data()
|
||||
}
|
||||
func (c *SMTPClient) Mail(from string) error {
|
||||
return c.client.Mail(from)
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Quit() error {
|
||||
return c.client.Quit()
|
||||
}
|
||||
func (c *SMTPClient) Rcpt(to string) error {
|
||||
return c.client.Rcpt(to)
|
||||
}
|
||||
|
||||
func (c *SMTPClient) StartTLS(config *tls.Config) error {
|
||||
return c.client.StartTLS(config)
|
||||
}
|
||||
|
||||
type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
Attachments []EmailAttachment
|
||||
}
|
||||
|
||||
type EmailAttachment struct {
|
||||
File io.Reader
|
||||
Title string
|
||||
}
|
||||
|
||||
func (e EmailAttachment) ReadContent() ([]byte, error) {
|
||||
bts, err := io.ReadAll(e.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading attachment: %s", err)
|
||||
}
|
||||
return bts, nil
|
||||
}
|
||||
|
||||
type MailServiceConfig struct {
|
||||
Auth smtp.Auth
|
||||
Host string
|
||||
Port string
|
||||
From string // Sender email address
|
||||
}
|
||||
|
||||
func dial(hostPort string) (smtpClient, error) {
|
||||
client, err := smtp.Dial(hostPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func NewMailService(config MailServiceConfig) *EmailService {
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
},
|
||||
dial: dial,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmailService) SendEmail(emailData EmailMessage) error {
|
||||
msg, err := newMessage(e.from, emailData.To, emailData.Subject).
|
||||
withAttachments(emailData.Body, emailData.Attachments)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while preparing email: %w", err)
|
||||
}
|
||||
|
||||
return e.send(emailData.To, msg)
|
||||
}
|
||||
|
||||
func (e *EmailService) send(to string, msg []byte) error {
|
||||
c, err := e.dial(e.host + ":" + e.port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DIAL: %s", err)
|
||||
}
|
||||
|
||||
if err = c.StartTLS(e.tlsconfig); err != nil {
|
||||
return fmt.Errorf("c.StartTLS: %s", err)
|
||||
}
|
||||
|
||||
// Auth
|
||||
if err = c.Auth(e.auth); err != nil {
|
||||
return fmt.Errorf("c.Auth: %s", err)
|
||||
}
|
||||
|
||||
// To && From
|
||||
if err = c.Mail(e.from); err != nil {
|
||||
return fmt.Errorf("c.Mail: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("c.Rcpt: %s", err)
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("c.Data: %s", err)
|
||||
}
|
||||
|
||||
written, err := w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("w.Write: %s", err)
|
||||
}
|
||||
|
||||
if written <= 0 {
|
||||
return fmt.Errorf("%d bytes written", written)
|
||||
}
|
||||
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("w.Close: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Quit(); err != nil {
|
||||
return fmt.Errorf("w.Quit: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type message struct {
|
||||
from string
|
||||
to string
|
||||
subject string
|
||||
}
|
||||
|
||||
func newMessage(from, to, subject string) message {
|
||||
return message{from: from, to: to, subject: subject}
|
||||
}
|
||||
|
||||
func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) {
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = m.from
|
||||
headers["To"] = m.to
|
||||
headers["Subject"] = m.subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
|
||||
var message bytes.Buffer
|
||||
|
||||
for k, v := range headers {
|
||||
message.WriteString(k)
|
||||
message.WriteString(": ")
|
||||
message.WriteString(v)
|
||||
message.WriteString("\r\n")
|
||||
}
|
||||
|
||||
message.WriteString("Content-Type: " + fmt.Sprintf("multipart/mixed; boundary=\"%s\"\r\n", delimeter))
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
||||
message.WriteString(body + "\r\n\r\n")
|
||||
|
||||
for _, attachment := range attachments {
|
||||
attachmentRawFile, err := attachment.ReadContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n")
|
||||
message.WriteString("Content-Type: application/octet-stream\r\n")
|
||||
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n")
|
||||
}
|
||||
|
||||
message.WriteString("--" + delimeter + "--") // End the message
|
||||
return message.Bytes(), nil
|
||||
}
|
||||
171
email_test.go
171
email_test.go
@@ -1,171 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
MailUser string `required:"false" split_words:"true"`
|
||||
MailPassword string `required:"false" split_words:"true"`
|
||||
MailHost string `required:"false" split_words:"true"`
|
||||
MailPort string `required:"false" split_words:"true"`
|
||||
MailFrom string `required:"false" split_words:"true"`
|
||||
MailTo string `required:"false" split_words:"true"`
|
||||
}
|
||||
|
||||
func newConfig(envFile string) *config {
|
||||
if envFile != "" {
|
||||
err := godotenv.Load(envFile)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("godotenv.Load: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &config{}
|
||||
err := envconfig.Process("", cfg)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("envconfig.Process: %w", err))
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
type mockWriter struct{}
|
||||
|
||||
func (w *mockWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
func (w *mockWriter) Write(p []byte) (n int, err error) {
|
||||
return 10, nil
|
||||
}
|
||||
|
||||
// Mock SMTP Client
|
||||
type mockSMTP struct{}
|
||||
|
||||
func (m *mockSMTP) StartTLS(*tls.Config) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Auth(a smtp.Auth) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Close() error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Data() (io.WriteCloser, error) {
|
||||
return &mockWriter{}, nil
|
||||
}
|
||||
func (m *mockSMTP) Mail(from string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Quit() error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Rcpt(to string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMockSendEmail(t *testing.T) {
|
||||
service := &EmailService{
|
||||
auth: smtp.PlainAuth("", "", "", ""),
|
||||
host: "",
|
||||
port: "",
|
||||
from: "",
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "",
|
||||
},
|
||||
dial: func(hostPort string) (smtpClient, error) {
|
||||
return &mockSMTP{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
emailData := EmailMessage{
|
||||
To: "test@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
}
|
||||
|
||||
err := service.SendEmail(emailData)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail(t *testing.T) {
|
||||
cfg := newConfig(".env.test")
|
||||
|
||||
mailSrv := NewMailService(MailServiceConfig{
|
||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
From: cfg.MailFrom,
|
||||
})
|
||||
|
||||
data := EmailMessage{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
}
|
||||
require.NoError(t, mailSrv.SendEmail(data))
|
||||
}
|
||||
|
||||
func TestSendEmailWithAttachments(t *testing.T) {
|
||||
cfg := newConfig(".env.test")
|
||||
|
||||
mailSrv := NewMailService(MailServiceConfig{
|
||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
From: cfg.MailFrom,
|
||||
})
|
||||
reader, err := os.Open("testdata/attachment1.txt")
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
reader2, err := os.Open("testdata/attachment2.txt")
|
||||
require.NoError(t, err)
|
||||
defer reader2.Close()
|
||||
|
||||
reader3, err := os.Open("testdata/attachment3.txt")
|
||||
require.NoError(t, err)
|
||||
defer reader3.Close()
|
||||
|
||||
data := EmailMessage{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
Attachments: []EmailAttachment{
|
||||
{
|
||||
Title: "attachment1.txt",
|
||||
File: reader,
|
||||
},
|
||||
{
|
||||
Title: "attachment2.txt",
|
||||
File: reader2,
|
||||
},
|
||||
{
|
||||
Title: "attachment3.txt",
|
||||
File: reader3,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = mailSrv.SendEmail(data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWithAttachments(t *testing.T) {
|
||||
msg := newMessage("from", "to", "subject")
|
||||
content, err := msg.withAttachments("body", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, len(content), 0)
|
||||
}
|
||||
30
examples/main.go
Normal file
30
examples/main.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/smtp"
|
||||
|
||||
"gitea.urkob.com/urko/emailsender/pkg/email"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Here fill with real data
|
||||
emailService := email.NewInsecure(email.SecureConfig{
|
||||
Auth: smtp.PlainAuth("", "your@email.com", "your-password", "smtp.youremail.com"),
|
||||
Host: "smtp.youremail.com",
|
||||
Port: "587",
|
||||
From: "your@email.com",
|
||||
})
|
||||
|
||||
emailService.SendEmail(email.MessageWithAttachments{
|
||||
To: "other@email.com",
|
||||
Subject: "Test Email",
|
||||
Body: "<html><body><p>Here your body, you can attach as html<p/></body></html>",
|
||||
Attachments: []email.EmailAttachment{
|
||||
{
|
||||
File: bytes.NewBuffer([]byte("test")), // This is an io.Reader
|
||||
Title: "document.pdf",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
4
go.mod
4
go.mod
@@ -1,6 +1,6 @@
|
||||
module gitea.urkob.com/urko/mail-sender
|
||||
module gitea.urkob.com/urko/emailsender
|
||||
|
||||
go 1.21.1
|
||||
go 1.23.4
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
42
internal/attachments/attachments.go
Normal file
42
internal/attachments/attachments.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package attachments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const delimeter = "**=myohmy689407924327"
|
||||
|
||||
type EmailAttachment struct {
|
||||
File io.Reader
|
||||
Title string
|
||||
}
|
||||
|
||||
func (e EmailAttachment) ReadContent() ([]byte, error) {
|
||||
bts, err := io.ReadAll(e.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading attachment: %s", err)
|
||||
}
|
||||
return bts, nil
|
||||
}
|
||||
|
||||
func AttachmentsToBytes(body string, attachments []EmailAttachment) ([]byte, error) {
|
||||
var message bytes.Buffer
|
||||
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
||||
message.WriteString(body + "\r\n\r\n")
|
||||
for _, attachment := range attachments {
|
||||
attachmentRawFile, err := attachment.ReadContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n")
|
||||
message.WriteString("Content-Type: application/octet-stream\r\n")
|
||||
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n")
|
||||
}
|
||||
message.WriteString("--" + delimeter + "--") // End the message
|
||||
return message.Bytes(), nil
|
||||
}
|
||||
39
internal/smtpclient/smtpclient.go
Normal file
39
internal/smtpclient/smtpclient.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package smtpclient
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
type SMTPClient struct {
|
||||
client smtp.Client
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Auth(a smtp.Auth) error {
|
||||
return c.client.Auth(a)
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Close() error {
|
||||
return c.client.Close()
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Data() (io.WriteCloser, error) {
|
||||
return c.client.Data()
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Mail(from string) error {
|
||||
return c.client.Mail(from)
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Quit() error {
|
||||
return c.client.Quit()
|
||||
}
|
||||
|
||||
func (c *SMTPClient) Rcpt(to string) error {
|
||||
return c.client.Rcpt(to)
|
||||
}
|
||||
|
||||
func (c *SMTPClient) StartTLS(config *tls.Config) error {
|
||||
return c.client.StartTLS(config)
|
||||
}
|
||||
403
pkg/email/email.go
Normal file
403
pkg/email/email.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mime = "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n"
|
||||
delimeter = "**=myohmy689407924327"
|
||||
)
|
||||
|
||||
type InsecureConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
From string // Sender email address
|
||||
}
|
||||
|
||||
type SecureConfig struct {
|
||||
Auth smtp.Auth
|
||||
Host string
|
||||
Port string
|
||||
From string // Sender email address
|
||||
}
|
||||
|
||||
type MessageWithAttachments struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
Attachments []EmailAttachment
|
||||
}
|
||||
|
||||
type RawMessage struct {
|
||||
To string
|
||||
Body string
|
||||
}
|
||||
|
||||
type SMTPClientIface interface {
|
||||
StartTLS(*tls.Config) error
|
||||
Auth(a smtp.Auth) error
|
||||
Close() error
|
||||
Data() (io.WriteCloser, error)
|
||||
Mail(from string) error
|
||||
Quit() error
|
||||
Rcpt(to string) error
|
||||
}
|
||||
|
||||
type SmtpDialFn func(hostPort string) (SMTPClientIface, error)
|
||||
|
||||
type EmailService struct {
|
||||
auth smtp.Auth
|
||||
host string
|
||||
port string
|
||||
from string
|
||||
tlsconfig *tls.Config
|
||||
dial SmtpDialFn
|
||||
}
|
||||
|
||||
func NewInsecureNoAuth(config InsecureConfig) *EmailService {
|
||||
return &EmailService{
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
dial: dial,
|
||||
}
|
||||
}
|
||||
|
||||
func NewInsecure(config SecureConfig) *EmailService {
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
},
|
||||
dial: dial,
|
||||
}
|
||||
}
|
||||
|
||||
var validCommonNames = []string{
|
||||
"ISRG Root X1",
|
||||
"R3",
|
||||
"R10",
|
||||
"R13",
|
||||
"R11",
|
||||
"E5",
|
||||
"E7",
|
||||
"DST Root CA X3",
|
||||
"DigiCert Global Root G2",
|
||||
"DigiCert Global G2 TLS RSA SHA256 2020 CA1",
|
||||
}
|
||||
|
||||
func customVerify(host string) func(cs tls.ConnectionState) error {
|
||||
return func(cs tls.ConnectionState) error {
|
||||
// Ensure we have at least one peer certificate
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return fmt.Errorf("no peer certificates provided")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Set up verification options with a DNSName check.
|
||||
// This will perform hostname verification automatically.
|
||||
opts := x509.VerifyOptions{
|
||||
CurrentTime: now,
|
||||
DNSName: host, // assuming config.Host is accessible here
|
||||
Intermediates: x509.NewCertPool(),
|
||||
}
|
||||
// Add all certificates except the leaf as intermediates.
|
||||
for i := 1; i < len(cs.PeerCertificates); i++ {
|
||||
opts.Intermediates.AddCert(cs.PeerCertificates[i])
|
||||
}
|
||||
|
||||
// Verify the certificate chain (including hostname check via opts.DNSName)
|
||||
if _, err := cs.PeerCertificates[0].Verify(opts); err != nil {
|
||||
return fmt.Errorf("certificate chain verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Perform additional custom checks
|
||||
for _, cert := range cs.PeerCertificates {
|
||||
if now.After(cert.NotAfter) {
|
||||
return fmt.Errorf("certificate expired on %s", cert.NotAfter)
|
||||
}
|
||||
if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) {
|
||||
return fmt.Errorf("certificate will expire soon on %s", cert.NotAfter)
|
||||
}
|
||||
|
||||
// Check that the issuer's CommonName is in our allowed list.
|
||||
if !slices.Contains(validCommonNames, cert.Issuer.CommonName) {
|
||||
return fmt.Errorf("untrusted certificate issuer: %s", cert.Issuer.CommonName)
|
||||
}
|
||||
|
||||
// Check that the public key algorithms
|
||||
switch cert.PublicKeyAlgorithm {
|
||||
case x509.RSA, x509.ECDSA:
|
||||
// OK
|
||||
default:
|
||||
return fmt.Errorf("unsupported public key algorithm: %v",
|
||||
cert.PublicKeyAlgorithm)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewSecure(config SecureConfig) *EmailService {
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
VerifyConnection: customVerify(config.Host),
|
||||
},
|
||||
dial: dial,
|
||||
}
|
||||
}
|
||||
|
||||
func NewSecure465(config SecureConfig) *EmailService {
|
||||
tlsCfg := tls.Config{
|
||||
// Ideally, InsecureSkipVerify: false,
|
||||
// or do a proper certificate validation
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
VerifyConnection: customVerify(config.Host),
|
||||
}
|
||||
return &EmailService{
|
||||
auth: config.Auth,
|
||||
host: config.Host,
|
||||
port: config.Port,
|
||||
from: config.From,
|
||||
tlsconfig: &tlsCfg,
|
||||
dial: func(hostPort string) (SMTPClientIface, error) {
|
||||
return dialTLS(hostPort, &tlsCfg)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dial(hostPort string) (SMTPClientIface, error) {
|
||||
client, err := smtp.Dial(hostPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func dialTLS(hostPort string, tlsConfig *tls.Config) (SMTPClientIface, error) {
|
||||
// 1) Create a raw TCP connection
|
||||
conn, err := net.Dial("tcp", hostPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2) Wrap it with TLS
|
||||
tlsConn := tls.Client(conn, tlsConfig)
|
||||
|
||||
// 3) Now create the SMTP client on this TLS connection
|
||||
host, _, _ := net.SplitHostPort(hostPort)
|
||||
c, err := smtp.NewClient(tlsConn, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (e *EmailService) SendEmail(emailData MessageWithAttachments) error {
|
||||
msg, err := newMessage(e.from, emailData.To, emailData.Subject).
|
||||
withAttachments(emailData.Body, emailData.Attachments)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while preparing email: %w", err)
|
||||
}
|
||||
|
||||
switch e.port {
|
||||
case "465":
|
||||
return e.sendTLS(emailData.To, msg)
|
||||
default:
|
||||
return e.send(emailData.To, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmailService) SendRaw(emailData RawMessage) error {
|
||||
switch e.port {
|
||||
case "465":
|
||||
return e.sendTLS(emailData.To, []byte(emailData.Body))
|
||||
default:
|
||||
return e.send(emailData.To, []byte(emailData.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmailService) send(to string, msg []byte) error {
|
||||
c, err := e.dial(e.host + ":" + e.port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DIAL: %s", err)
|
||||
}
|
||||
|
||||
if e.tlsconfig != nil {
|
||||
if err = c.StartTLS(e.tlsconfig); err != nil {
|
||||
return fmt.Errorf("c.StartTLS: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
if e.auth != nil {
|
||||
if err = c.Auth(e.auth); err != nil {
|
||||
return fmt.Errorf("c.Auth: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// To && From
|
||||
if err = c.Mail(e.from); err != nil {
|
||||
return fmt.Errorf("c.Mail: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("c.Rcpt: %s", err)
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("c.Data: %s", err)
|
||||
}
|
||||
|
||||
written, err := w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("w.Write: %s", err)
|
||||
}
|
||||
|
||||
if written <= 0 {
|
||||
return fmt.Errorf("%d bytes written", written)
|
||||
}
|
||||
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("w.Close: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Quit(); err != nil {
|
||||
return fmt.Errorf("w.Quit: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailService) sendTLS(to string, msg []byte) error {
|
||||
c, err := e.dial(e.host + ":" + e.port) // dialTLS
|
||||
if err != nil {
|
||||
return fmt.Errorf("DIAL: %s", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Auth
|
||||
if err = c.Auth(e.auth); err != nil {
|
||||
return fmt.Errorf("c.Auth: %s", err)
|
||||
}
|
||||
|
||||
// To && From
|
||||
if err = c.Mail(e.from); err != nil {
|
||||
return fmt.Errorf("c.Mail: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("c.Rcpt: %s", err)
|
||||
}
|
||||
|
||||
// Data
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("c.Data: %s", err)
|
||||
}
|
||||
|
||||
written, err := w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("w.Write: %s", err)
|
||||
}
|
||||
|
||||
if written <= 0 {
|
||||
return fmt.Errorf("%d bytes written", written)
|
||||
}
|
||||
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("w.Close: %s", err)
|
||||
}
|
||||
|
||||
if err = c.Quit(); err != nil {
|
||||
return fmt.Errorf("w.Quit: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type message struct {
|
||||
from string
|
||||
to string
|
||||
subject string
|
||||
}
|
||||
|
||||
func newMessage(from, to, subject string) message {
|
||||
return message{from: from, to: to, subject: subject}
|
||||
}
|
||||
|
||||
func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) {
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = m.from
|
||||
headers["To"] = m.to
|
||||
headers["Subject"] = m.subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
|
||||
var message bytes.Buffer
|
||||
|
||||
for k, v := range headers {
|
||||
message.WriteString(k)
|
||||
message.WriteString(": ")
|
||||
message.WriteString(v)
|
||||
message.WriteString("\r\n")
|
||||
}
|
||||
|
||||
message.WriteString("Content-Type: " + fmt.Sprintf("multipart/mixed; boundary=\"%s\"\r\n", delimeter))
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n")
|
||||
message.WriteString(body + "\r\n\r\n")
|
||||
|
||||
for _, attachment := range attachments {
|
||||
attachmentRawFile, err := attachment.ReadContent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message.WriteString("--" + delimeter + "\r\n")
|
||||
message.WriteString("Content-Disposition: attachment; filename=\"" + attachment.Title + "\"\r\n")
|
||||
message.WriteString("Content-Type: application/octet-stream\r\n")
|
||||
message.WriteString("Content-Transfer-Encoding: base64\r\n\r\n")
|
||||
message.WriteString(base64.StdEncoding.EncodeToString(attachmentRawFile) + "\r\n")
|
||||
}
|
||||
|
||||
message.WriteString("--" + delimeter + "--") // End the message
|
||||
return message.Bytes(), nil
|
||||
}
|
||||
|
||||
type EmailAttachment struct {
|
||||
File io.Reader
|
||||
Title string
|
||||
}
|
||||
|
||||
func (e EmailAttachment) ReadContent() ([]byte, error) {
|
||||
bts, err := io.ReadAll(e.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading attachment: %s", err)
|
||||
}
|
||||
return bts, nil
|
||||
}
|
||||
184
pkg/email/email_test.go
Executable file
184
pkg/email/email_test.go
Executable file
@@ -0,0 +1,184 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
MailUser string `required:"false" split_words:"true"`
|
||||
MailPassword string `required:"false" split_words:"true"`
|
||||
MailHost string `required:"false" split_words:"true"`
|
||||
MailPort string `required:"false" split_words:"true"`
|
||||
MailFrom string `required:"false" split_words:"true"`
|
||||
MailTo string `required:"false" split_words:"true"`
|
||||
}
|
||||
|
||||
func newConfig(envFile string) *config {
|
||||
if envFile != "" {
|
||||
err := godotenv.Load(envFile)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("godotenv.Load: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &config{}
|
||||
err := envconfig.Process("", cfg)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("envconfig.Process: %w", err))
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestNewConfig_MissingEnvFile(t *testing.T) {
|
||||
assert.Panics(t, func() { newConfig(".missing_env_file") })
|
||||
}
|
||||
|
||||
func TestMockSendEmail(t *testing.T) {
|
||||
service := NewMockMailService(func(params ...interface{}) {})
|
||||
|
||||
emailData := MessageWithAttachments{
|
||||
To: "test@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
}
|
||||
|
||||
err := service.SendEmail(emailData)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInsecure(t *testing.T) {
|
||||
cfg := newConfig(".env.test")
|
||||
|
||||
mailSrv := NewInsecure(SecureConfig{
|
||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
From: cfg.MailFrom,
|
||||
})
|
||||
|
||||
t.Run("TestSendEmail", func(t *testing.T) {
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
}
|
||||
require.NoError(t, mailSrv.SendEmail(data))
|
||||
})
|
||||
|
||||
t.Run("TestSendEmailWithAttachments", func(t *testing.T) {
|
||||
reader, err := os.Open("testdata/attachment1.txt")
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
reader2, err := os.Open("testdata/attachment2.txt")
|
||||
require.NoError(t, err)
|
||||
defer reader2.Close()
|
||||
|
||||
reader3, err := os.Open("testdata/attachment3.txt")
|
||||
require.NoError(t, err)
|
||||
defer reader3.Close()
|
||||
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
Attachments: []EmailAttachment{
|
||||
{
|
||||
Title: "attachment1.txt",
|
||||
File: reader,
|
||||
},
|
||||
{
|
||||
Title: "attachment2.txt",
|
||||
File: reader2,
|
||||
},
|
||||
{
|
||||
Title: "attachment3.txt",
|
||||
File: reader3,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = mailSrv.SendEmail(data)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("TestWithAttachments", func(t *testing.T) {
|
||||
msg := newMessage("from", "to", "subject")
|
||||
content, err := msg.withAttachments("body", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, len(content), 0)
|
||||
})
|
||||
|
||||
t.Run("TestSendEmail_InvalidRecipient", func(t *testing.T) {
|
||||
data := MessageWithAttachments{
|
||||
To: "invalid_email",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
}
|
||||
err := mailSrv.SendEmail(data)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("TestSendEmail_FailedAuthentication", func(t *testing.T) {
|
||||
// set up authentication to fail
|
||||
mailSrv := NewInsecure(SecureConfig{
|
||||
Auth: smtp.PlainAuth("", "wronguser", "wrongpassword", cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
From: cfg.MailFrom,
|
||||
})
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
}
|
||||
err := mailSrv.SendEmail(data)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecure(t *testing.T) {
|
||||
cfg := newConfig(".env.test")
|
||||
|
||||
emailService := NewSecure(SecureConfig{
|
||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
From: cfg.MailFrom,
|
||||
})
|
||||
|
||||
// Assert that the tls.Config is set up correctly
|
||||
assert.NotNil(t, emailService.tlsconfig)
|
||||
assert.True(t, emailService.tlsconfig.InsecureSkipVerify)
|
||||
assert.Equal(t, cfg.MailHost, emailService.tlsconfig.ServerName)
|
||||
assert.NotNil(t, emailService.tlsconfig.VerifyConnection)
|
||||
|
||||
t.Run("TestSendEmail", func(t *testing.T) {
|
||||
// Mock the client and test the StartTLS method
|
||||
var called bool
|
||||
mockDialFn := func(hostPort string) (SMTPClientIface, error) {
|
||||
called = true
|
||||
return &mockSMTP{}, nil
|
||||
}
|
||||
emailService.dial = mockDialFn
|
||||
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
}
|
||||
require.NoError(t, emailService.SendEmail(data))
|
||||
assert.Equal(t, true, called)
|
||||
})
|
||||
|
||||
}
|
||||
61
pkg/email/mock.go
Normal file
61
pkg/email/mock.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
type MockCallbackFn func(params ...interface{})
|
||||
|
||||
func NewMockMailService(callbackFn MockCallbackFn, params ...interface{}) *EmailService {
|
||||
return &EmailService{
|
||||
auth: smtp.PlainAuth("", "", "", ""),
|
||||
host: "",
|
||||
port: "",
|
||||
from: "",
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: "",
|
||||
},
|
||||
dial: func(hostPort string) (SMTPClientIface, error) {
|
||||
callbackFn(params)
|
||||
return &mockSMTP{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type mockWriter struct{}
|
||||
|
||||
func (w *mockWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
func (w *mockWriter) Write(p []byte) (n int, err error) {
|
||||
return 10, nil
|
||||
}
|
||||
|
||||
// Mock SMTP Client
|
||||
type mockSMTP struct{}
|
||||
|
||||
func (m *mockSMTP) StartTLS(*tls.Config) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Auth(a smtp.Auth) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Close() error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Data() (io.WriteCloser, error) {
|
||||
return &mockWriter{}, nil
|
||||
}
|
||||
func (m *mockSMTP) Mail(from string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Quit() error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockSMTP) Rcpt(to string) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user