Skip to content

Commit 0c4a379

Browse files
feat: implement laravel mailer
- Added `pkg/mail` package with `Mailer` interface. - Implemented `SMTPMailer` supporting `net/smtp` with TLS/STARTTLS. - Implemented `LogMailer` for local development. - Added factory to create mailer based on configuration. - Added comprehensive tests for mailer implementations. - Ensured security by sanitizing headers to prevent injection. - Fixed recipient logic to include To, Cc, and Bcc in SMTP envelope.
1 parent ff55c2e commit 0c4a379

5 files changed

Lines changed: 415 additions & 0 deletions

File tree

pkg/mail/factory.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package mail
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/pixelvide/laravel-go/pkg/config"
7+
)
8+
9+
// NewMailer creates a new Mailer based on the configuration
10+
func NewMailer(cfg config.MailConfig) (Mailer, error) {
11+
switch cfg.Mailer {
12+
case "smtp":
13+
return NewSMTPMailer(cfg), nil
14+
case "log":
15+
return NewLogMailer(cfg), nil
16+
default:
17+
return nil, fmt.Errorf("unsupported mailer: %s", cfg.Mailer)
18+
}
19+
}

pkg/mail/log.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package mail
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/pixelvide/laravel-go/pkg/config"
8+
"github.com/rs/zerolog/log"
9+
)
10+
11+
// LogMailer implements Mailer by logging messages
12+
type LogMailer struct {
13+
cfg config.MailConfig
14+
}
15+
16+
// NewLogMailer creates a new LogMailer
17+
func NewLogMailer(cfg config.MailConfig) *LogMailer {
18+
return &LogMailer{cfg: cfg}
19+
}
20+
21+
// Send logs the message details
22+
func (m *LogMailer) Send(ctx context.Context, msg *Message) error {
23+
// Set default From address if not provided
24+
if msg.From == "" {
25+
if m.cfg.FromAddress != "" {
26+
msg.From = m.cfg.FromAddress
27+
if m.cfg.FromName != "" {
28+
msg.From = fmt.Sprintf("%s <%s>", m.cfg.FromName, m.cfg.FromAddress)
29+
}
30+
}
31+
}
32+
33+
logger := log.Ctx(ctx).With().
34+
Str("mailer", "log").
35+
Str("from", msg.From).
36+
Strs("to", msg.To).
37+
Str("subject", msg.Subject).
38+
Str("content_type", msg.ContentType).
39+
Logger()
40+
41+
if len(msg.Cc) > 0 {
42+
logger = logger.With().Strs("cc", msg.Cc).Logger()
43+
}
44+
if len(msg.Bcc) > 0 {
45+
logger = logger.With().Strs("bcc", msg.Bcc).Logger()
46+
}
47+
48+
logger.Info().Msg("Sending email")
49+
50+
// Also log the body for debugging purposes, but maybe at debug level or just printed
51+
// Since this is a "log" mailer, the purpose is to see the email.
52+
logger.Info().Msgf("Body:\n%s", msg.Body)
53+
54+
return nil
55+
}

pkg/mail/mailer.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package mail
2+
3+
import "context"
4+
5+
// Message represents an email message
6+
type Message struct {
7+
From string
8+
To []string
9+
Cc []string
10+
Bcc []string
11+
Subject string
12+
Body string
13+
ContentType string // e.g., "text/plain", "text/html"
14+
}
15+
16+
// Mailer is the interface for sending emails
17+
type Mailer interface {
18+
// Send sends the given message
19+
Send(ctx context.Context, msg *Message) error
20+
}

pkg/mail/mailer_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package mail
2+
3+
import (
4+
"context"
5+
"bytes"
6+
"testing"
7+
"strings"
8+
9+
"github.com/pixelvide/laravel-go/pkg/config"
10+
"github.com/rs/zerolog"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestFactory(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
config config.MailConfig
18+
wantType interface{}
19+
expectErr bool
20+
}{
21+
{
22+
name: "smtp",
23+
config: config.MailConfig{
24+
Mailer: "smtp",
25+
},
26+
wantType: &SMTPMailer{},
27+
expectErr: false,
28+
},
29+
{
30+
name: "log",
31+
config: config.MailConfig{
32+
Mailer: "log",
33+
},
34+
wantType: &LogMailer{},
35+
expectErr: false,
36+
},
37+
{
38+
name: "invalid",
39+
config: config.MailConfig{
40+
Mailer: "invalid",
41+
},
42+
wantType: nil,
43+
expectErr: true,
44+
},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
got, err := NewMailer(tt.config)
50+
if tt.expectErr {
51+
assert.Error(t, err)
52+
} else {
53+
assert.NoError(t, err)
54+
assert.IsType(t, tt.wantType, got)
55+
}
56+
})
57+
}
58+
}
59+
60+
func TestLogMailer_Send(t *testing.T) {
61+
// Capture log output
62+
var buf bytes.Buffer
63+
logger := zerolog.New(&buf)
64+
ctx := logger.WithContext(context.Background())
65+
66+
cfg := config.MailConfig{
67+
Mailer: "log",
68+
FromAddress: "test@example.com",
69+
FromName: "Test Sender",
70+
}
71+
mailer := NewLogMailer(cfg)
72+
73+
msg := &Message{
74+
To: []string{"recipient@example.com"},
75+
Subject: "Test Subject",
76+
Body: "Test Body",
77+
}
78+
79+
err := mailer.Send(ctx, msg)
80+
assert.NoError(t, err)
81+
82+
output := buf.String()
83+
assert.Contains(t, output, "Sending email")
84+
assert.Contains(t, output, "Test Sender <test@example.com>")
85+
assert.Contains(t, output, "recipient@example.com")
86+
assert.Contains(t, output, "Test Subject")
87+
assert.Contains(t, output, "Test Body")
88+
}
89+
90+
func TestSMTPHelper_ParseEmail(t *testing.T) {
91+
tests := []struct {
92+
input string
93+
expected string
94+
wantErr bool
95+
}{
96+
{"test@example.com", "test@example.com", false},
97+
{"Name <test@example.com>", "test@example.com", false},
98+
{"<test@example.com>", "test@example.com", false},
99+
{"Invalid <test@example.com", "", true}, // net/mail is strict
100+
}
101+
102+
for _, tt := range tests {
103+
t.Run(tt.input, func(t *testing.T) {
104+
got, err := parseEmailAddress(tt.input)
105+
if tt.wantErr {
106+
assert.Error(t, err)
107+
} else {
108+
assert.NoError(t, err)
109+
assert.Equal(t, tt.expected, got)
110+
}
111+
})
112+
}
113+
}
114+
115+
func TestSMTPHelper_BuildBody(t *testing.T) {
116+
msg := &Message{
117+
From: "sender@example.com",
118+
To: []string{"to@example.com"},
119+
Subject: "Test",
120+
Body: "Body",
121+
ContentType: "text/html",
122+
}
123+
124+
body, err := buildEmailBody(msg)
125+
assert.NoError(t, err)
126+
assert.Contains(t, body, "From: sender@example.com")
127+
assert.Contains(t, body, "To: to@example.com")
128+
assert.Contains(t, body, "Subject: Test")
129+
assert.Contains(t, body, "Content-Type: text/html")
130+
assert.True(t, strings.HasSuffix(body, "\r\n\r\nBody"))
131+
}
132+
133+
func TestSMTPHelper_BuildBody_Sanitization(t *testing.T) {
134+
msg := &Message{
135+
From: "sender@example.com",
136+
To: []string{"to@example.com"},
137+
Subject: "Test\r\nInjected: Header",
138+
Body: "Body",
139+
}
140+
141+
body, err := buildEmailBody(msg)
142+
assert.NoError(t, err)
143+
assert.Contains(t, body, "Subject: TestInjected: Header")
144+
assert.NotContains(t, body, "Subject: Test\r\n")
145+
}
146+
147+
func TestSMTPHelper_Recipients(t *testing.T) {
148+
msg := &Message{
149+
To: []string{"to1@example.com", "to2@example.com"},
150+
Cc: []string{"cc1@example.com"},
151+
Bcc: []string{"bcc1@example.com"},
152+
}
153+
154+
recipients := getAllRecipients(msg)
155+
assert.Len(t, recipients, 4)
156+
assert.Contains(t, recipients, "to1@example.com")
157+
assert.Contains(t, recipients, "cc1@example.com")
158+
assert.Contains(t, recipients, "bcc1@example.com")
159+
}

0 commit comments

Comments
 (0)