Skip to content

Commit f3e87ec

Browse files
authored
Merge pull request #14 from gojangframework/codex/email-queueing
email queueing
2 parents 21f3ed3 + 446ad32 commit f3e87ec

4 files changed

Lines changed: 502 additions & 0 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ SMTP_PORT=587
1717
SMTP_USER=
1818
SMTP_PASS=
1919
SMTP_FROM=noreply@gojang.local
20+
SMTP_FROM_NAME=Gojang
21+
EMAIL_SEND_RATE=14
22+
EMAIL_QUEUE_SIZE=1000
23+
EMAIL_WORKER_COUNT=14
24+
EMAIL_SEND_TIMEOUT=15s

gojang/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ type Config struct {
2424
SMTPUser string `env:"SMTP_USER"`
2525
SMTPPass string `env:"SMTP_PASS"`
2626
SMTPFrom string `env:"SMTP_FROM" envDefault:"noreply@localhost"`
27+
28+
// Email queue settings
29+
SMTPFromName string `env:"SMTP_FROM_NAME"`
30+
EmailSendRate int `env:"EMAIL_SEND_RATE" envDefault:"14"`
31+
EmailQueueSize int `env:"EMAIL_QUEUE_SIZE" envDefault:"1000"`
32+
EmailWorkerCount int `env:"EMAIL_WORKER_COUNT" envDefault:"14"`
33+
EmailSendTimeout time.Duration `env:"EMAIL_SEND_TIMEOUT" envDefault:"15s"`
2734
}
2835

2936
func Load() (*Config, error) {

gojang/utils/email.go

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
package utils
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"fmt"
8+
"mime"
9+
"net/mail"
10+
"net/smtp"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
"golang.org/x/time/rate"
16+
)
17+
18+
// EmailConfig configures SMTP email delivery and queue behavior.
19+
type EmailConfig struct {
20+
SMTPHost string
21+
SMTPPort int
22+
SMTPUser string
23+
SMTPPass string
24+
FromAddress string
25+
FromDisplayName string
26+
MaxSendRate int
27+
QueueSize int
28+
WorkerCount int
29+
SendTimeout time.Duration
30+
}
31+
32+
// EmailMessage represents an email to send.
33+
type EmailMessage struct {
34+
To []string
35+
Cc []string
36+
Bcc []string
37+
Subject string
38+
Body string
39+
IsHTML bool
40+
}
41+
42+
type emailJob struct {
43+
msg EmailMessage
44+
}
45+
46+
type emailSender interface {
47+
SendMail(ctx context.Context, from string, to []string, msg []byte) error
48+
}
49+
50+
type smtpEmailSender struct {
51+
addr string
52+
auth smtp.Auth
53+
}
54+
55+
func (s smtpEmailSender) SendMail(ctx context.Context, from string, to []string, msg []byte) error {
56+
result := make(chan error, 1)
57+
go func() {
58+
result <- smtp.SendMail(s.addr, s.auth, from, to, msg)
59+
}()
60+
61+
select {
62+
case err := <-result:
63+
return err
64+
case <-ctx.Done():
65+
return ctx.Err()
66+
}
67+
}
68+
69+
// EmailService queues email for asynchronous SMTP delivery.
70+
type EmailService struct {
71+
sender emailSender
72+
fromAddress string
73+
fromHeader string
74+
queue chan emailJob
75+
limiter *rate.Limiter
76+
sendTimeout time.Duration
77+
78+
mu sync.Mutex
79+
closed bool
80+
81+
cancel context.CancelFunc
82+
wg sync.WaitGroup
83+
}
84+
85+
// NewEmailService creates an SMTP-backed email queue.
86+
func NewEmailService(cfg EmailConfig) (*EmailService, error) {
87+
if strings.TrimSpace(cfg.SMTPHost) == "" {
88+
return nil, fmt.Errorf("SMTP host is required")
89+
}
90+
if strings.TrimSpace(cfg.FromAddress) == "" {
91+
return nil, fmt.Errorf("from address is required")
92+
}
93+
94+
port := cfg.SMTPPort
95+
if port <= 0 {
96+
port = 587
97+
}
98+
99+
var auth smtp.Auth
100+
if cfg.SMTPUser != "" || cfg.SMTPPass != "" {
101+
auth = smtp.PlainAuth("", cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPHost)
102+
}
103+
104+
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, port)
105+
return newEmailServiceWithSender(smtpEmailSender{addr: addr, auth: auth}, cfg), nil
106+
}
107+
108+
func newEmailServiceWithSender(sender emailSender, cfg EmailConfig) *EmailService {
109+
maxSendRate := cfg.MaxSendRate
110+
if maxSendRate <= 0 {
111+
maxSendRate = 14
112+
}
113+
114+
queueSize := cfg.QueueSize
115+
if queueSize <= 0 {
116+
queueSize = 1000
117+
}
118+
119+
workerCount := cfg.WorkerCount
120+
if workerCount <= 0 {
121+
workerCount = maxSendRate
122+
}
123+
if workerCount > queueSize {
124+
workerCount = queueSize
125+
}
126+
if workerCount <= 0 {
127+
workerCount = 1
128+
}
129+
130+
sendTimeout := cfg.SendTimeout
131+
if sendTimeout <= 0 {
132+
sendTimeout = 15 * time.Second
133+
}
134+
135+
fromHeader := cfg.FromAddress
136+
if cfg.FromDisplayName != "" {
137+
fromHeader = (&mail.Address{Name: cfg.FromDisplayName, Address: cfg.FromAddress}).String()
138+
}
139+
140+
ctx, cancel := context.WithCancel(context.Background())
141+
service := &EmailService{
142+
sender: sender,
143+
fromAddress: cfg.FromAddress,
144+
fromHeader: fromHeader,
145+
queue: make(chan emailJob, queueSize),
146+
limiter: rate.NewLimiter(rate.Limit(maxSendRate), 1),
147+
sendTimeout: sendTimeout,
148+
cancel: cancel,
149+
}
150+
151+
for range workerCount {
152+
service.wg.Add(1)
153+
go service.worker(ctx)
154+
}
155+
156+
return service
157+
}
158+
159+
func (e *EmailService) worker(ctx context.Context) {
160+
defer e.wg.Done()
161+
162+
for job := range e.queue {
163+
if err := e.sendQueuedEmail(ctx, &job.msg); err != nil {
164+
Warnw("email.job_failed", "error", err, "to", job.msg.To, "subject", job.msg.Subject)
165+
}
166+
}
167+
}
168+
169+
func (e *EmailService) sendQueuedEmail(ctx context.Context, msg *EmailMessage) error {
170+
if err := e.limiter.Wait(ctx); err != nil {
171+
return fmt.Errorf("email worker stopped before send: %w", err)
172+
}
173+
174+
sendCtx, cancel := context.WithTimeout(ctx, e.sendTimeout)
175+
defer cancel()
176+
177+
raw, err := e.buildMessage(msg)
178+
if err != nil {
179+
return err
180+
}
181+
182+
recipients := append([]string(nil), msg.To...)
183+
recipients = append(recipients, msg.Cc...)
184+
recipients = append(recipients, msg.Bcc...)
185+
186+
if err := e.sender.SendMail(sendCtx, e.fromAddress, recipients, raw); err != nil {
187+
Errorw("email.send_failed", "error", err, "to", msg.To, "subject", msg.Subject)
188+
return fmt.Errorf("failed to send email: %w", err)
189+
}
190+
191+
Infow("email.sent", "to", msg.To, "subject", msg.Subject)
192+
return nil
193+
}
194+
195+
func (e *EmailService) buildMessage(msg *EmailMessage) ([]byte, error) {
196+
if msg == nil {
197+
return nil, fmt.Errorf("email message is required")
198+
}
199+
200+
var body bytes.Buffer
201+
writeHeader(&body, "From", e.fromHeader)
202+
writeHeader(&body, "To", strings.Join(msg.To, ", "))
203+
if len(msg.Cc) > 0 {
204+
writeHeader(&body, "Cc", strings.Join(msg.Cc, ", "))
205+
}
206+
writeHeader(&body, "Subject", mime.QEncoding.Encode("UTF-8", msg.Subject))
207+
writeHeader(&body, "MIME-Version", "1.0")
208+
if msg.IsHTML {
209+
writeHeader(&body, "Content-Type", `text/html; charset="UTF-8"`)
210+
} else {
211+
writeHeader(&body, "Content-Type", `text/plain; charset="UTF-8"`)
212+
}
213+
writeHeader(&body, "Content-Transfer-Encoding", "base64")
214+
body.WriteString("\r\n")
215+
216+
encoded := make([]byte, base64.StdEncoding.EncodedLen(len([]byte(msg.Body))))
217+
base64.StdEncoding.Encode(encoded, []byte(msg.Body))
218+
for len(encoded) > 76 {
219+
body.Write(encoded[:76])
220+
body.WriteString("\r\n")
221+
encoded = encoded[76:]
222+
}
223+
body.Write(encoded)
224+
body.WriteString("\r\n")
225+
226+
return body.Bytes(), nil
227+
}
228+
229+
func writeHeader(buf *bytes.Buffer, key, value string) {
230+
buf.WriteString(key)
231+
buf.WriteString(": ")
232+
buf.WriteString(value)
233+
buf.WriteString("\r\n")
234+
}
235+
236+
func cloneEmailMessage(msg *EmailMessage) EmailMessage {
237+
return EmailMessage{
238+
To: append([]string(nil), msg.To...),
239+
Cc: append([]string(nil), msg.Cc...),
240+
Bcc: append([]string(nil), msg.Bcc...),
241+
Subject: msg.Subject,
242+
Body: msg.Body,
243+
IsHTML: msg.IsHTML,
244+
}
245+
}
246+
247+
// SendEmail queues an email for asynchronous delivery.
248+
func (e *EmailService) SendEmail(msg *EmailMessage) error {
249+
if msg == nil {
250+
return fmt.Errorf("email message is required")
251+
}
252+
if len(msg.To) == 0 {
253+
return fmt.Errorf("at least one recipient is required")
254+
}
255+
256+
job := emailJob{msg: cloneEmailMessage(msg)}
257+
258+
e.mu.Lock()
259+
defer e.mu.Unlock()
260+
261+
if e.closed {
262+
return fmt.Errorf("email service is shutting down")
263+
}
264+
265+
select {
266+
case e.queue <- job:
267+
Infow("email.queued", "to", job.msg.To, "subject", job.msg.Subject)
268+
return nil
269+
default:
270+
Warnw("email.queue_full", "to", job.msg.To, "subject", job.msg.Subject)
271+
return fmt.Errorf("email queue is full")
272+
}
273+
}
274+
275+
// Shutdown stops accepting new email and drains queued jobs until ctx expires.
276+
func (e *EmailService) Shutdown(ctx context.Context) error {
277+
e.mu.Lock()
278+
if e.closed {
279+
e.mu.Unlock()
280+
return nil
281+
}
282+
e.closed = true
283+
close(e.queue)
284+
e.mu.Unlock()
285+
286+
done := make(chan struct{})
287+
go func() {
288+
e.wg.Wait()
289+
close(done)
290+
}()
291+
292+
select {
293+
case <-done:
294+
e.cancel()
295+
return nil
296+
case <-ctx.Done():
297+
e.cancel()
298+
<-done
299+
return ctx.Err()
300+
}
301+
}
302+
303+
// SendPlainEmail queues a plain text email.
304+
func (e *EmailService) SendPlainEmail(to []string, subject, body string) error {
305+
return e.SendEmail(&EmailMessage{
306+
To: to,
307+
Subject: subject,
308+
Body: body,
309+
IsHTML: false,
310+
})
311+
}
312+
313+
// SendHTMLEmail queues an HTML email.
314+
func (e *EmailService) SendHTMLEmail(to []string, subject, htmlBody string) error {
315+
return e.SendEmail(&EmailMessage{
316+
To: to,
317+
Subject: subject,
318+
Body: htmlBody,
319+
IsHTML: true,
320+
})
321+
}

0 commit comments

Comments
 (0)