@@ -3,6 +3,8 @@ APP_URL=http://localhost:8000
SERVER_PORT=8000 PASSWORD_SALT=onasty NOTE_PASSWORD_SALT=secret + +METRICS_ENABLED=true METRICS_PORT=8001 LOG_LEVEL=debug@@ -24,9 +26,8 @@
REDIS_ADDR="redis:6379" CACHE_USERS_TTL=1h -MAILGUN_FROM=onasty@mail.com -MAILGUN_DOMAI='<domain>' -MAILGUN_API_KEY='<token>' +NATS_URL="nats:4222" + VERIFICATION_TOKEN_TTL=48h RATELIMITER_RPS=100
@@ -8,7 +8,7 @@ migrate: ./migrations/Taskfile.yml
tasks: run: - - docker compose up -d --build --remove-orphans core + - docker compose up -d --build --remove-orphans core mailer lint: - golangci-lint run
@@ -10,11 +10,12 @@ "os"
"os/signal" "github.com/gin-gonic/gin" + "github.com/nats-io/nats.go" "github.com/olexsmir/onasty/internal/config" + "github.com/olexsmir/onasty/internal/events/mailermq" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/logger" - "github.com/olexsmir/onasty/internal/mailer" "github.com/olexsmir/onasty/internal/metrics" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv"@@ -37,7 +38,7 @@ os.Exit(1)
} } -//nolint:err113 +//nolint:err113,funlen func run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel()@@ -58,6 +59,11 @@ gin.SetMode(gin.ReleaseMode)
} // app deps + nc, err := nats.Connect(cfg.NatsURL) + if err != nil { + return err + } + psqlDB, err := psqlutil.Connect(ctx, cfg.PostgresDSN) if err != nil { return err@@ -71,7 +77,8 @@
userPasswordHasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) notePasswordHasher := hasher.NewSHA256Hasher(cfg.NotePassowrdSalt) jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL) - mailGunMailer := mailer.NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) + + mailermq := mailermq.New(nc) sessionrepo := sessionrepo.New(psqlDB) vertokrepo := vertokrepo.New(psqlDB)@@ -84,7 +91,7 @@ sessionrepo,
vertokrepo, userPasswordHasher, jwtTokenizer, - mailGunMailer, + mailermq, usercache, cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL,
@@ -10,6 +10,14 @@ ports:
- 8000:8000 - 8001:8001 + mailer: + image: onasty:mailer + container_name: onasty-mailer + build: + context: . + dockerfile: ./mailer/Dockerfile + env_file: ./mailer/.env + postgres: image: postgres:16-alpine container_name: onasty-postgres@@ -21,6 +29,11 @@ volumes:
- .docker/postgres:/var/lib/postgresql/data ports: - 5432:5432 + + nats: + image: nats:2.10 + ports: + - 4222:4222 redis: image: redis:7.4-alpine
@@ -136,7 +136,6 @@ }),
) e.Equal(http.StatusOK, httpResp.Code) - e.NotEmpty(e.mailer.GetLastSentEmailToEmail(email)) } func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() {@@ -175,7 +174,7 @@
e.Equal(httpResp.Code, t.expectedCode) // no email should be sent - e.Empty(e.mailer.GetLastSentEmailToEmail(t.email)) + // e.Empty(e.mailer.GetLastSentEmailToEmail(t.email)) } }
@@ -17,7 +17,6 @@ "github.com/olexsmir/onasty/internal/config"
"github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" "github.com/olexsmir/onasty/internal/logger" - "github.com/olexsmir/onasty/internal/mailer" "github.com/olexsmir/onasty/internal/service/notesrv" "github.com/olexsmir/onasty/internal/service/usersrv" "github.com/olexsmir/onasty/internal/store/psql/noterepo"@@ -57,7 +56,6 @@
router http.Handler hasher hasher.Hasher jwtTokenizer jwtutil.JWTTokenizer - mailer *mailer.TestMailer } errorResponse struct { Message string `json:"message"`@@ -102,7 +100,6 @@ slog.SetDefault(logger)
e.hasher = hasher.NewSHA256Hasher(cfg.PasswordSalt) e.jwtTokenizer = jwtutil.NewJWTUtil(cfg.JwtSigningKey, time.Hour) - e.mailer = mailer.NewTestMailer() sessionrepo := sessionrepo.New(e.postgresDB) vertokrepo := vertokrepo.New(e.postgresDB)@@ -115,7 +112,7 @@ sessionrepo,
vertokrepo, e.hasher, e.jwtTokenizer, - e.mailer, + newMailerMockService(), usercache, cfg.JwtRefreshTokenTTL, cfg.VerificationTokenTTL,
@@ -0,0 +1,22 @@
+package e2e_test + +import ( + "context" + + "github.com/olexsmir/onasty/internal/events/mailermq" +) + +var _ mailermq.Mailer = (*mailerMockService)(nil) + +type mailerMockService struct{} + +func newMailerMockService() *mailerMockService { + return &mailerMockService{} +} + +func (m mailerMockService) SendVerificationEmail( + _ context.Context, + _ mailermq.SendVerificationEmailRequest, +) error { + return nil +}
@@ -11,6 +11,7 @@ github.com/henvic/pgq v0.0.3
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 github.com/jackc/pgx/v5 v5.7.2 github.com/mailgun/mailgun-go/v4 v4.21.0 + github.com/nats-io/nats.go v1.38.0 github.com/prometheus/client_golang v1.20.5 github.com/redis/go-redis/v9 v9.7.0 github.com/stretchr/testify v1.10.0@@ -85,6 +86,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nkeys v0.4.9 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -235,6 +235,12 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.38.0 h1:A7P+g7Wjp4/NWqDOOP/K6hfhr54DvdDQUznt5JFg9XA= +github.com/nats-io/nats.go v1.38.0/go.mod h1:IGUM++TwokGnXPs82/wCuiHS02/aKrdYUQkU8If6yjw= +github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0= +github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -10,4 +10,16 @@ scheme: http
follow_redirects: true honor_timestamps: true static_configs: - - targets: [core:8001] + - targets: [core:8001] + labels: + service: "core" + + - job_name: mailer + metrics_path: /metrics + scheme: http + follow_redirects: true + honor_labels: true + static_configs: + - targets: [mailer:8001] + labels: + service: "mailer"
@@ -11,6 +11,7 @@ type Config struct {
AppEnv string AppURL string ServerPort string + NatsURL string PostgresDSN string PasswordSalt string@@ -48,6 +49,7 @@ return &Config{
AppEnv: getenvOrDefault("APP_ENV", "debug"), AppURL: getenvOrDefault("APP_URL", ""), ServerPort: getenvOrDefault("SERVER_PORT", "3000"), + NatsURL: getenvOrDefault("NATS_URL", ""), PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""),
@@ -0,0 +1,36 @@
+package events + +import ( + "fmt" + + "github.com/nats-io/nats.go" +) + +const ( + natsHeaderErrorCode = "Nats-Service-Error-Code" + natsHeaderErrorMsg = "Nats-Service-Error" +) + +var _ error = (*Error)(nil) + +type Error struct { + Code string + Message string +} + +func (e Error) Error() string { + return fmt.Sprintf("code: %s; msg: %s", e.Code, e.Message) +} + +func CheckRespForError(resp *nats.Msg) error { + code := resp.Header.Get(natsHeaderErrorCode) + msg := resp.Header.Get(natsHeaderErrorMsg) + if code == "" && msg == "" { + return nil + } + + return &Error{ + Code: code, + Message: msg, + } +}
@@ -0,0 +1,62 @@
+package mailermq + +import ( + "context" + "encoding/json" + + "github.com/nats-io/nats.go" + "github.com/olexsmir/onasty/internal/events" + "github.com/olexsmir/onasty/internal/transport/http/reqid" +) + +type Mailer interface { + SendVerificationEmail(ctx context.Context, input SendVerificationEmailRequest) error +} + +type MailerMQ struct { + nc *nats.Conn +} + +const sendMailSubject = "mailer.send" + +func New(nc *nats.Conn) *MailerMQ { + return &MailerMQ{ + nc: nc, + } +} + +type sendRequest struct { + RequestID string `json:"request_id"` + Receiver string `json:"receiver"` + TemplateName string `json:"template_name"` + Options map[string]string `json:"options"` +} + +type SendVerificationEmailRequest struct { + Receiver string + Token string +} + +func (m MailerMQ) SendVerificationEmail( + ctx context.Context, + inp SendVerificationEmailRequest, +) error { + req, err := json.Marshal(sendRequest{ + RequestID: reqid.GetContext(ctx), + Receiver: inp.Receiver, + TemplateName: "email_verification", + Options: map[string]string{ + "token": inp.Token, + }, + }) + if err != nil { + return err + } + + resp, err := m.nc.RequestWithContext(ctx, sendMailSubject, req) + if err != nil { + return err + } + + return events.CheckRespForError(resp) +}
@@ -1,13 +1,16 @@
-package mailer +package main import ( "context" "log/slog" "github.com/mailgun/mailgun-go/v4" - "github.com/olexsmir/onasty/internal/metrics" "github.com/olexsmir/onasty/internal/transport/http/reqid" ) + +type Mailer interface { + Send(ctx context.Context, to, subject, content string) error +} var _ Mailer = (*Mailgun)(nil)@@ -33,13 +36,14 @@ slog.InfoContext(ctx, "email sent", "to", to)
_, _, err := m.mg.Send(ctx, msg) if err != nil { - metrics.RecordEmailFailed(reqid.GetContext(ctx)) + RecordEmailFailed(reqid.GetContext(ctx)) return err } slog.DebugContext(ctx, "email sent", "subject", subject, "content", content, "err", err) slog.InfoContext(ctx, "email sent", "to", to) - metrics.RecordEmailSent() + + RecordEmailSent() return nil }
@@ -1,42 +0,0 @@
-package mailer - -import ( - "context" - "sync" -) - -var _ Mailer = (*TestMailer)(nil) - -type TestMailer struct { - mu sync.Mutex - - emails map[string]string -} - -// NewTestMailer create a mailer for tests -// that implementation of Mailer stores all sent email in memory -// to get the last email sent to a specific email use GetLastSentEmailToEmail -func NewTestMailer() *TestMailer { - return &TestMailer{ //nolint:exhaustruct - emails: make(map[string]string), - } -} - -func (t *TestMailer) Send(_ context.Context, to, _, content string) error { - t.mu.Lock() - defer t.mu.Unlock() - - t.emails[to] = content - - return nil -} - -// GetLastSentEmailToEmail returns the last email sent to a specific email -func (t *TestMailer) GetLastSentEmailToEmail(email string) string { - t.mu.Lock() - defer t.mu.Unlock() - - e := t.emails[email] - - return e -}
@@ -1,33 +0,0 @@
-package mailer - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMailer_Send(t *testing.T) { - m := NewTestMailer() - assert.Empty(t, m.emails) - - email := "test@mail.com" - err := m.Send(context.TODO(), email, "", "content") - require.NoError(t, err) - - assert.Equal(t, "content", m.emails[email]) -} - -func TestMailer_GetLastSentEmailToEmail(t *testing.T) { - email := "test@mail.com" - content := "content" - - m := NewTestMailer() - assert.Empty(t, m.emails) - - m.emails[email] = content - - c := m.GetLastSentEmailToEmail(email) - assert.Equal(t, content, c) -}
@@ -1,42 +0,0 @@
-package usersrv - -import ( - "context" - "errors" - "fmt" - "log/slog" -) - -var ErrFailedToSendVerifcationEmail = errors.New("failed to send verification email") - -const ( - verificationEmailSubject = "Onasty: verify your email" - verificationEmailBody = `To verify your email, please follow this link: -<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> -<br /> -<br /> -This link will expire after 24 hours.` -) - -func (u *UserSrv) sendVerificationEmail( - ctx context.Context, - cancel context.CancelFunc, - userEmail string, - token string, - url string, -) { - select { - case <-ctx.Done(): - slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err()) - default: - if err := u.mailer.Send( - ctx, - userEmail, - verificationEmailSubject, - fmt.Sprintf(verificationEmailBody, url, token), - ); err != nil { - slog.ErrorContext(ctx, "failed to send verification email", "err", err) - } - cancel() - } -}
@@ -8,15 +8,14 @@ "time"
"github.com/gofrs/uuid/v5" "github.com/olexsmir/onasty/internal/dtos" + "github.com/olexsmir/onasty/internal/events/mailermq" "github.com/olexsmir/onasty/internal/hasher" "github.com/olexsmir/onasty/internal/jwtutil" - "github.com/olexsmir/onasty/internal/mailer" "github.com/olexsmir/onasty/internal/models" "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" "github.com/olexsmir/onasty/internal/store/psql/userepo" "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" "github.com/olexsmir/onasty/internal/store/rdb/usercache" - "github.com/olexsmir/onasty/internal/transport/http/reqid" ) type UserServicer interface {@@ -44,7 +43,7 @@ sessionstore sessionrepo.SessionStorer
vertokrepo vertokrepo.VerificationTokenStorer hasher hasher.Hasher jwtTokenizer jwtutil.JWTTokenizer - mailer mailer.Mailer + mailermq mailermq.Mailer cache usercache.UserCacheer refreshTokenTTL time.Duration@@ -58,7 +57,7 @@ sessionstore sessionrepo.SessionStorer,
vertokrepo vertokrepo.VerificationTokenStorer, hasher hasher.Hasher, jwtTokenizer jwtutil.JWTTokenizer, - mailer mailer.Mailer, + mailermq mailermq.Mailer, cache usercache.UserCacheer, refreshTokenTTL, verificationTokenTTL time.Duration, appURL string,@@ -69,7 +68,7 @@ sessionstore: sessionstore,
vertokrepo: vertokrepo, hasher: hasher, jwtTokenizer: jwtTokenizer, - mailer: mailer, + mailermq: mailermq, cache: cache, refreshTokenTTL: refreshTokenTTL, verificationTokenTTL: verificationTokenTTL,@@ -99,8 +98,12 @@ if err := u.vertokrepo.Create(ctx, vtok, uid, time.Now(), time.Now().Add(u.verificationTokenTTL)); err != nil {
return uuid.Nil, err } - sendingCtx, cancel := getContextForEmailSending(ctx) - go u.sendVerificationEmail(sendingCtx, cancel, inp.Email, vtok, u.appURL) + if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ + Receiver: inp.Email, + Token: vtok, + }); err != nil { + return uuid.Nil, err + } return uid, nil }@@ -221,8 +224,12 @@ if err != nil {
return err } - sendingCtx, cancel := getContextForEmailSending(ctx) - go u.sendVerificationEmail(sendingCtx, cancel, inp.Email, token, u.appURL) + if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ + Receiver: inp.Email, + Token: token, + }); err != nil { + return err + } return nil }@@ -285,11 +292,3 @@ Access: accessToken,
Refresh: refreshToken, }, err } - -func getContextForEmailSending(ctx context.Context) (context.Context, context.CancelFunc) { - rid := reqid.GetContext(ctx) - resCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - resCtx = reqid.SetContext(resCtx, rid) - - return resCtx, cancel -}
@@ -0,0 +1,10 @@
+APP_URL=http://localhost:8000 +NATS_URL="nats:4222" +METRICS_ENABLED=true +METRICS_PORT=8001 +LOG_LEVEL=debug +LOG_FORMAT=text +LOG_SHOW_LINE=true +MAILGUN_FROM=onasty@mail.com +MAILGUN_DOMAI='<domain>' +MAILGUN_API_KEY='<token>'
@@ -0,0 +1,18 @@
+FROM golang:1.23.3-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY internal internal +COPY mailer mailer + +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +RUN go build -trimpath -ldflags='-w -s' -o /mailer ./mailer + + +FROM alpine:3.20 +COPY --from=builder /mailer /mailer +RUN apk --no-cache add ca-certificates +ENTRYPOINT ["/mailer"]
@@ -0,0 +1,41 @@
+# mailer service + +All templates could be found *[here](./template.go)* + +## endpoints +### `mailer.ping` +This endpoint always returns pong message + +Response: +```json +{ + "message": "pong" +} +``` + +### `mailer.send` + +Input +- `request_id` : *string* - (optional) the request id, needed to keep consistency across services +- `receiver` : *string* - the email receiver +- `template_name` : *string* - the template that's going to be used +- `options` : *Map<string, string>* - template specific options + + +Example input +```json +{ + "request_id": "hello_world", + "receiver": "onasty@example.com", + "template_name": "email_verification", + "options": { + "token": "the_verification_token" + } +} +``` + +#### Template specific options +- `email_verification` + - `token` the token that is used in verification link + +
@@ -0,0 +1,40 @@
+package main + +import "os" + +type Config struct { + AppURL string + NatsURL string + MailgunFrom string + MailgunDomain string + MailgunAPIKey string + + LogLevel string + LogFormat string + LogShowLine bool + + MetricsEnabled bool + MetricsPort string +} + +func NewConfig() *Config { + return &Config{ + AppURL: getenvOrDefault("APP_URL", ""), + NatsURL: getenvOrDefault("NATS_URL", ""), + MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), + MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""), + MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""), + LogLevel: getenvOrDefault("LOG_LEVEL", "debug"), + LogFormat: getenvOrDefault("LOG_FORMAT", "json"), + LogShowLine: getenvOrDefault("LOG_SHOW_LINE", "true") == "true", + MetricsPort: getenvOrDefault("METRICS_PORT", ""), + MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true", + } +} + +func getenvOrDefault(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +}
@@ -0,0 +1,69 @@
+package main + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/nats-io/nats.go/micro" + "github.com/olexsmir/onasty/internal/transport/http/reqid" +) + +type Handlers struct { + service *Service +} + +func NewHandlers(service *Service) *Handlers { + return &Handlers{ + service: service, + } +} + +func (h Handlers) RegisterAll(svc micro.Service) error { + m := svc.AddGroup("mailer") + if err := m.AddEndpoint("ping", micro.HandlerFunc(h.pingHandler)); err != nil { + return err + } + + if err := m.AddEndpoint("send", micro.HandlerFunc(h.sendHandler)); err != nil { + return err + } + + return nil +} + +type pingResponse struct { + Message string `json:"message"` +} + +func (h Handlers) pingHandler(req micro.Request) { + _ = req.RespondJSON(pingResponse{ + Message: "pong", + }) +} + +type sendRequest struct { + RequestID string `json:"request_id"` + + Receiver string `json:"receiver"` + TemplateName string `json:"template_name"` + Options map[string]string `json:"options"` +} + +func (h Handlers) sendHandler(req micro.Request) { + var inp sendRequest + if err := json.Unmarshal(req.Data(), &inp); err != nil { + slog.Error("failed to unmarshal input data", "err", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx = reqid.SetContext(ctx, inp.RequestID) + + if err := h.service.Send(ctx, cancel, inp.Receiver, inp.TemplateName, inp.Options); err != nil { + _ = req.Error("500", err.Error(), nil) + } + + _ = req.Respond(nil) +}
@@ -0,0 +1,92 @@
+package main + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/micro" + "github.com/olexsmir/onasty/internal/logger" + "github.com/olexsmir/onasty/internal/transport/http/httpserver" + + _ "embed" +) + +//go:embed version +var _version string + +var version = strings.Trim(_version, "\n") + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg := NewConfig() + nc, err := nats.Connect(cfg.NatsURL) + if err != nil { + return err + } + + logger, err := logger.NewCustomLogger(cfg.LogLevel, cfg.LogFormat, cfg.LogShowLine) + if err != nil { + return err + } + + slog.SetDefault(logger) + + //nolint:exhaustruct + svc, err := micro.AddService(nc, micro.Config{ + Name: "mailer", + Version: version, + }) + if err != nil { + return err + } + + mg := NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) + service := NewService(cfg.AppURL, mg) + handlers := NewHandlers(service) + + if err := handlers.RegisterAll(svc); err != nil { + return err + } + + if cfg.MetricsEnabled { + srv := httpserver.NewServer(cfg.MetricsPort, MetricsHandler()) + go func() { + slog.Info("starting metrics server", "port", cfg.MetricsPort) + if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) { + slog.Error("failed to start metrics server", "error", err) + } + }() + } + + slog.Info("the service is listening") + + // graceful shutdown + quitCh := make(chan os.Signal, 1) + signal.Notify(quitCh, syscall.SIGINT, syscall.SIGTERM) + <-quitCh + + slog.Info("stopping the service") + + if err := svc.Stop(); err != nil { + return err + } + + if err := nc.Drain(); err != nil { + return err + } + + return nil +}
@@ -0,0 +1,37 @@
+package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + emailSentSuccessfully = promauto.NewCounter(prometheus.CounterOpts{ //nolint:exhaustruct + Name: "mail_sent_total", + Help: "the total number of successfully sent email", + }) + + emailFailedToSend = promauto.NewCounterVec(prometheus.CounterOpts{ //nolint:exhaustruct + Name: "mail_failed_total", + Help: "the total number of email that failed to send", + }, []string{"request_id"}) +) + +func MetricsHandler() http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /metrics", promhttp.Handler()) + return mux +} + +func RecordEmailSent() { + go emailSentSuccessfully.Inc() +} + +func RecordEmailFailed(reqid string) { + go emailFailedToSend.With(prometheus.Labels{ + "request_id": reqid, + }).Inc() +}
@@ -0,0 +1,47 @@
+package main + +import ( + "context" + "log/slog" +) + +type Service struct { + appURL string + mg *Mailgun +} + +func NewService(appURL string, mg *Mailgun) *Service { + return &Service{ + appURL: appURL, + mg: mg, + } +} + +func (s Service) Send( + ctx context.Context, + cancel context.CancelFunc, + receiver, templateName string, + templateOpts map[string]string, +) error { + tmpl, err := getTemplate(s.appURL, templateName) + if err != nil { + return err + } + + t := tmpl(templateOpts) + + go func() { + select { + case <-ctx.Done(): + slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err()) + return + default: + if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil { + slog.ErrorContext(ctx, "failed to send verification email", "err", err) + } + cancel() + } + }() + + return nil +}
@@ -0,0 +1,34 @@
+package main + +import ( + "errors" + "fmt" +) + +type Template struct { + Subject string + Body string +} + +type TemplateFunc func(args map[string]string) Template + +func getTemplate(appURL string, templateName string) (TemplateFunc, error) { + if templateName == "email_verification" { + return emailVerificationTemplate(appURL), nil + } + + return nil, errors.New("failed to get template") //nolint:err113 +} + +func emailVerificationTemplate(appURL string) TemplateFunc { + return func(opts map[string]string) Template { + return Template{ + Subject: "Onasty: verify your email", + Body: fmt.Sprintf(`To verify your email, please follow this link: +<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> +<br /> +<br /> +This link will expire after 24 hours.`, appURL, opts["token"]), + } + } +}