Compare commits

..

21 Commits

Author SHA1 Message Date
537fbeebd9 fix: customVerify cert alg + update valid CNs 2025-10-20 18:57:29 +00:00
34f9238cf0 fix; raw message no subject 2025-05-29 10:53:09 +02:00
e04ed76fee feat: SendRaw 2025-05-29 09:34:24 +02:00
b57eb95497 refactor: insecure no auth/tls 2025-05-29 09:24:26 +02:00
9844ebc65d refactor: remove file 2025-03-19 15:45:33 +01:00
2028190971 refactor: add secure with tls 2025-03-17 14:16:26 +01:00
85ca7512c6 chore: bump go version 2025-03-17 12:08:04 +01:00
dca0236b06 fix: update certbot r10 2024-08-22 15:11:42 +02:00
5b9abc5af1 fix: certbot issuer 2024-08-20 12:05:40 +02:00
2dd66cbaac fix: check common names 2024-08-20 12:02:23 +02:00
aca3105039 fix: readme 2023-12-26 10:09:54 +01:00
529b7dd153 feat: add ci lint config file 2023-12-26 10:09:44 +01:00
83ef9c2076 feat: test secure 2023-12-26 09:51:06 +01:00
624a269454 refactor: constructor functions 2023-12-26 09:16:21 +01:00
1371889d6d feat: add callback function to mock 2023-10-21 21:31:07 +02:00
a946f769ac feat: expose mock email service function 2023-10-21 20:56:32 +02:00
9eb969a4c3 feat: expose mocks 2023-10-21 20:51:20 +02:00
fc005a74c3 refactor: project structure 2023-10-21 20:31:31 +02:00
1bbdbf751b refactor: rename module 2023-10-21 19:07:26 +02:00
bbf75783e7 feat: add README 2023-10-21 19:05:48 +02:00
c004e66643 refactor: project structure 2023-10-21 19:05:42 +02:00
16 changed files with 889 additions and 394 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
# dotenv environment variables file
.env
.env*
coverage

79
.golangci.yml Executable file
View 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
View 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

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
# Email Sender
## Description
`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
- Send HTML emails.
- Attach multiple files to the email.
- Built-in support for TLS encryption.
- Simple API for sending emails.
## Installation
Clone this repository:
```bash
git clone https://gitea.urkob.com/urko/emailsender.git
```
## Usage
Check examples in [examples](https://gitea.urkob.com/urko/emailsender/examples)
## Dependencies
- Go's standard `net/smtp` package
- Go's standard `crypto/tls` package for secure email sending.
## Contribution
Feel free to submit issues or pull requests if you find any bugs or have suggestions for improvements.

View File

@@ -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
}

View File

@@ -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
View 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
View File

@@ -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

View 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
}

View 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
View 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
View 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
View 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
}