diff --git a/README.md b/README.md index e2f2deb..b06731d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fullstack Template -A production-ready fullstack starter. Clone it, rename things, and focus on your business logic — the infrastructure is already wired. Ships with a Go + Gin backend, Next.js 16 web app, Android mobile app (Kotlin + Compose), PostgreSQL, Docker Compose, hot reload, integration testing, and a full agentic development setup for AI coding assistants. +A production-ready fullstack starter. Clone it, rename things, and focus on your business logic, the infrastructure is already wired. Save on AI tokens too. Ships with a Go + Gin backend, Next.js 16 web app, Android mobile app (Kotlin + Compose), PostgreSQL, Docker Compose, hot reload, integration testing, and a full agentic development setup for AI coding assistants. ## Table of Contents diff --git a/backend/.env.example b/backend/.env.example index 9c31cdd..970ec6e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,4 +22,11 @@ BLUEPRINT_WS_ALLOWED_ORIGIN=http://localhost:3000 # Grafana admin credentials (Docker Compose only; defaults to admin/admin locally) # Override in production via environment variables — never commit real passwords. GRAFANA_ADMIN_USER=admin -GRAFANA_ADMIN_PASSWORD=admin \ No newline at end of file +GRAFANA_ADMIN_PASSWORD=admin +# Mailjet transactional email (optional; omit or leave empty to disable email sending) +# API keys from: https://app.mailjet.com/account/apikeys +MAILJET_API_KEY=your_mailjet_api_key_here +MAILJET_SECRET_KEY=your_mailjet_secret_key_here +# Sender identity — must be a verified Mailjet sender address +FROM_EMAIL=no-reply@example.com +FROM_NAME=MyApp \ No newline at end of file diff --git a/backend/docs/_index.md b/backend/docs/_index.md index b49ba1e..73857a8 100644 --- a/backend/docs/_index.md +++ b/backend/docs/_index.md @@ -19,3 +19,4 @@ The `docs` agent reads this index first to locate the right file before diving i | Background job queue (Asynq, task definitions, worker, Asynqmon UI) | [queue.md](queue.md) | `internal/usecase/enqueuer.go`, `internal/infrastructure/queue/tasks.go`, `internal/infrastructure/queue/client.go`, `internal/infrastructure/queue/worker.go`, `internal/infrastructure/queue/handlers.go`, `internal/transport/handlers/routes.go`, `cmd/api/main.go` | | Redis Streams event fan-out (producer, consumer, consumer groups) | [streams.md](streams.md) | `internal/infrastructure/streams/events.go`, `internal/infrastructure/streams/producer.go`, `internal/infrastructure/streams/consumer.go` | | Firebase Cloud Messaging — token storage, send API, FCM endpoints | [fcm.md](fcm.md) | `internal/domain/fcm_token.go`, `internal/usecase/notification.go`, `internal/infrastructure/database/postgres/fcm_token_repository.go`, `internal/transport/handlers/fcm_handler.go`, `pkg/firebase/app.go`, `pkg/firebase/messaging.go` | +| Transactional email (Mailjet) — EmailSender interface, MailjetSender, sandbox mode, templates | [email.md](email.md) | `internal/usecase/email.go`, `internal/infrastructure/email/mailjet.go`, `internal/infrastructure/email/templates/welcome.html`, `internal/bootstrap/bootstrap.go`, `internal/server/server.go`, `internal/transport/handlers/handler.go` | diff --git a/backend/docs/bootstrap.md b/backend/docs/bootstrap.md index dfd2709..003be60 100644 --- a/backend/docs/bootstrap.md +++ b/backend/docs/bootstrap.md @@ -1,6 +1,6 @@ --- topic: bootstrap -last_verified: 2026-06-15 +last_verified: 2026-06-23 sources: - internal/bootstrap/bootstrap.go - internal/server/server.go @@ -15,14 +15,17 @@ sources: ## App struct ```go type App struct { - DB *sql.DB - Cache usecase.CacheService // nil when REDIS_URL is not set - Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set - Config Config - Log *slog.Logger + DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set + Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set + FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set + EmailSender usecase.EmailSender // nil when MAILJET_API_KEY/SECRET_KEY are not set + Config Config + Log *slog.Logger } ``` -`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Firebase`) are nil when their corresponding env vars are absent. +`App` is constructed once by `Run` and passed to `server.NewServer`. Nothing re-initialises dependencies after this point. Optional fields (`Cache`, `Enqueuer`, `Firebase`, `FCMSender`, `EmailSender`) are nil when their corresponding env vars are absent. ## Config struct ```go @@ -47,9 +50,11 @@ type Config struct { 2. Validate required fields via `validateConfig()` — fast, no I/O 3. Open `*sql.DB` via `postgres.NewPostgresDB(cfg.DB)` 4. Probe Postgres with `probeWithRetry` under a 60-second total timeout -5. Init Redis via `redis.New(cfg.RedisURL)` and probe it — skipped when `REDIS_URL` is empty -6. Init Firebase Admin SDK via `firebase.NewAuthClient(ctx, projectID, credentialsJSON)` — skipped when `FIREBASE_PROJECT_ID` is empty -7. Return `*App` on success; return a non-nil error on any failure +5. Init Redis cache via `redis.New(cfg.RedisURL)` and probe it — skipped when `REDIS_URL` is empty +6. Init Asynq enqueuer via `queue.NewClient(cfg.RedisURL)` — skipped when `REDIS_URL` is empty +7. Init Firebase app via `firebase.NewApp(ctx, ...)`, then init Auth client (`firebase.NewAuthClient`) and FCM messaging client (`firebase.NewMessagingClient`) from the same app instance — all skipped when `FIREBASE_PROJECT_ID` is empty +8. Init Mailjet email sender via `email.NewMailjetSender(...)` — skipped when `MAILJET_API_KEY` or `MAILJET_SECRET_KEY` is empty; startup fails if only a partial Mailjet config is provided +9. Return `*App` on success; return a non-nil error on any failure ```go ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/backend/docs/email.md b/backend/docs/email.md new file mode 100644 index 0000000..b0925cc --- /dev/null +++ b/backend/docs/email.md @@ -0,0 +1,157 @@ +--- +title: Email (Mailjet) +last_verified: 2026-06-23 +sources: + - internal/usecase/email.go + - internal/infrastructure/email/mailjet.go + - internal/infrastructure/email/templates/welcome.html + - internal/bootstrap/bootstrap.go +--- + +# Email (Mailjet) + +## Overview + +`usecase.EmailSender` is the interface all email sending goes through: + +```go +// internal/usecase/email.go +type EmailSender interface { + SendWelcomeEmail(ctx context.Context, toEmail, toName string) error +} +``` + +The interface lives in `usecase/` because that is the layer that depends on it — handlers and future use-case logic call `EmailSender` without knowing about Mailjet. + +`App.EmailSender` is `nil` when `MAILJET_API_KEY` is omitted, so callers must nil-guard before use. + +## Implementation + +`MailjetSender` in `internal/infrastructure/email/mailjet.go` implements the interface using the Mailjet Send API v3.1. + +### Constructors + +```go +// Production — sends real email +func NewMailjetSender(apiKey, secretKey, fromEmail, fromName string, baseURL ...string) *MailjetSender + +// Sandbox — Mailjet validates the payload but does not deliver; for integration tests +func NewSandboxSender(apiKey, secretKey, fromEmail, fromName string) *MailjetSender +``` + +The optional `baseURL` variadic on `NewMailjetSender` overrides the Mailjet API endpoint, which allows unit tests to point the sender at an `httptest.Server`. + +### HTML templates + +Templates are embedded at compile time via `//go:embed`: + +```go +//go:embed templates/welcome.html +var templateFS embed.FS +``` + +`templates/welcome.html` is a Go `html/template` file. The only template data value currently used is `{{.Name}}` (the recipient's display name). `renderWelcomeTemplate` parses and executes the template on each call and returns the rendered HTML string. + +### Wiring (bootstrap) + +`bootstrap.Run` constructs the sender when both `MAILJET_API_KEY` and `MAILJET_SECRET_KEY` are non-empty: + +```go +var emailSender usecase.EmailSender +if cfg.MailjetAPIKey != "" && cfg.MailjetSecretKey != "" { + emailSender = email.NewMailjetSender( + cfg.MailjetAPIKey, + cfg.MailjetSecretKey, + cfg.FromEmail, + cfg.FromName, + ) +} +``` + +`emailSender` is stored on `bootstrap.App.EmailSender`. `server.NewServer` passes it to `handlers.NewHandler` as the last argument. + +## Env vars + +| Variable | Required | Description | +|---|---|---| +| `MAILJET_API_KEY` | No | Mailjet API key. Omit (or leave empty) to disable all email sending. | +| `MAILJET_SECRET_KEY` | No | Mailjet secret key. Must be set alongside `MAILJET_API_KEY`. | +| `FROM_EMAIL` | No | Verified sender address — e.g. `no-reply@example.com`. | +| `FROM_NAME` | No | Sender display name — e.g. `MyApp`. | + +When `MAILJET_API_KEY` or `MAILJET_SECRET_KEY` is empty, `App.EmailSender` is `nil` and no email is sent. The other two vars are only read when the sender is initialised. + +## How to add a new email type + +1. **Add a method to the interface** in `internal/usecase/email.go`: + ```go + SendPasswordResetEmail(ctx context.Context, toEmail, resetURL string) error + ``` + +2. **Add an HTML template** at `internal/infrastructure/email/templates/password_reset.html`. Use Go template syntax (`{{.FieldName}}`) for dynamic values. + +3. **Implement the method** on `MailjetSender` in `internal/infrastructure/email/mailjet.go`. Follow the `SendWelcomeEmail` pattern: render the template, populate `mailjet.InfoMessagesV31`, call `client.SendMailV31`. + +4. **Update the test double** in `internal/usecase/email_usecase_test.go` to add the new method stub so `mockEmailSender` continues to satisfy the interface at compile time. + +5. **Write tests** — see the Testing section below. + +## Testing + +### Unit test — interface contract (`internal/usecase/`) + +Test that a mock satisfies the interface and propagates errors correctly. No network calls. + +```go +type mockEmailSender struct { + calledWithEmail string + err error +} + +func (m *mockEmailSender) SendWelcomeEmail(_ context.Context, toEmail, toName string) error { + m.calledWithEmail = toEmail + return m.err +} + +var _ usecase.EmailSender = (*mockEmailSender)(nil) // compile-time check +``` + +See `internal/usecase/email_usecase_test.go` for the full example. + +### Unit test — HTTP error propagation (`internal/infrastructure/email/`) + +Use `httptest.NewServer` and pass its URL as the `baseURL` override to `NewMailjetSender`. No real credentials needed. + +```go +srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{"StatusCode": 401}) +})) +defer srv.Close() + +sender := email.NewMailjetSender("bad-key", "bad-secret", "no-reply@example.com", "Test", srv.URL+"/v3") +err := sender.SendWelcomeEmail(context.Background(), "user@example.com", "User") +// assert err != nil +``` + +### Integration test — live Mailjet sandbox + +Uses `NewSandboxSender` which sets `SandBoxMode: true` on the Mailjet request. Mailjet validates the payload and returns a success response without delivering the email. + +```go +func TestMailjetSender_SendWelcomeEmail_Sandbox(t *testing.T) { + apiKey := os.Getenv("MAILJET_API_KEY") + secretKey := os.Getenv("MAILJET_SECRET_KEY") + if apiKey == "" || secretKey == "" { + t.Skip("MAILJET_API_KEY and MAILJET_SECRET_KEY not set — skipping Mailjet sandbox integration test") + } + + sender := email.NewSandboxSender(apiKey, secretKey, "no-reply@example.com", "MyApp Test") + err := sender.SendWelcomeEmail(context.Background(), "sandbox@mailjet.com", "Sandbox User") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} +``` + +The skip guard means this test is silent in CI unless the credentials are injected as secrets. diff --git a/backend/docs/environment.md b/backend/docs/environment.md index 26f7373..ab87c1b 100644 --- a/backend/docs/environment.md +++ b/backend/docs/environment.md @@ -1,6 +1,6 @@ --- topic: environment -last_verified: 2026-06-15 +last_verified: 2026-06-23 sources: - .env - internal/bootstrap/bootstrap.go @@ -36,6 +36,11 @@ This runs on package init before any env var is read — no explicit `godotenv.L | `FIREBASE_SERVICE_ACCOUNT_JSON` | `bootstrap.go`, `pkg/firebase/admin.go` | — | Raw JSON content of a Firebase service account key file. When omitted the SDK falls back to Application Default Credentials (ADC) — appropriate for GCP-hosted deployments. Only relevant when `FIREBASE_PROJECT_ID` is set. | | `REDIS_URL` | `bootstrap.go` | — | Redis connection URL. When omitted or empty, cache/Redis initialization is skipped and the app runs without Redis. | | `BLUEPRINT_WS_ALLOWED_ORIGIN` | `internal/transport/handlers/ws_handler.go` | — | Allowed origin for WebSocket CORS checks in staging/production. When omitted, WebSocket origin validation is skipped (local dev). | +| `SENTRY_DSN` | `bootstrap.go`, `internal/transport/handlers/routes.go` | — | Sentry error-tracking DSN. When omitted, the Sentry middleware is not registered. | +| `MAILJET_API_KEY` | `bootstrap.go` | — | Mailjet API key. When omitted (or empty), `App.EmailSender` is `nil` and no email is sent. | +| `MAILJET_SECRET_KEY` | `bootstrap.go` | — | Mailjet secret key. Must be provided alongside `MAILJET_API_KEY`. | +| `FROM_EMAIL` | `bootstrap.go` | — | Verified Mailjet sender address (e.g. `no-reply@example.com`). Required when `MAILJET_API_KEY` and `MAILJET_SECRET_KEY` are set; startup fails if omitted. | +| `FROM_NAME` | `bootstrap.go` | — | Sender display name (e.g. `MyApp`). Only read when both Mailjet credentials are set. | Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing. diff --git a/backend/docs/routing.md b/backend/docs/routing.md index 87093df..4742090 100644 --- a/backend/docs/routing.md +++ b/backend/docs/routing.md @@ -1,6 +1,6 @@ --- topic: routing -last_verified: 2026-06-15 +last_verified: 2026-06-23 sources: - internal/transport/handlers/handler.go - internal/transport/handlers/routes.go @@ -18,25 +18,51 @@ sources: ```go // internal/transport/handlers/handler.go type Handler struct { - healthUC usecase.HealthUseCase - verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) - hub *ws.Hub + healthUC usecase.HealthUseCase + verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only) + hub *ws.Hub + enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + queueUI http.Handler // nil disables /admin/queues route + fcmSender usecase.NotificationSender // nil when Firebase is not configured + fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured + emailSender usecase.EmailSender // nil when MAILJET_API_KEY/SECRET_KEY are not set } -func NewHandler(healthUC usecase.HealthUseCase, verifier usecase.FirebaseTokenVerifier, hub *ws.Hub) *Handler { - return &Handler{healthUC: healthUC, verifier: verifier, hub: hub} -} +func NewHandler( + healthUC usecase.HealthUseCase, + verifier usecase.FirebaseTokenVerifier, + hub *ws.Hub, + enqueuer usecase.Enqueuer, + queueUI http.Handler, + fcmSender usecase.NotificationSender, + fcmTokenRepo usecase.FCMTokenRepository, + emailSender usecase.EmailSender, +) *Handler ``` -The `Handler` struct holds use case interfaces and infrastructure dependencies — not `*sql.DB` directly. `verifier` is stored on the struct (not passed to `RegisterRoutes`) so the WebSocket handler can read it inline for query-param auth. +The `Handler` struct holds use case interfaces and infrastructure dependencies — not `*sql.DB` directly. `verifier` is stored on the struct (not passed to `RegisterRoutes`) so the WebSocket handler can read it inline for query-param auth. `fcmTokenRepo` and `fcmSender` are nil when `FIREBASE_PROJECT_ID` is not set; their routes are only registered when non-nil. ## Wiring (server.go) `internal/server/server.go` contains `NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error)` — wiring only, no logic. -It receives the already-validated `*bootstrap.App` (which holds `*sql.DB`, `Cache`, `Firebase`, and `Config`) and a `*ws.Hub`, constructs the repository, use case, and handler in order, then returns a configured `*http.Server`. Errors from initialisation steps are returned to the caller. +It receives the already-validated `*bootstrap.App` (which holds `*sql.DB`, `Cache`, `Enqueuer`, `Firebase`, `FCMSender`, and `Config`) and a `*ws.Hub`, constructs the repository, use case, and handler in order, then returns a configured `*http.Server`. Errors from initialisation steps are returned to the caller. ```go healthRepo := postgres.NewHealthRepository(app.DB) healthUC := usecase.NewHealthUseCase(healthRepo) -h := handlers.NewHandler(healthUC, app.Firebase, hub) + +var fcmTokenRepo *postgres.FCMTokenRepository +if app.Firebase != nil { + fcmTokenRepo = postgres.NewFCMTokenRepository(app.DB) +} + +var queueUI http.Handler +if app.Config.RedisURL != "" { + // parse URL and build asynqmon.New(...) +} + +h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo) + +// Register DB pool metrics collector (AlreadyRegisteredError is silenced). +prometheus.Register(postgres.NewDBStatsCollector(app.DB)) return &http.Server{ Addr: fmt.Sprintf(":%d", app.Config.Port), @@ -48,7 +74,7 @@ return &http.Server{ ``` ## Route registration -All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`. +All routes are registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`. `rps` and `burst` come from `bootstrap.Config` (env vars `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST`); pass `rps=0` to disable. `h.verifier` (set via `NewHandler`) controls Firebase auth — the verifier is read from the struct, not passed to `RegisterRoutes`; a `nil` verifier skips Firebase auth (development only — see [auth](auth.md)). @@ -56,6 +82,8 @@ All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.H func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.Handler { r := gin.New() + r.Use(middleware.SentryMiddleware(sentryDSN)) + // Gin's colorful logger locally; structured slog logger in staging/production. if gin.Mode() == gin.DebugMode { r.Use(gin.Recovery(), gin.Logger()) @@ -63,22 +91,41 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http. r.Use(gin.Recovery(), middleware.Logger()) } + r.Use(middleware.PrometheusMiddleware()) r.Use(middleware.RateLimit(rps, burst)) r.Use(cors.New(cors.Config{ ... })) r.GET("/", h.HelloWorldHandler) r.GET("/health", h.HealthHandler) - r.GET("/ws", h.WsHandler) // WebSocket upgrade — auth via ?token= query param + r.GET("/ws", h.WsHandler) + + // /metrics restricted to loopback/RFC 1918 in staging/production. + if gin.Mode() == gin.ReleaseMode { + r.GET("/metrics", middleware.LocalNetworkOnly(), gin.WrapH(promhttp.Handler())) + } else { + r.GET("/metrics", gin.WrapH(promhttp.Handler())) + } r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // Asynqmon job-monitoring UI — debug/local only. + if gin.Mode() == gin.DebugMode && h.queueUI != nil { + r.GET("/admin/queues", gin.WrapH(h.queueUI)) + r.Any("/admin/queues/*path", gin.WrapH(h.queueUI)) + } + api := r.Group("/api/v1") if h.verifier != nil { api.Use(middleware.FirebaseAuth(h.verifier)) } api.GET("/me", h.MeHandler) + if h.fcmTokenRepo != nil { + api.POST("/fcm/register", h.RegisterFCMToken) + api.DELETE("/fcm/unregister", h.UnregisterFCMToken) + } + return r } ``` @@ -109,8 +156,14 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH. | GET | `/` | none | `HelloWorldHandler` — returns `{"message": "Hello World"}` | `hello_handler.go` | | GET | `/health` | none | `HealthHandler` — returns `HealthStats`; 503 when DB is down | `health_handler.go` | | GET | `/ws` | `?token=` query param | `WsHandler` — upgrades to WebSocket; 401 when token missing/invalid | `ws_handler.go` | -| GET | `/metrics` | `LocalNetworkOnly()` | Prometheus scrape endpoint; restricted to loopback/RFC 1918 in staging/production | `metrics_handler.go` | +| GET | `/metrics` | `LocalNetworkOnly()` in release mode | Prometheus scrape endpoint; unrestricted in debug mode | `routes.go` | +| GET | `/swagger/*any` | none | Swagger UI | `routes.go` | +| GET | `/admin/queues` | none (debug mode only) | Asynqmon job-monitoring UI | `routes.go` | | GET | `/api/v1/me` | FirebaseAuth header | `MeHandler` — returns verified `FirebaseToken` claims | `auth_handler.go` | +| POST | `/api/v1/fcm/register` | FirebaseAuth header | `RegisterFCMToken` — stores device FCM token | `fcm_handler.go` | +| DELETE | `/api/v1/fcm/unregister` | FirebaseAuth header | `UnregisterFCMToken` — removes device FCM token | `fcm_handler.go` | + +FCM routes are only registered when `h.fcmTokenRepo != nil` (i.e., `FIREBASE_PROJECT_ID` is set). ## Graceful shutdown Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM. diff --git a/backend/go.mod b/backend/go.mod index 3cfa6fa..04cf9e8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,6 +17,7 @@ require ( github.com/hibiken/asynqmon v0.7.2 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 + github.com/mailjet/mailjet-apiv3-go/v4 v4.0.8 github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.20.1 diff --git a/backend/go.sum b/backend/go.sum index 924372e..d47603a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -326,6 +326,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailjet/mailjet-apiv3-go/v4 v4.0.8 h1:13GKWoXoKtYzgNFbRmdnq7fhTORg5tDkK7fSjVJinbk= +github.com/mailjet/mailjet-apiv3-go/v4 v4.0.8/go.mod h1:2SU3t6eh/uK6BSeBmdhpIUau99L4iPlIfbx4o4pAUQs= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index dd4c899..2631c61 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -17,6 +17,7 @@ import ( "backend/internal/infrastructure/cache/redis" "backend/internal/infrastructure/database/postgres" + "backend/internal/infrastructure/email" "backend/internal/infrastructure/queue" "backend/internal/usecase" "backend/pkg/firebase" @@ -34,13 +35,14 @@ const ( // App holds all initialised, validated shared dependencies. // Constructed once by Run and passed to the HTTP server. type App struct { - DB *sql.DB - Cache usecase.CacheService // nil when REDIS_URL is not set - Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set - Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set - FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set - Config Config - Log *slog.Logger + DB *sql.DB + Cache usecase.CacheService // nil when REDIS_URL is not set + Enqueuer usecase.Enqueuer // nil when REDIS_URL is not set + Firebase usecase.FirebaseAdminClient // nil when FIREBASE_PROJECT_ID is not set + FCMSender usecase.NotificationSender // nil when FIREBASE_PROJECT_ID is not set + EmailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set + Config Config + Log *slog.Logger } // Config holds all validated configuration values read from environment variables. @@ -54,6 +56,10 @@ type Config struct { FirebaseProjectID string FirebaseServiceAccountJSON string SentryDSN string + MailjetAPIKey string + MailjetSecretKey string + FromEmail string + FromName string } // ConfigError is returned when required configuration is absent or invalid. @@ -138,16 +144,28 @@ func Run(ctx context.Context) (*App, error) { log.Info("bootstrap: firebase clients initialised", "project_id", cfg.FirebaseProjectID) } + var emailSender usecase.EmailSender + if cfg.MailjetAPIKey != "" && cfg.MailjetSecretKey != "" { + emailSender = email.NewMailjetSender( + cfg.MailjetAPIKey, + cfg.MailjetSecretKey, + cfg.FromEmail, + cfg.FromName, + ) + log.Info("bootstrap: mailjet email sender initialised", "from_email", cfg.FromEmail) + } + log.Info("bootstrap: all checks passed — ready to serve") return &App{ - DB: db, - Cache: cache, - Enqueuer: enqueuer, - Firebase: firebaseClient, - FCMSender: fcmSender, - Config: cfg, - Log: log, + DB: db, + Cache: cache, + Enqueuer: enqueuer, + Firebase: firebaseClient, + FCMSender: fcmSender, + EmailSender: emailSender, + Config: cfg, + Log: log, }, nil } @@ -182,6 +200,10 @@ func loadConfig() Config { FirebaseProjectID: os.Getenv("FIREBASE_PROJECT_ID"), FirebaseServiceAccountJSON: os.Getenv("FIREBASE_SERVICE_ACCOUNT_JSON"), SentryDSN: os.Getenv("SENTRY_DSN"), + MailjetAPIKey: os.Getenv("MAILJET_API_KEY"), + MailjetSecretKey: os.Getenv("MAILJET_SECRET_KEY"), + FromEmail: os.Getenv("FROM_EMAIL"), + FromName: os.Getenv("FROM_NAME"), DB: postgres.DBConfig{ Host: os.Getenv("BLUEPRINT_DB_HOST"), Port: os.Getenv("BLUEPRINT_DB_PORT"), @@ -211,6 +233,13 @@ func validateConfig(cfg Config, log *slog.Logger) error { requireNonEmpty("BLUEPRINT_DB_USERNAME", cfg.DB.Username) requireNonEmpty("BLUEPRINT_DB_PASSWORD", cfg.DB.Password) + // Mailjet: if any credential is provided, the full set is required. + if cfg.MailjetAPIKey != "" || cfg.MailjetSecretKey != "" || cfg.FromEmail != "" { + requireNonEmpty("MAILJET_API_KEY", cfg.MailjetAPIKey) + requireNonEmpty("MAILJET_SECRET_KEY", cfg.MailjetSecretKey) + requireNonEmpty("FROM_EMAIL", cfg.FromEmail) + } + if len(issues) > 0 { for _, issue := range issues { log.Error("bootstrap: config invalid", "detail", issue) diff --git a/backend/internal/infrastructure/email/mailjet.go b/backend/internal/infrastructure/email/mailjet.go new file mode 100644 index 0000000..7e65837 --- /dev/null +++ b/backend/internal/infrastructure/email/mailjet.go @@ -0,0 +1,102 @@ +package email + +import ( + "bytes" + "context" + "embed" + "fmt" + "html/template" + + mailjet "github.com/mailjet/mailjet-apiv3-go/v4" +) + +//go:embed templates/welcome.html +var templateFS embed.FS + +// MailjetSender implements usecase.EmailSender using the Mailjet +// transactional email API (Send API v3.1). +type MailjetSender struct { + client *mailjet.Client + fromEmail string + fromName string + sandboxMode bool +} + +// NewMailjetSender constructs a MailjetSender with the supplied credentials. +// Provide a non-empty baseURL to override the Mailjet endpoint (useful in tests). +func NewMailjetSender(apiKey, secretKey, fromEmail, fromName string, baseURL ...string) *MailjetSender { + client := mailjet.NewMailjetClient(apiKey, secretKey, baseURL...) + return &MailjetSender{ + client: client, + fromEmail: fromEmail, + fromName: fromName, + } +} + +// NewSandboxSender constructs a MailjetSender that sends all messages in +// sandbox mode (Mailjet validates the request but does not deliver the email). +// Intended for integration tests against the live Mailjet API. +func NewSandboxSender(apiKey, secretKey, fromEmail, fromName string) *MailjetSender { + return &MailjetSender{ + client: mailjet.NewMailjetClient(apiKey, secretKey), + fromEmail: fromEmail, + fromName: fromName, + sandboxMode: true, + } +} + +// SendWelcomeEmail sends a welcome email to toEmail rendered from the +// embedded welcome.html template. +func (s *MailjetSender) SendWelcomeEmail(_ context.Context, toEmail, toName string) error { + html, err := renderWelcomeTemplate(toName) + if err != nil { + return fmt.Errorf("email: render welcome template: %w", err) + } + + messagesInfo := []mailjet.InfoMessagesV31{ + { + From: &mailjet.RecipientV31{ + Email: s.fromEmail, + Name: s.fromName, + }, + To: &mailjet.RecipientsV31{ + mailjet.RecipientV31{ + Email: toEmail, + Name: toName, + }, + }, + Subject: "Welcome to MyApp!", + HTMLPart: html, + }, + } + + req := &mailjet.MessagesV31{ + Info: messagesInfo, + SandBoxMode: s.sandboxMode, + } + + if _, err := s.client.SendMailV31(req); err != nil { + return fmt.Errorf("email: mailjet send: %w", err) + } + return nil +} + +// renderWelcomeTemplate executes the embedded welcome.html template with the +// recipient name and returns the rendered HTML string. +func renderWelcomeTemplate(name string) (string, error) { + raw, err := templateFS.ReadFile("templates/welcome.html") + if err != nil { + return "", fmt.Errorf("read welcome template: %w", err) + } + + tmpl, err := template.New("welcome").Parse(string(raw)) + if err != nil { + return "", fmt.Errorf("parse welcome template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, map[string]string{"Name": name}); err != nil { + return "", fmt.Errorf("execute welcome template: %w", err) + } + return buf.String(), nil +} diff --git a/backend/internal/infrastructure/email/mailjet_test.go b/backend/internal/infrastructure/email/mailjet_test.go new file mode 100644 index 0000000..ec9602c --- /dev/null +++ b/backend/internal/infrastructure/email/mailjet_test.go @@ -0,0 +1,109 @@ +package email_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "backend/internal/infrastructure/email" +) + +// TestMain is the entry point for this test package. +// No external containers are needed — real Mailjet sandbox API is used when +// credentials are available; otherwise the sandbox test is skipped. +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} + +// TestMailjetSender_SendWelcomeEmail_Sandbox calls the real Mailjet Send API +// in sandbox mode (email is validated but not delivered). It is skipped when +// MAILJET_API_KEY / MAILJET_SECRET_KEY are not set in the environment. +func TestMailjetSender_SendWelcomeEmail_Sandbox(t *testing.T) { + apiKey := os.Getenv("MAILJET_API_KEY") + secretKey := os.Getenv("MAILJET_SECRET_KEY") + + if apiKey == "" || secretKey == "" { + t.Skip("MAILJET_API_KEY and MAILJET_SECRET_KEY not set — skipping Mailjet sandbox integration test") + } + + fromEmail := os.Getenv("FROM_EMAIL") + fromName := os.Getenv("FROM_NAME") + if fromEmail == "" { + fromEmail = "no-reply@example.com" + } + if fromName == "" { + fromName = "MyApp Test" + } + + // withSandbox is unexported but we can exercise it by keeping the sender + // internal to this package test; instead we call the exported constructor + // and rely on a helper that forces sandbox mode (see newSandboxSender). + sender := email.NewSandboxSender(apiKey, secretKey, fromEmail, fromName) + + err := sender.SendWelcomeEmail(context.Background(), "sandbox@mailjet.com", "Sandbox User") + if err != nil { + t.Fatalf("SendWelcomeEmail sandbox: unexpected error: %v", err) + } +} + +// TestMailjetSender_SendWelcomeEmail_Non200_ReturnsError uses an httptest +// server that returns 401 Unauthorized to verify the sender propagates errors. +func TestMailjetSender_SendWelcomeEmail_Non200_ReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + if err := json.NewEncoder(w).Encode(map[string]any{ + "ErrorInfo": "api key invalid", + "ErrorMessage": "Unauthorized", + "StatusCode": 401, + }); err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer srv.Close() + + // Pass the test server URL as the baseURL override. + sender := email.NewMailjetSender("bad-key", "bad-secret", "no-reply@example.com", "Test", srv.URL+"/v3") + + err := sender.SendWelcomeEmail(context.Background(), "user@example.com", "User") + if err == nil { + t.Fatal("expected error for non-200 response, got nil") + } +} + +// TestMailjetSender_SendWelcomeEmail_Success uses an httptest server that +// returns a valid Mailjet v3.1 success response body. +func TestMailjetSender_SendWelcomeEmail_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Minimal valid Mailjet v3.1 response. + if err := json.NewEncoder(w).Encode(map[string]any{ + "Messages": []map[string]any{ + { + "Status": "success", + "To": []map[string]any{ + { + "Email": "user@example.com", + "MessageUUID": "abc-123", + "MessageID": 1111111111111111, + "MessageHref": "https://api.mailjet.com/v3/REST/message/1111111111111111", + }, + }, + }, + }, + }); err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer srv.Close() + + sender := email.NewMailjetSender("key", "secret", "no-reply@example.com", "Test", srv.URL+"/v3") + + err := sender.SendWelcomeEmail(context.Background(), "user@example.com", "User") + if err != nil { + t.Fatalf("SendWelcomeEmail: unexpected error: %v", err) + } +} diff --git a/backend/internal/infrastructure/email/templates/welcome.html b/backend/internal/infrastructure/email/templates/welcome.html new file mode 100644 index 0000000..3162b47 --- /dev/null +++ b/backend/internal/infrastructure/email/templates/welcome.html @@ -0,0 +1,45 @@ + + + + + + Welcome to MyApp + + + + + + +
+ + + + + + + + + + +
+

MyApp

+
+

Welcome, {{.Name}}!

+

+ Thank you for joining MyApp. We're thrilled to have you on board. +

+

+ If you have any questions or need assistance, feel free to reach out to our support team. +

+

+ Welcome aboard,
+ The MyApp Team +

+
+

+ © 2025 MyApp. All rights reserved. +

+
+
+ + diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index c1e857f..d599a24 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -56,7 +56,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) { } } - h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo) + h := handlers.NewHandler(healthUC, app.Firebase, hub, app.Enqueuer, queueUI, app.FCMSender, fcmTokenRepo, app.EmailSender) // Register DB pool metrics collector. // AlreadyRegisteredError is silenced — only the first registration wins diff --git a/backend/internal/transport/handlers/handler.go b/backend/internal/transport/handlers/handler.go index a87cff4..22bfa8b 100644 --- a/backend/internal/transport/handlers/handler.go +++ b/backend/internal/transport/handlers/handler.go @@ -16,6 +16,7 @@ type Handler struct { queueUI http.Handler // nil disables /admin/queues route fcmSender usecase.NotificationSender // nil when Firebase is not configured fcmTokenRepo usecase.FCMTokenRepository // nil when Firebase is not configured + emailSender usecase.EmailSender // nil when MAILJET_API_KEY is not set } // NewHandler constructs a Handler with all required use cases. @@ -27,6 +28,7 @@ func NewHandler( queueUI http.Handler, fcmSender usecase.NotificationSender, fcmTokenRepo usecase.FCMTokenRepository, + emailSender usecase.EmailSender, ) *Handler { return &Handler{ healthUC: healthUC, @@ -36,5 +38,6 @@ func NewHandler( queueUI: queueUI, fcmSender: fcmSender, fcmTokenRepo: fcmTokenRepo, + emailSender: emailSender, } } diff --git a/backend/internal/transport/handlers/health_handler_test.go b/backend/internal/transport/handlers/health_handler_test.go index 7b75e84..b90e168 100644 --- a/backend/internal/transport/handlers/health_handler_test.go +++ b/backend/internal/transport/handlers/health_handler_test.go @@ -31,7 +31,7 @@ func TestHealthHandler_Success(t *testing.T) { Status: "up", Message: "It's healthy", } - h := NewHandler(&mockHealthUC{stats: want}, nil, nil, nil, nil, nil, nil) + h := NewHandler(&mockHealthUC{stats: want}, nil, nil, nil, nil, nil, nil, nil) r := gin.New() r.GET("/health", h.HealthHandler) @@ -50,7 +50,7 @@ func TestHealthHandler_Success(t *testing.T) { } func TestHealthHandler_ServiceUnavailable(t *testing.T) { - h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}, nil, nil, nil, nil, nil, nil) + h := NewHandler(&mockHealthUC{err: errors.New("connection refused")}, nil, nil, nil, nil, nil, nil, nil) r := gin.New() r.GET("/health", h.HealthHandler) diff --git a/backend/internal/usecase/email.go b/backend/internal/usecase/email.go new file mode 100644 index 0000000..2bc812b --- /dev/null +++ b/backend/internal/usecase/email.go @@ -0,0 +1,8 @@ +package usecase + +import "context" + +// EmailSender sends transactional email messages. +type EmailSender interface { + SendWelcomeEmail(ctx context.Context, toEmail, toName string) error +} diff --git a/backend/internal/usecase/email_usecase_test.go b/backend/internal/usecase/email_usecase_test.go new file mode 100644 index 0000000..a2409b7 --- /dev/null +++ b/backend/internal/usecase/email_usecase_test.go @@ -0,0 +1,53 @@ +package usecase_test + +import ( + "context" + "errors" + "testing" + + "backend/internal/usecase" +) + +// mockEmailSender is a test double implementing usecase.EmailSender. +type mockEmailSender struct { + calledWithEmail string + calledWithName string + err error +} + +func (m *mockEmailSender) SendWelcomeEmail(_ context.Context, toEmail, toName string) error { + m.calledWithEmail = toEmail + m.calledWithName = toName + return m.err +} + +// Verify that mockEmailSender satisfies the interface at compile time. +var _ usecase.EmailSender = (*mockEmailSender)(nil) + +func TestEmailSender_SendWelcomeEmail_CallsWithCorrectArgs(t *testing.T) { + mock := &mockEmailSender{} + + err := mock.SendWelcomeEmail(context.Background(), "alice@example.com", "Alice") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.calledWithEmail != "alice@example.com" { + t.Errorf("toEmail: got %q, want %q", mock.calledWithEmail, "alice@example.com") + } + if mock.calledWithName != "Alice" { + t.Errorf("toName: got %q, want %q", mock.calledWithName, "Alice") + } +} + +func TestEmailSender_SendWelcomeEmail_PropagatesError(t *testing.T) { + sentinel := errors.New("smtp error") + mock := &mockEmailSender{err: sentinel} + + err := mock.SendWelcomeEmail(context.Background(), "bob@example.com", "Bob") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, sentinel) { + t.Errorf("expected sentinel error, got: %v", err) + } +}