Skip to content

Commit cd2b2ab

Browse files
feauture: central email service for all emails
1 parent 00477bf commit cd2b2ab

File tree

13 files changed

+1113
-20
lines changed

13 files changed

+1113
-20
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
.Trashes
1414
ehthumbs.db
1515
Thumbs.db
16-
16+
backend/**/.env
1717
# Terraform
1818
infra/.terraform/
1919
infra/.terraform.lock.hcl

backend/email-service/Dockerfile

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,18 @@
1-
FROM golang:1.24-alpine AS builder
1+
FROM golang:1.21-alpine AS builder
22

33
WORKDIR /app
4-
5-
RUN apk add --no-cache git ca-certificates tzdata
6-
74
COPY go.mod go.sum ./
85
RUN go mod download
96

107
COPY . .
8+
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
119

12-
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o server ./cmd
13-
14-
FROM scratch
15-
16-
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
17-
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
18-
19-
COPY --from=builder /app/server /server
10+
FROM alpine:latest
11+
RUN apk --no-cache add ca-certificates
12+
WORKDIR /root/
2013

21-
EXPOSE 8082
14+
COPY --from=builder /app/main .
15+
COPY --from=builder /app/templates ./templates/
2216

23-
CMD ["/server"]
17+
EXPOSE 8080
18+
CMD ["./main"]

backend/email-service/cmd/main.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"os"
8+
"os/signal"
9+
"runtime"
10+
"sync"
11+
"syscall"
12+
"time"
13+
14+
"github.com/multi-tenants-cms-golang/email-service/config"
15+
"github.com/multi-tenants-cms-golang/email-service/internal/email"
16+
"github.com/multi-tenants-cms-golang/email-service/internal/health"
17+
natss "github.com/multi-tenants-cms-golang/email-service/internal/nats"
18+
"github.com/nats-io/nats.go"
19+
"go.uber.org/zap"
20+
)
21+
22+
func main() {
23+
logger, err := zap.NewProduction()
24+
if err != nil {
25+
panic(err)
26+
}
27+
defer func(logger *zap.Logger) {
28+
err := logger.Sync()
29+
if err != nil {
30+
panic(err.Error())
31+
}
32+
}(logger)
33+
34+
cfg, err := config.Load()
35+
if err != nil {
36+
logger.Fatal("failed to load configuration", zap.Error(err))
37+
}
38+
39+
// Connect to NATS
40+
nc, err := nats.Connect(cfg.NATS.URL,
41+
nats.ReconnectWait(time.Second),
42+
nats.MaxReconnects(-1),
43+
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
44+
logger.Error("NATS disconnected", zap.Error(err))
45+
}),
46+
nats.ReconnectHandler(func(nc *nats.Conn) {
47+
logger.Info("NATS reconnected")
48+
}),
49+
)
50+
if err != nil {
51+
logger.Fatal("failed to connect to NATS", zap.Error(err))
52+
}
53+
defer nc.Close()
54+
55+
emailCh := make(chan natss.EmailRequest, 100)
56+
57+
// Create email service
58+
emailService, err := email.NewService(
59+
logger,
60+
cfg.SMTP.Host,
61+
cfg.SMTP.Port,
62+
cfg.SMTP.User,
63+
cfg.SMTP.Password,
64+
cfg.SMTP.FromAddr,
65+
cfg.SMTP.TemplateDir,
66+
)
67+
if err != nil {
68+
logger.Fatal("failed to create email service", zap.Error(err))
69+
}
70+
71+
// Create NATS consumer
72+
natsConsumer, err := natss.NewConsumer(
73+
nc,
74+
logger,
75+
emailCh,
76+
cfg.NATS.StreamName,
77+
cfg.NATS.Subject,
78+
cfg.NATS.ConsumerName,
79+
)
80+
if err != nil {
81+
logger.Fatal("failed to create NATS consumer", zap.Error(err))
82+
}
83+
84+
// Create contexts
85+
ctx, cancel := context.WithCancel(context.Background())
86+
defer cancel()
87+
88+
var wg sync.WaitGroup
89+
90+
// Start worker goroutines
91+
workerCount := runtime.NumCPU()
92+
logger.Info("starting email workers", zap.Int("count", workerCount))
93+
94+
for i := 0; i < workerCount; i++ {
95+
wg.Add(1)
96+
go func(workerID int) {
97+
defer wg.Done()
98+
logger.Info("worker started", zap.Int("id", workerID))
99+
100+
for {
101+
select {
102+
case req, ok := <-emailCh:
103+
if !ok {
104+
logger.Info("worker stopping", zap.Int("id", workerID))
105+
return
106+
}
107+
108+
if err := emailService.Send(ctx, req); err != nil {
109+
logger.Error("failed to send email",
110+
zap.Int("worker", workerID),
111+
zap.String("to", req.To),
112+
zap.String("template", req.Template),
113+
zap.Error(err))
114+
}
115+
case <-ctx.Done():
116+
logger.Info("worker stopping due to context cancellation", zap.Int("id", workerID))
117+
return
118+
}
119+
}
120+
}(i)
121+
}
122+
123+
// Start NATS consumer
124+
wg.Add(1)
125+
go func() {
126+
defer wg.Done()
127+
if err := natsConsumer.Start(ctx); err != nil {
128+
logger.Error("NATS consumer failed", zap.Error(err))
129+
}
130+
}()
131+
132+
// Start health server
133+
healthServer := health.NewServer(cfg.Server.Port, logger)
134+
wg.Add(1)
135+
go func() {
136+
defer wg.Done()
137+
if err := healthServer.Start(); !errors.Is(err, http.ErrServerClosed) && err != nil {
138+
logger.Error("health server failed", zap.Error(err))
139+
}
140+
}()
141+
142+
// Wait for shutdown signal
143+
sigCh := make(chan os.Signal, 1)
144+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
145+
<-sigCh
146+
147+
logger.Info("shutdown signal received, starting graceful shutdown")
148+
149+
// Create shutdown context
150+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
151+
defer shutdownCancel()
152+
153+
// Stop health server
154+
if err := healthServer.Stop(shutdownCtx); err != nil {
155+
logger.Error("failed to stop health server gracefully", zap.Error(err))
156+
}
157+
158+
// Cancel main context to stop all goroutines
159+
cancel()
160+
161+
close(emailCh)
162+
163+
done := make(chan struct{})
164+
go func() {
165+
wg.Wait()
166+
close(done)
167+
}()
168+
169+
select {
170+
case <-done:
171+
logger.Info("all goroutines stopped")
172+
case <-shutdownCtx.Done():
173+
logger.Warn("shutdown timeout exceeded, forcing exit")
174+
}
175+
176+
logger.Info("shutdown complete")
177+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strconv"
7+
"time"
8+
)
9+
10+
type Config struct {
11+
Server ServerConfig
12+
NATS NATSConfig
13+
SMTP SMTPConfig
14+
}
15+
16+
type ServerConfig struct {
17+
Port string
18+
ShutdownTimeout time.Duration
19+
}
20+
21+
type NATSConfig struct {
22+
URL string
23+
StreamName string
24+
Subject string
25+
ConsumerName string
26+
}
27+
28+
type SMTPConfig struct {
29+
Host string
30+
Port int
31+
User string
32+
Password string
33+
FromAddr string
34+
TemplateDir string
35+
}
36+
37+
func Load() (*Config, error) {
38+
smtpPortStr := os.Getenv("SMTP_PORT")
39+
if smtpPortStr == "" {
40+
smtpPortStr = "587"
41+
}
42+
43+
smtpPort, err := strconv.Atoi(smtpPortStr)
44+
if err != nil {
45+
return nil, fmt.Errorf("invalid SMTP_PORT: %w", err)
46+
}
47+
48+
cfg := &Config{
49+
Server: ServerConfig{
50+
Port: getEnvOrDefault("SERVER_PORT", "8080"),
51+
ShutdownTimeout: 30 * time.Second,
52+
},
53+
NATS: NATSConfig{
54+
URL: getEnvOrDefault("NATS_URL", "nats://localhost:4222"),
55+
StreamName: getEnvOrDefault("STREAM_NAME", "email-stream"),
56+
Subject: getEnvOrDefault("SUBJECT_TO", "email.send"),
57+
ConsumerName: getEnvOrDefault("CONSUMER_NAME", "email-consumer"),
58+
},
59+
SMTP: SMTPConfig{
60+
Host: getEnvOrDefault("SMTP_HOST", "localhost"),
61+
Port: smtpPort,
62+
User: os.Getenv("SMTP_USER"),
63+
Password: os.Getenv("SMTP_PASSWORD"),
64+
FromAddr: os.Getenv("FROM_ADDR"),
65+
TemplateDir: getEnvOrDefault("TEMPLATE_DIR", "./templates"),
66+
},
67+
}
68+
69+
if err := cfg.validate(); err != nil {
70+
return nil, fmt.Errorf("config validation failed: %w", err)
71+
}
72+
73+
return cfg, nil
74+
}
75+
76+
func (c *Config) validate() error {
77+
if c.SMTP.User == "" {
78+
return fmt.Errorf("SMTP_USER is required")
79+
}
80+
if c.SMTP.Password == "" {
81+
return fmt.Errorf("SMTP_PASSWORD is required")
82+
}
83+
if c.SMTP.FromAddr == "" {
84+
return fmt.Errorf("FROM_ADDR is required")
85+
}
86+
return nil
87+
}
88+
89+
func getEnvOrDefault(key, defaultValue string) string {
90+
if value := os.Getenv(key); value != "" {
91+
return value
92+
}
93+
return defaultValue
94+
}

backend/email-service/go.mod

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
module github.com/multi-tenants-cms-golang/email-service
2+
3+
go 1.24
4+
5+
require (
6+
github.com/nats-io/nats.go v1.43.0
7+
github.com/wneessen/go-mail v0.6.2
8+
go.uber.org/zap v1.27.0
9+
)
10+
11+
require (
12+
github.com/klauspost/compress v1.18.0 // indirect
13+
github.com/nats-io/nkeys v0.4.11 // indirect
14+
github.com/nats-io/nuid v1.0.1 // indirect
15+
go.uber.org/multierr v1.10.0 // indirect
16+
golang.org/x/crypto v0.37.0 // indirect
17+
golang.org/x/sys v0.32.0 // indirect
18+
golang.org/x/text v0.24.0 // indirect
19+
)

0 commit comments

Comments
 (0)