Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
9 changes: 8 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
1 change: 1 addition & 0 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
25 changes: 15 additions & 10 deletions backend/docs/bootstrap.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
157 changes: 157 additions & 0 deletions backend/docs/email.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion backend/docs/environment.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
topic: environment
last_verified: 2026-06-15
last_verified: 2026-06-23
sources:
- .env
- internal/bootstrap/bootstrap.go
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading