29 files changed,
588 insertions(+),
160 deletions(-)
Author:
Smirnov Oleksandr
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-01-10 22:25:33 +0200
Parent:
5bfc943
jump to
M
.env.example
··· 3 3 SERVER_PORT=8000 4 4 PASSWORD_SALT=onasty 5 5 NOTE_PASSWORD_SALT=secret 6 + 7 +METRICS_ENABLED=true 6 8 METRICS_PORT=8001 7 9 8 10 LOG_LEVEL=debug ··· 24 26 REDIS_ADDR="redis:6379" 25 27 CACHE_USERS_TTL=1h 26 28 27 -MAILGUN_FROM=onasty@mail.com 28 -MAILGUN_DOMAI='<domain>' 29 -MAILGUN_API_KEY='<token>' 29 +NATS_URL="nats:4222" 30 + 30 31 VERIFICATION_TOKEN_TTL=48h 31 32 32 33 RATELIMITER_RPS=100
M
cmd/server/main.go
··· 10 10 "os/signal" 11 11 12 12 "github.com/gin-gonic/gin" 13 + "github.com/nats-io/nats.go" 13 14 "github.com/olexsmir/onasty/internal/config" 15 + "github.com/olexsmir/onasty/internal/events/mailermq" 14 16 "github.com/olexsmir/onasty/internal/hasher" 15 17 "github.com/olexsmir/onasty/internal/jwtutil" 16 18 "github.com/olexsmir/onasty/internal/logger" 17 - "github.com/olexsmir/onasty/internal/mailer" 18 19 "github.com/olexsmir/onasty/internal/metrics" 19 20 "github.com/olexsmir/onasty/internal/service/notesrv" 20 21 "github.com/olexsmir/onasty/internal/service/usersrv" ··· 37 38 } 38 39 } 39 40 40 -//nolint:err113 41 +//nolint:err113,funlen 41 42 func run(ctx context.Context) error { 42 43 ctx, cancel := context.WithCancel(ctx) 43 44 defer cancel() ··· 58 59 } 59 60 60 61 // app deps 62 + nc, err := nats.Connect(cfg.NatsURL) 63 + if err != nil { 64 + return err 65 + } 66 + 61 67 psqlDB, err := psqlutil.Connect(ctx, cfg.PostgresDSN) 62 68 if err != nil { 63 69 return err ··· 71 77 userPasswordHasher := hasher.NewSHA256Hasher(cfg.PasswordSalt) 72 78 notePasswordHasher := hasher.NewSHA256Hasher(cfg.NotePassowrdSalt) 73 79 jwtTokenizer := jwtutil.NewJWTUtil(cfg.JwtSigningKey, cfg.JwtAccessTokenTTL) 74 - mailGunMailer := mailer.NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) 80 + 81 + mailermq := mailermq.New(nc) 75 82 76 83 sessionrepo := sessionrepo.New(psqlDB) 77 84 vertokrepo := vertokrepo.New(psqlDB) ··· 84 91 vertokrepo, 85 92 userPasswordHasher, 86 93 jwtTokenizer, 87 - mailGunMailer, 94 + mailermq, 88 95 usercache, 89 96 cfg.JwtRefreshTokenTTL, 90 97 cfg.VerificationTokenTTL,
M
docker-compose.yml
··· 10 10 - 8000:8000 11 11 - 8001:8001 12 12 13 + mailer: 14 + image: onasty:mailer 15 + container_name: onasty-mailer 16 + build: 17 + context: . 18 + dockerfile: ./mailer/Dockerfile 19 + env_file: ./mailer/.env 20 + 13 21 postgres: 14 22 image: postgres:16-alpine 15 23 container_name: onasty-postgres ··· 21 29 - .docker/postgres:/var/lib/postgresql/data 22 30 ports: 23 31 - 5432:5432 32 + 33 + nats: 34 + image: nats:2.10 35 + ports: 36 + - 4222:4222 24 37 25 38 redis: 26 39 image: redis:7.4-alpine
M
e2e/apiv1_auth_test.go
··· 136 136 ) 137 137 138 138 e.Equal(http.StatusOK, httpResp.Code) 139 - e.NotEmpty(e.mailer.GetLastSentEmailToEmail(email)) 140 139 } 141 140 142 141 func (e *AppTestSuite) TestAuthV1_ResendVerificationEmail_wrong() { ··· 175 174 e.Equal(httpResp.Code, t.expectedCode) 176 175 177 176 // no email should be sent 178 - e.Empty(e.mailer.GetLastSentEmailToEmail(t.email)) 177 + // e.Empty(e.mailer.GetLastSentEmailToEmail(t.email)) 179 178 } 180 179 } 181 180
M
e2e/e2e_test.go
··· 17 17 "github.com/olexsmir/onasty/internal/hasher" 18 18 "github.com/olexsmir/onasty/internal/jwtutil" 19 19 "github.com/olexsmir/onasty/internal/logger" 20 - "github.com/olexsmir/onasty/internal/mailer" 21 20 "github.com/olexsmir/onasty/internal/service/notesrv" 22 21 "github.com/olexsmir/onasty/internal/service/usersrv" 23 22 "github.com/olexsmir/onasty/internal/store/psql/noterepo" ··· 57 56 router http.Handler 58 57 hasher hasher.Hasher 59 58 jwtTokenizer jwtutil.JWTTokenizer 60 - mailer *mailer.TestMailer 61 59 } 62 60 errorResponse struct { 63 61 Message string `json:"message"` ··· 102 100 103 101 e.hasher = hasher.NewSHA256Hasher(cfg.PasswordSalt) 104 102 e.jwtTokenizer = jwtutil.NewJWTUtil(cfg.JwtSigningKey, time.Hour) 105 - e.mailer = mailer.NewTestMailer() 106 103 107 104 sessionrepo := sessionrepo.New(e.postgresDB) 108 105 vertokrepo := vertokrepo.New(e.postgresDB) ··· 115 112 vertokrepo, 116 113 e.hasher, 117 114 e.jwtTokenizer, 118 - e.mailer, 115 + newMailerMockService(), 119 116 usercache, 120 117 cfg.JwtRefreshTokenTTL, 121 118 cfg.VerificationTokenTTL,
A
e2e/mailer_test.go
··· 1 +package e2e_test 2 + 3 +import ( 4 + "context" 5 + 6 + "github.com/olexsmir/onasty/internal/events/mailermq" 7 +) 8 + 9 +var _ mailermq.Mailer = (*mailerMockService)(nil) 10 + 11 +type mailerMockService struct{} 12 + 13 +func newMailerMockService() *mailerMockService { 14 + return &mailerMockService{} 15 +} 16 + 17 +func (m mailerMockService) SendVerificationEmail( 18 + _ context.Context, 19 + _ mailermq.SendVerificationEmailRequest, 20 +) error { 21 + return nil 22 +}
M
go.mod
··· 11 11 github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 12 12 github.com/jackc/pgx/v5 v5.7.2 13 13 github.com/mailgun/mailgun-go/v4 v4.21.0 14 + github.com/nats-io/nats.go v1.38.0 14 15 github.com/prometheus/client_golang v1.20.5 15 16 github.com/redis/go-redis/v9 v9.7.0 16 17 github.com/stretchr/testify v1.10.0 ··· 85 86 github.com/modern-go/reflect2 v1.0.2 // indirect 86 87 github.com/morikuni/aec v1.0.0 // indirect 87 88 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 89 + github.com/nats-io/nkeys v0.4.9 // indirect 90 + github.com/nats-io/nuid v1.0.1 // indirect 88 91 github.com/opencontainers/go-digest v1.0.0 // indirect 89 92 github.com/opencontainers/image-spec v1.1.0 // indirect 90 93 github.com/pelletier/go-toml/v2 v2.2.2 // indirect
M
go.sum
··· 235 235 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 236 236 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 237 237 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 238 +github.com/nats-io/nats.go v1.38.0 h1:A7P+g7Wjp4/NWqDOOP/K6hfhr54DvdDQUznt5JFg9XA= 239 +github.com/nats-io/nats.go v1.38.0/go.mod h1:IGUM++TwokGnXPs82/wCuiHS02/aKrdYUQkU8If6yjw= 240 +github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0= 241 +github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE= 242 +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= 243 +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 238 244 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 239 245 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 240 246 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
M
infra/prometheus/prometheus.yml
··· 10 10 follow_redirects: true 11 11 honor_timestamps: true 12 12 static_configs: 13 - - targets: [core:8001] 13 + - targets: [core:8001] 14 + labels: 15 + service: "core" 16 + 17 + - job_name: mailer 18 + metrics_path: /metrics 19 + scheme: http 20 + follow_redirects: true 21 + honor_labels: true 22 + static_configs: 23 + - targets: [mailer:8001] 24 + labels: 25 + service: "mailer"
M
internal/config/config.go
··· 11 11 AppEnv string 12 12 AppURL string 13 13 ServerPort string 14 + NatsURL string 14 15 15 16 PostgresDSN string 16 17 PasswordSalt string ··· 48 49 AppEnv: getenvOrDefault("APP_ENV", "debug"), 49 50 AppURL: getenvOrDefault("APP_URL", ""), 50 51 ServerPort: getenvOrDefault("SERVER_PORT", "3000"), 52 + NatsURL: getenvOrDefault("NATS_URL", ""), 51 53 52 54 PostgresDSN: getenvOrDefault("POSTGRESQL_DSN", ""), 53 55 PasswordSalt: getenvOrDefault("PASSWORD_SALT", ""),
A
internal/events/events.go
··· 1 +package events 2 + 3 +import ( 4 + "fmt" 5 + 6 + "github.com/nats-io/nats.go" 7 +) 8 + 9 +const ( 10 + natsHeaderErrorCode = "Nats-Service-Error-Code" 11 + natsHeaderErrorMsg = "Nats-Service-Error" 12 +) 13 + 14 +var _ error = (*Error)(nil) 15 + 16 +type Error struct { 17 + Code string 18 + Message string 19 +} 20 + 21 +func (e Error) Error() string { 22 + return fmt.Sprintf("code: %s; msg: %s", e.Code, e.Message) 23 +} 24 + 25 +func CheckRespForError(resp *nats.Msg) error { 26 + code := resp.Header.Get(natsHeaderErrorCode) 27 + msg := resp.Header.Get(natsHeaderErrorMsg) 28 + if code == "" && msg == "" { 29 + return nil 30 + } 31 + 32 + return &Error{ 33 + Code: code, 34 + Message: msg, 35 + } 36 +}
A
internal/events/mailermq/mailermq.go
··· 1 +package mailermq 2 + 3 +import ( 4 + "context" 5 + "encoding/json" 6 + 7 + "github.com/nats-io/nats.go" 8 + "github.com/olexsmir/onasty/internal/events" 9 + "github.com/olexsmir/onasty/internal/transport/http/reqid" 10 +) 11 + 12 +type Mailer interface { 13 + SendVerificationEmail(ctx context.Context, input SendVerificationEmailRequest) error 14 +} 15 + 16 +type MailerMQ struct { 17 + nc *nats.Conn 18 +} 19 + 20 +const sendMailSubject = "mailer.send" 21 + 22 +func New(nc *nats.Conn) *MailerMQ { 23 + return &MailerMQ{ 24 + nc: nc, 25 + } 26 +} 27 + 28 +type sendRequest struct { 29 + RequestID string `json:"request_id"` 30 + Receiver string `json:"receiver"` 31 + TemplateName string `json:"template_name"` 32 + Options map[string]string `json:"options"` 33 +} 34 + 35 +type SendVerificationEmailRequest struct { 36 + Receiver string 37 + Token string 38 +} 39 + 40 +func (m MailerMQ) SendVerificationEmail( 41 + ctx context.Context, 42 + inp SendVerificationEmailRequest, 43 +) error { 44 + req, err := json.Marshal(sendRequest{ 45 + RequestID: reqid.GetContext(ctx), 46 + Receiver: inp.Receiver, 47 + TemplateName: "email_verification", 48 + Options: map[string]string{ 49 + "token": inp.Token, 50 + }, 51 + }) 52 + if err != nil { 53 + return err 54 + } 55 + 56 + resp, err := m.nc.RequestWithContext(ctx, sendMailSubject, req) 57 + if err != nil { 58 + return err 59 + } 60 + 61 + return events.CheckRespForError(resp) 62 +}
M
internal/mailer/mailgun.go
→ mailer/mailgun.go
··· 1 -package mailer 1 +package main 2 2 3 3 import ( 4 4 "context" 5 5 "log/slog" 6 6 7 7 "github.com/mailgun/mailgun-go/v4" 8 - "github.com/olexsmir/onasty/internal/metrics" 9 8 "github.com/olexsmir/onasty/internal/transport/http/reqid" 10 9 ) 10 + 11 +type Mailer interface { 12 + Send(ctx context.Context, to, subject, content string) error 13 +} 11 14 12 15 var _ Mailer = (*Mailgun)(nil) 13 16 ··· 33 36 34 37 _, _, err := m.mg.Send(ctx, msg) 35 38 if err != nil { 36 - metrics.RecordEmailFailed(reqid.GetContext(ctx)) 39 + RecordEmailFailed(reqid.GetContext(ctx)) 37 40 return err 38 41 } 39 42 40 43 slog.DebugContext(ctx, "email sent", "subject", subject, "content", content, "err", err) 41 44 slog.InfoContext(ctx, "email sent", "to", to) 42 - metrics.RecordEmailSent() 45 + 46 + RecordEmailSent() 43 47 44 48 return nil 45 49 }
D
internal/mailer/testing_mailer.go
··· 1 -package mailer 2 - 3 -import ( 4 - "context" 5 - "sync" 6 -) 7 - 8 -var _ Mailer = (*TestMailer)(nil) 9 - 10 -type TestMailer struct { 11 - mu sync.Mutex 12 - 13 - emails map[string]string 14 -} 15 - 16 -// NewTestMailer create a mailer for tests 17 -// that implementation of Mailer stores all sent email in memory 18 -// to get the last email sent to a specific email use GetLastSentEmailToEmail 19 -func NewTestMailer() *TestMailer { 20 - return &TestMailer{ //nolint:exhaustruct 21 - emails: make(map[string]string), 22 - } 23 -} 24 - 25 -func (t *TestMailer) Send(_ context.Context, to, _, content string) error { 26 - t.mu.Lock() 27 - defer t.mu.Unlock() 28 - 29 - t.emails[to] = content 30 - 31 - return nil 32 -} 33 - 34 -// GetLastSentEmailToEmail returns the last email sent to a specific email 35 -func (t *TestMailer) GetLastSentEmailToEmail(email string) string { 36 - t.mu.Lock() 37 - defer t.mu.Unlock() 38 - 39 - e := t.emails[email] 40 - 41 - return e 42 -}
D
internal/mailer/testing_mailer_test.go
··· 1 -package mailer 2 - 3 -import ( 4 - "context" 5 - "testing" 6 - 7 - "github.com/stretchr/testify/assert" 8 - "github.com/stretchr/testify/require" 9 -) 10 - 11 -func TestMailer_Send(t *testing.T) { 12 - m := NewTestMailer() 13 - assert.Empty(t, m.emails) 14 - 15 - email := "test@mail.com" 16 - err := m.Send(context.TODO(), email, "", "content") 17 - require.NoError(t, err) 18 - 19 - assert.Equal(t, "content", m.emails[email]) 20 -} 21 - 22 -func TestMailer_GetLastSentEmailToEmail(t *testing.T) { 23 - email := "test@mail.com" 24 - content := "content" 25 - 26 - m := NewTestMailer() 27 - assert.Empty(t, m.emails) 28 - 29 - m.emails[email] = content 30 - 31 - c := m.GetLastSentEmailToEmail(email) 32 - assert.Equal(t, content, c) 33 -}
D
internal/service/usersrv/email.go
··· 1 -package usersrv 2 - 3 -import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "log/slog" 8 -) 9 - 10 -var ErrFailedToSendVerifcationEmail = errors.New("failed to send verification email") 11 - 12 -const ( 13 - verificationEmailSubject = "Onasty: verify your email" 14 - verificationEmailBody = `To verify your email, please follow this link: 15 -<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> 16 -<br /> 17 -<br /> 18 -This link will expire after 24 hours.` 19 -) 20 - 21 -func (u *UserSrv) sendVerificationEmail( 22 - ctx context.Context, 23 - cancel context.CancelFunc, 24 - userEmail string, 25 - token string, 26 - url string, 27 -) { 28 - select { 29 - case <-ctx.Done(): 30 - slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err()) 31 - default: 32 - if err := u.mailer.Send( 33 - ctx, 34 - userEmail, 35 - verificationEmailSubject, 36 - fmt.Sprintf(verificationEmailBody, url, token), 37 - ); err != nil { 38 - slog.ErrorContext(ctx, "failed to send verification email", "err", err) 39 - } 40 - cancel() 41 - } 42 -}
M
internal/service/usersrv/usersrv.go
··· 8 8 9 9 "github.com/gofrs/uuid/v5" 10 10 "github.com/olexsmir/onasty/internal/dtos" 11 + "github.com/olexsmir/onasty/internal/events/mailermq" 11 12 "github.com/olexsmir/onasty/internal/hasher" 12 13 "github.com/olexsmir/onasty/internal/jwtutil" 13 - "github.com/olexsmir/onasty/internal/mailer" 14 14 "github.com/olexsmir/onasty/internal/models" 15 15 "github.com/olexsmir/onasty/internal/store/psql/sessionrepo" 16 16 "github.com/olexsmir/onasty/internal/store/psql/userepo" 17 17 "github.com/olexsmir/onasty/internal/store/psql/vertokrepo" 18 18 "github.com/olexsmir/onasty/internal/store/rdb/usercache" 19 - "github.com/olexsmir/onasty/internal/transport/http/reqid" 20 19 ) 21 20 22 21 type UserServicer interface { ··· 44 43 vertokrepo vertokrepo.VerificationTokenStorer 45 44 hasher hasher.Hasher 46 45 jwtTokenizer jwtutil.JWTTokenizer 47 - mailer mailer.Mailer 46 + mailermq mailermq.Mailer 48 47 cache usercache.UserCacheer 49 48 50 49 refreshTokenTTL time.Duration ··· 58 57 vertokrepo vertokrepo.VerificationTokenStorer, 59 58 hasher hasher.Hasher, 60 59 jwtTokenizer jwtutil.JWTTokenizer, 61 - mailer mailer.Mailer, 60 + mailermq mailermq.Mailer, 62 61 cache usercache.UserCacheer, 63 62 refreshTokenTTL, verificationTokenTTL time.Duration, 64 63 appURL string, ··· 69 68 vertokrepo: vertokrepo, 70 69 hasher: hasher, 71 70 jwtTokenizer: jwtTokenizer, 72 - mailer: mailer, 71 + mailermq: mailermq, 73 72 cache: cache, 74 73 refreshTokenTTL: refreshTokenTTL, 75 74 verificationTokenTTL: verificationTokenTTL, ··· 99 98 return uuid.Nil, err 100 99 } 101 100 102 - sendingCtx, cancel := getContextForEmailSending(ctx) 103 - go u.sendVerificationEmail(sendingCtx, cancel, inp.Email, vtok, u.appURL) 101 + if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ 102 + Receiver: inp.Email, 103 + Token: vtok, 104 + }); err != nil { 105 + return uuid.Nil, err 106 + } 104 107 105 108 return uid, nil 106 109 } ··· 221 224 return err 222 225 } 223 226 224 - sendingCtx, cancel := getContextForEmailSending(ctx) 225 - go u.sendVerificationEmail(sendingCtx, cancel, inp.Email, token, u.appURL) 227 + if err := u.mailermq.SendVerificationEmail(ctx, mailermq.SendVerificationEmailRequest{ 228 + Receiver: inp.Email, 229 + Token: token, 230 + }); err != nil { 231 + return err 232 + } 226 233 227 234 return nil 228 235 } ··· 285 292 Refresh: refreshToken, 286 293 }, err 287 294 } 288 - 289 -func getContextForEmailSending(ctx context.Context) (context.Context, context.CancelFunc) { 290 - rid := reqid.GetContext(ctx) 291 - resCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 292 - resCtx = reqid.SetContext(resCtx, rid) 293 - 294 - return resCtx, cancel 295 -}
A
mailer/Dockerfile
··· 1 +FROM golang:1.23.3-alpine AS builder 2 + 3 +WORKDIR /app 4 + 5 +COPY go.mod go.sum ./ 6 +RUN go mod download 7 + 8 +COPY internal internal 9 +COPY mailer mailer 10 + 11 +ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 12 +RUN go build -trimpath -ldflags='-w -s' -o /mailer ./mailer 13 + 14 + 15 +FROM alpine:3.20 16 +COPY --from=builder /mailer /mailer 17 +RUN apk --no-cache add ca-certificates 18 +ENTRYPOINT ["/mailer"]
A
mailer/README.md
··· 1 +# mailer service 2 + 3 +All templates could be found *[here](./template.go)* 4 + 5 +## endpoints 6 +### `mailer.ping` 7 +This endpoint always returns pong message 8 + 9 +Response: 10 +```json 11 +{ 12 + "message": "pong" 13 +} 14 +``` 15 + 16 +### `mailer.send` 17 + 18 +Input 19 +- `request_id` : *string* - (optional) the request id, needed to keep consistency across services 20 +- `receiver` : *string* - the email receiver 21 +- `template_name` : *string* - the template that's going to be used 22 +- `options` : *Map<string, string>* - template specific options 23 + 24 + 25 +Example input 26 +```json 27 +{ 28 + "request_id": "hello_world", 29 + "receiver": "onasty@example.com", 30 + "template_name": "email_verification", 31 + "options": { 32 + "token": "the_verification_token" 33 + } 34 +} 35 +``` 36 + 37 +#### Template specific options 38 +- `email_verification` 39 + - `token` the token that is used in verification link 40 + 41 +
A
mailer/config.go
··· 1 +package main 2 + 3 +import "os" 4 + 5 +type Config struct { 6 + AppURL string 7 + NatsURL string 8 + MailgunFrom string 9 + MailgunDomain string 10 + MailgunAPIKey string 11 + 12 + LogLevel string 13 + LogFormat string 14 + LogShowLine bool 15 + 16 + MetricsEnabled bool 17 + MetricsPort string 18 +} 19 + 20 +func NewConfig() *Config { 21 + return &Config{ 22 + AppURL: getenvOrDefault("APP_URL", ""), 23 + NatsURL: getenvOrDefault("NATS_URL", ""), 24 + MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), 25 + MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""), 26 + MailgunAPIKey: getenvOrDefault("MAILGUN_API_KEY", ""), 27 + LogLevel: getenvOrDefault("LOG_LEVEL", "debug"), 28 + LogFormat: getenvOrDefault("LOG_FORMAT", "json"), 29 + LogShowLine: getenvOrDefault("LOG_SHOW_LINE", "true") == "true", 30 + MetricsPort: getenvOrDefault("METRICS_PORT", ""), 31 + MetricsEnabled: getenvOrDefault("METRICS_ENABLED", "true") == "true", 32 + } 33 +} 34 + 35 +func getenvOrDefault(key, def string) string { 36 + if v, ok := os.LookupEnv(key); ok { 37 + return v 38 + } 39 + return def 40 +}
A
mailer/handlers.go
··· 1 +package main 2 + 3 +import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "time" 8 + 9 + "github.com/nats-io/nats.go/micro" 10 + "github.com/olexsmir/onasty/internal/transport/http/reqid" 11 +) 12 + 13 +type Handlers struct { 14 + service *Service 15 +} 16 + 17 +func NewHandlers(service *Service) *Handlers { 18 + return &Handlers{ 19 + service: service, 20 + } 21 +} 22 + 23 +func (h Handlers) RegisterAll(svc micro.Service) error { 24 + m := svc.AddGroup("mailer") 25 + if err := m.AddEndpoint("ping", micro.HandlerFunc(h.pingHandler)); err != nil { 26 + return err 27 + } 28 + 29 + if err := m.AddEndpoint("send", micro.HandlerFunc(h.sendHandler)); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 +} 35 + 36 +type pingResponse struct { 37 + Message string `json:"message"` 38 +} 39 + 40 +func (h Handlers) pingHandler(req micro.Request) { 41 + _ = req.RespondJSON(pingResponse{ 42 + Message: "pong", 43 + }) 44 +} 45 + 46 +type sendRequest struct { 47 + RequestID string `json:"request_id"` 48 + 49 + Receiver string `json:"receiver"` 50 + TemplateName string `json:"template_name"` 51 + Options map[string]string `json:"options"` 52 +} 53 + 54 +func (h Handlers) sendHandler(req micro.Request) { 55 + var inp sendRequest 56 + if err := json.Unmarshal(req.Data(), &inp); err != nil { 57 + slog.Error("failed to unmarshal input data", "err", err) 58 + return 59 + } 60 + 61 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 62 + ctx = reqid.SetContext(ctx, inp.RequestID) 63 + 64 + if err := h.service.Send(ctx, cancel, inp.Receiver, inp.TemplateName, inp.Options); err != nil { 65 + _ = req.Error("500", err.Error(), nil) 66 + } 67 + 68 + _ = req.Respond(nil) 69 +}
A
mailer/main.go
··· 1 +package main 2 + 3 +import ( 4 + "errors" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "os/signal" 10 + "strings" 11 + "syscall" 12 + 13 + "github.com/nats-io/nats.go" 14 + "github.com/nats-io/nats.go/micro" 15 + "github.com/olexsmir/onasty/internal/logger" 16 + "github.com/olexsmir/onasty/internal/transport/http/httpserver" 17 + 18 + _ "embed" 19 +) 20 + 21 +//go:embed version 22 +var _version string 23 + 24 +var version = strings.Trim(_version, "\n") 25 + 26 +func main() { 27 + if err := run(); err != nil { 28 + fmt.Fprintf(os.Stderr, "error: %v\n", err) 29 + os.Exit(1) 30 + } 31 +} 32 + 33 +func run() error { 34 + cfg := NewConfig() 35 + nc, err := nats.Connect(cfg.NatsURL) 36 + if err != nil { 37 + return err 38 + } 39 + 40 + logger, err := logger.NewCustomLogger(cfg.LogLevel, cfg.LogFormat, cfg.LogShowLine) 41 + if err != nil { 42 + return err 43 + } 44 + 45 + slog.SetDefault(logger) 46 + 47 + //nolint:exhaustruct 48 + svc, err := micro.AddService(nc, micro.Config{ 49 + Name: "mailer", 50 + Version: version, 51 + }) 52 + if err != nil { 53 + return err 54 + } 55 + 56 + mg := NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) 57 + service := NewService(cfg.AppURL, mg) 58 + handlers := NewHandlers(service) 59 + 60 + if err := handlers.RegisterAll(svc); err != nil { 61 + return err 62 + } 63 + 64 + if cfg.MetricsEnabled { 65 + srv := httpserver.NewServer(cfg.MetricsPort, MetricsHandler()) 66 + go func() { 67 + slog.Info("starting metrics server", "port", cfg.MetricsPort) 68 + if err := srv.Start(); !errors.Is(err, http.ErrServerClosed) { 69 + slog.Error("failed to start metrics server", "error", err) 70 + } 71 + }() 72 + } 73 + 74 + slog.Info("the service is listening") 75 + 76 + // graceful shutdown 77 + quitCh := make(chan os.Signal, 1) 78 + signal.Notify(quitCh, syscall.SIGINT, syscall.SIGTERM) 79 + <-quitCh 80 + 81 + slog.Info("stopping the service") 82 + 83 + if err := svc.Stop(); err != nil { 84 + return err 85 + } 86 + 87 + if err := nc.Drain(); err != nil { 88 + return err 89 + } 90 + 91 + return nil 92 +}
A
mailer/metrics.go
··· 1 +package main 2 + 3 +import ( 4 + "net/http" 5 + 6 + "github.com/prometheus/client_golang/prometheus" 7 + "github.com/prometheus/client_golang/prometheus/promauto" 8 + "github.com/prometheus/client_golang/prometheus/promhttp" 9 +) 10 + 11 +var ( 12 + emailSentSuccessfully = promauto.NewCounter(prometheus.CounterOpts{ //nolint:exhaustruct 13 + Name: "mail_sent_total", 14 + Help: "the total number of successfully sent email", 15 + }) 16 + 17 + emailFailedToSend = promauto.NewCounterVec(prometheus.CounterOpts{ //nolint:exhaustruct 18 + Name: "mail_failed_total", 19 + Help: "the total number of email that failed to send", 20 + }, []string{"request_id"}) 21 +) 22 + 23 +func MetricsHandler() http.Handler { 24 + mux := http.NewServeMux() 25 + mux.Handle("GET /metrics", promhttp.Handler()) 26 + return mux 27 +} 28 + 29 +func RecordEmailSent() { 30 + go emailSentSuccessfully.Inc() 31 +} 32 + 33 +func RecordEmailFailed(reqid string) { 34 + go emailFailedToSend.With(prometheus.Labels{ 35 + "request_id": reqid, 36 + }).Inc() 37 +}
A
mailer/service.go
··· 1 +package main 2 + 3 +import ( 4 + "context" 5 + "log/slog" 6 +) 7 + 8 +type Service struct { 9 + appURL string 10 + mg *Mailgun 11 +} 12 + 13 +func NewService(appURL string, mg *Mailgun) *Service { 14 + return &Service{ 15 + appURL: appURL, 16 + mg: mg, 17 + } 18 +} 19 + 20 +func (s Service) Send( 21 + ctx context.Context, 22 + cancel context.CancelFunc, 23 + receiver, templateName string, 24 + templateOpts map[string]string, 25 +) error { 26 + tmpl, err := getTemplate(s.appURL, templateName) 27 + if err != nil { 28 + return err 29 + } 30 + 31 + t := tmpl(templateOpts) 32 + 33 + go func() { 34 + select { 35 + case <-ctx.Done(): 36 + slog.ErrorContext(ctx, "failed to send verification email", "err", ctx.Err()) 37 + return 38 + default: 39 + if err := s.mg.Send(ctx, receiver, t.Subject, t.Body); err != nil { 40 + slog.ErrorContext(ctx, "failed to send verification email", "err", err) 41 + } 42 + cancel() 43 + } 44 + }() 45 + 46 + return nil 47 +}
A
mailer/template.go
··· 1 +package main 2 + 3 +import ( 4 + "errors" 5 + "fmt" 6 +) 7 + 8 +type Template struct { 9 + Subject string 10 + Body string 11 +} 12 + 13 +type TemplateFunc func(args map[string]string) Template 14 + 15 +func getTemplate(appURL string, templateName string) (TemplateFunc, error) { 16 + if templateName == "email_verification" { 17 + return emailVerificationTemplate(appURL), nil 18 + } 19 + 20 + return nil, errors.New("failed to get template") //nolint:err113 21 +} 22 + 23 +func emailVerificationTemplate(appURL string) TemplateFunc { 24 + return func(opts map[string]string) Template { 25 + return Template{ 26 + Subject: "Onasty: verify your email", 27 + Body: fmt.Sprintf(`To verify your email, please follow this link: 28 +<a href="%[1]s/api/v1/auth/verify/%[2]s">%[1]s/api/v1/auth/verify/%[2]s</a> 29 +<br /> 30 +<br /> 31 +This link will expire after 24 hours.`, appURL, opts["token"]), 32 + } 33 + } 34 +}