This commit is contained in:
17
services/notification/Dockerfile
Normal file
17
services/notification/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation. This service
|
||||
# has no gRPC API — only a RabbitMQ consumer and a tiny health endpoint.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/notification/ services/notification/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/notification-server ./services/notification/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/notification-server ./notification-server
|
||||
ENV ENKLY_NOTIFICATION_HEALTH_ADDR=:9007
|
||||
EXPOSE 9007
|
||||
ENTRYPOINT ["./notification-server"]
|
||||
81
services/notification/cmd/server/main.go
Normal file
81
services/notification/cmd/server/main.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Command server runs the notification microservice: a pure RabbitMQ
|
||||
// consumer with no gRPC server and no database of its own — it sends
|
||||
// email in reaction to events published by iam. Replaces the email-
|
||||
// sending half of internal/handlers/auth.go + internal/mail/mail.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/mailkit"
|
||||
"enkly/services/notification/internal/application"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
if err := envkit.LoadDotEnv(".env"); err != nil {
|
||||
logger.Error("load .env", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
|
||||
healthAddr = envkit.String("ENKLY_NOTIFICATION_HEALTH_ADDR", ":9007")
|
||||
|
||||
smtpHost = envkit.String("ENKLY_SMTP_HOST", "smtp.gmail.com")
|
||||
smtpPort = envkit.String("ENKLY_SMTP_PORT", "587")
|
||||
smtpUsername = envkit.String("ENKLY_SMTP_USERNAME", "")
|
||||
smtpPassword = envkit.String("ENKLY_SMTP_PASSWORD", "")
|
||||
smtpFrom = envkit.String("ENKLY_SMTP_FROM", "")
|
||||
)
|
||||
|
||||
mailer := mailkit.New(mailkit.Config{
|
||||
Host: smtpHost, Port: smtpPort, Username: smtpUsername, Password: smtpPassword, From: smtpFrom,
|
||||
})
|
||||
if !mailer.Configured() {
|
||||
logger.Warn("mail is not configured — verification codes and reset links will be logged, not emailed",
|
||||
"hint", "set ENKLY_SMTP_USERNAME and ENKLY_SMTP_PASSWORD (a Gmail App Password) in .env")
|
||||
}
|
||||
|
||||
bus, err := eventbus.Connect(rabbitURL, logger)
|
||||
if err != nil {
|
||||
logger.Error("connect rabbitmq", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer bus.Close()
|
||||
|
||||
svc := &application.Service{Mailer: mailer, Logger: logger}
|
||||
|
||||
// A minimal HTTP health endpoint — this service has no gRPC API
|
||||
// (nothing calls it synchronously), but docker-compose and an
|
||||
// orchestrator still want something to poll.
|
||||
go func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
lis, err := net.Listen("tcp", healthAddr)
|
||||
if err != nil {
|
||||
logger.Error("health listen", "err", err)
|
||||
return
|
||||
}
|
||||
_ = http.Serve(lis, mux)
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
logger.Info("notification service consuming", "queue", eventbus.NotificationQueueBindings.Queue, "mail_configured", mailer.Configured())
|
||||
if err := bus.Consume(ctx, eventbus.NotificationQueueBindings, svc.HandleEvent); err != nil && ctx.Err() == nil {
|
||||
logger.Error("consume", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("shutting down")
|
||||
}
|
||||
96
services/notification/internal/application/service.go
Normal file
96
services/notification/internal/application/service.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Package application is the notification service's entire job: listen
|
||||
// for the events iam publishes when it needs an email sent, and send
|
||||
// it. Replaces the parts of internal/handlers/auth.go that called
|
||||
// mail.Mailer.Send inline (sendVerificationEmail, sendResetEmail),
|
||||
// decoupled onto RabbitMQ per the task's requirement that asynchronous,
|
||||
// no-reply-needed work use the message bus rather than a synchronous
|
||||
// call — sending an email has no result the caller
|
||||
// (iam's SignUp/ResendVerification/ForgotPassword RPCs) needs to wait
|
||||
// for.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"enkly/pkg/eventbus"
|
||||
"enkly/pkg/eventbus/events"
|
||||
"enkly/pkg/mailkit"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Mailer *mailkit.Mailer
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// HandleEvent is the single eventbus.Handler this service registers —
|
||||
// see cmd/server/main.go's Consume call. It switches on the envelope's
|
||||
// routing key the same way the monolith's handlers switched on which
|
||||
// function (sendVerificationEmail vs. sendResetEmail) was calling
|
||||
// mail.Send.
|
||||
func (s *Service) HandleEvent(ctx context.Context, env eventbus.Envelope) error {
|
||||
switch env.RoutingKey {
|
||||
case eventbus.RoutingKeyVerificationCodeIssued:
|
||||
var ev events.VerificationCodeIssued
|
||||
if err := decode(env, &ev); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendVerificationEmail(ctx, ev)
|
||||
|
||||
case eventbus.RoutingKeyPasswordResetRequested:
|
||||
var ev events.PasswordResetRequested
|
||||
if err := decode(env, &ev); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendResetEmail(ctx, ev)
|
||||
|
||||
default:
|
||||
// Bound to this queue by mistake (topology drift) — log and ack
|
||||
// rather than requeue-looping forever on a message we'll never
|
||||
// know how to handle.
|
||||
s.Logger.Warn("notification: unhandled routing key", "routing_key", env.RoutingKey)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func decode(env eventbus.Envelope, v any) error {
|
||||
if err := json.Unmarshal(env.Payload, v); err != nil {
|
||||
return fmt.Errorf("decode %s payload: %w", env.RoutingKey, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendVerificationEmail replaces Handlers.sendVerificationEmail.
|
||||
func (s *Service) sendVerificationEmail(_ context.Context, ev events.VerificationCodeIssued) error {
|
||||
subject := "Verify your enkly account"
|
||||
body := fmt.Sprintf(
|
||||
"Hi,\n\nYour enkly verification code is: %s\n\nThis code expires in 15 minutes. If you didn't request this, you can ignore this email.\n",
|
||||
ev.Code)
|
||||
if !s.Mailer.Configured() {
|
||||
s.Logger.Info("mail not configured — verification code logged instead", "email", ev.Email, "code", ev.Code)
|
||||
return nil
|
||||
}
|
||||
if err := s.Mailer.Send(ev.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("send verification email to %s: %w", ev.Email, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendResetEmail replaces Handlers.sendResetEmail.
|
||||
func (s *Service) sendResetEmail(_ context.Context, ev events.PasswordResetRequested) error {
|
||||
subject := "Reset your enkly password"
|
||||
body := fmt.Sprintf(
|
||||
"Hi,\n\nSomeone (hopefully you) requested a password reset for your enkly account.\n\n"+
|
||||
"Reset it here: %s\n\nThis link expires in an hour. If you didn't request this, you can ignore this email.\n",
|
||||
ev.ResetLink)
|
||||
if !s.Mailer.Configured() {
|
||||
s.Logger.Info("mail not configured — reset link logged instead", "email", ev.Email, "link", ev.ResetLink)
|
||||
return nil
|
||||
}
|
||||
if err := s.Mailer.Send(ev.Email, subject, body); err != nil {
|
||||
return fmt.Errorf("send reset email to %s: %w", ev.Email, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user