Compare commits
10 Commits
aca3105039
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 537fbeebd9 | |||
| 34f9238cf0 | |||
| e04ed76fee | |||
| b57eb95497 | |||
| 9844ebc65d | |||
| 2028190971 | |||
| 85ca7512c6 | |||
| dca0236b06 | |||
| 5b9abc5af1 | |||
| 2dd66cbaac |
@@ -9,14 +9,14 @@ import (
|
||||
|
||||
func main() {
|
||||
// Here fill with real data
|
||||
emailService := email.NewInsecure(email.MailServiceConfig{
|
||||
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.EmailMessage{
|
||||
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>",
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
||||
module gitea.urkob.com/urko/emailsender
|
||||
|
||||
go 1.21.1
|
||||
go 1.23.4
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"slices"
|
||||
"time"
|
||||
@@ -17,6 +18,31 @@ const (
|
||||
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
|
||||
@@ -38,7 +64,16 @@ type EmailService struct {
|
||||
dial SmtpDialFn
|
||||
}
|
||||
|
||||
func NewInsecure(config MailServiceConfig) *EmailService {
|
||||
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,
|
||||
@@ -52,9 +87,75 @@ func NewInsecure(config MailServiceConfig) *EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
var validCommonNames = []string{"ISRG Root X1", "R3", "DST Root CA X3"}
|
||||
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 NewSecure(config MailServiceConfig) *EmailService {
|
||||
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,
|
||||
@@ -63,63 +164,30 @@ func NewSecure(config MailServiceConfig) *EmailService {
|
||||
tlsconfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
ServerName: config.Host,
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
|
||||
// // Check the server's common name
|
||||
// for _, cert := range cs.PeerCertificates {
|
||||
// log.Println("cert.DNSNames", cert.DNSNames)
|
||||
// if err := cert.VerifyHostname(config.Host); err != nil {
|
||||
// return fmt.Errorf("invalid common name: %w", err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// Check the certificate chain
|
||||
opts := x509.VerifyOptions{
|
||||
Intermediates: x509.NewCertPool(),
|
||||
}
|
||||
for _, cert := range cs.PeerCertificates[1:] {
|
||||
opts.Intermediates.AddCert(cert)
|
||||
}
|
||||
_, err := cs.PeerCertificates[0].Verify(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid certificate chain: %w", err)
|
||||
}
|
||||
|
||||
// Iterate over the certificates again to perform custom checks
|
||||
for _, cert := range cs.PeerCertificates {
|
||||
// TODO: add more checks here...
|
||||
if time.Now().After(cert.NotAfter) {
|
||||
return fmt.Errorf("certificate has expired")
|
||||
}
|
||||
if time.Now().Add(30 * 24 * time.Hour).After(cert.NotAfter) {
|
||||
return fmt.Errorf("certificate will expire within 30 days")
|
||||
}
|
||||
|
||||
if !slices.Contains(validCommonNames, cert.Issuer.CommonName) {
|
||||
return fmt.Errorf("certificate is not issued by a trusted CA")
|
||||
}
|
||||
// log.Println("cert.ExtKeyUsage", cert.ExtKeyUsage)
|
||||
// if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 || len(cert.ExtKeyUsage) == 0 || !slices.Contains(cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth) {
|
||||
// log.Printf("%+v", cert)
|
||||
// return fmt.Errorf("certificate cannot be used for server authentication")
|
||||
// }
|
||||
if cert.PublicKeyAlgorithm != x509.RSA {
|
||||
return fmt.Errorf("unsupported public key algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
VerifyConnection: customVerify(config.Host),
|
||||
},
|
||||
dial: dial,
|
||||
}
|
||||
}
|
||||
|
||||
type MailServiceConfig struct {
|
||||
Auth smtp.Auth
|
||||
Host string
|
||||
Port string
|
||||
From string // Sender email address
|
||||
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) {
|
||||
@@ -130,7 +198,26 @@ func dial(hostPort string) (SMTPClientIface, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (e *EmailService) SendEmail(emailData EmailMessage) error {
|
||||
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)
|
||||
|
||||
@@ -138,7 +225,21 @@ func (e *EmailService) SendEmail(emailData EmailMessage) error {
|
||||
return fmt.Errorf("error while preparing email: %w", err)
|
||||
}
|
||||
|
||||
return e.send(emailData.To, msg)
|
||||
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 {
|
||||
@@ -147,10 +248,60 @@ func (e *EmailService) send(to string, msg []byte) error {
|
||||
return fmt.Errorf("DIAL: %s", err)
|
||||
}
|
||||
|
||||
if err = c.StartTLS(e.tlsconfig); err != nil {
|
||||
return fmt.Errorf("c.StartTLS: %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)
|
||||
@@ -187,6 +338,7 @@ func (e *EmailService) send(to string, msg []byte) error {
|
||||
if err = c.Quit(); err != nil {
|
||||
return fmt.Errorf("w.Quit: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -236,3 +388,16 @@ func (m message) withAttachments(body string, attachments []EmailAttachment) ([]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestNewConfig_MissingEnvFile(t *testing.T) {
|
||||
func TestMockSendEmail(t *testing.T) {
|
||||
service := NewMockMailService(func(params ...interface{}) {})
|
||||
|
||||
emailData := EmailMessage{
|
||||
emailData := MessageWithAttachments{
|
||||
To: "test@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
@@ -60,7 +60,7 @@ func TestMockSendEmail(t *testing.T) {
|
||||
func TestNewInsecure(t *testing.T) {
|
||||
cfg := newConfig(".env.test")
|
||||
|
||||
mailSrv := NewInsecure(MailServiceConfig{
|
||||
mailSrv := NewInsecure(SecureConfig{
|
||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
@@ -68,7 +68,7 @@ func TestNewInsecure(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TestSendEmail", func(t *testing.T) {
|
||||
data := EmailMessage{
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
@@ -89,7 +89,7 @@ func TestNewInsecure(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer reader3.Close()
|
||||
|
||||
data := EmailMessage{
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
@@ -120,7 +120,7 @@ func TestNewInsecure(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TestSendEmail_InvalidRecipient", func(t *testing.T) {
|
||||
data := EmailMessage{
|
||||
data := MessageWithAttachments{
|
||||
To: "invalid_email",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
@@ -131,13 +131,13 @@ func TestNewInsecure(t *testing.T) {
|
||||
|
||||
t.Run("TestSendEmail_FailedAuthentication", func(t *testing.T) {
|
||||
// set up authentication to fail
|
||||
mailSrv := NewInsecure(MailServiceConfig{
|
||||
mailSrv := NewInsecure(SecureConfig{
|
||||
Auth: smtp.PlainAuth("", "wronguser", "wrongpassword", cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
From: cfg.MailFrom,
|
||||
})
|
||||
data := EmailMessage{
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email.",
|
||||
@@ -150,7 +150,7 @@ func TestNewInsecure(t *testing.T) {
|
||||
func TestSecure(t *testing.T) {
|
||||
cfg := newConfig(".env.test")
|
||||
|
||||
emailService := NewSecure(MailServiceConfig{
|
||||
emailService := NewSecure(SecureConfig{
|
||||
Auth: smtp.PlainAuth("", cfg.MailUser, cfg.MailPassword, cfg.MailHost),
|
||||
Host: cfg.MailHost,
|
||||
Port: cfg.MailPort,
|
||||
@@ -172,7 +172,7 @@ func TestSecure(t *testing.T) {
|
||||
}
|
||||
emailService.dial = mockDialFn
|
||||
|
||||
data := EmailMessage{
|
||||
data := MessageWithAttachments{
|
||||
To: cfg.MailTo,
|
||||
Subject: "Mail Sender",
|
||||
Body: "Hello this is a test email",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user