From 5ccc7443903b71495d538edb50e3342999117346 Mon Sep 17 00:00:00 2001 From: Victor Ivanov Date: Thu, 26 Feb 2026 01:27:57 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20v1.1=20bugfixes=20=E2=80=94=20respon?= =?UTF-8?q?se=20wrappers,=20field=20names,=20template=20detail,=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Add ListResponse[T] wrapper to 6 list endpoints (audiences, api-keys, webhooks, topics, contact properties, segments) so frontend `data?.data ?? []` pattern works correctly. Phase 2: Fix broadcast field names (total_recipients→recipients, sent_count→sent, add audience_name). Fix domain dns_records field name and wrap GET /domains/{id} in {data: ...}. Phase 3: Add TemplateDetailResponse with subject, html, text, published, updated_at. Template Get() now fetches latest version content. Phase 4: Update Dockerfile to golang:1.25-alpine. Remove deprecated docker-compose version field. Co-Authored-By: Claude Opus 4.6 --- PLAN.md | 385 ---------------------- deploy/docker/Dockerfile | 2 +- docker-compose.dev.yml | 2 - docker-compose.yml | 2 - internal/dto/broadcast.go | 15 +- internal/dto/common.go | 5 + internal/dto/domain.go | 2 +- internal/dto/template.go | 12 + internal/handler/apikey_test.go | 6 +- internal/handler/audience_test.go | 6 +- internal/handler/contact_property_test.go | 6 +- internal/handler/domain.go | 2 +- internal/handler/segment_test.go | 6 +- internal/handler/template_test.go | 2 +- internal/handler/topic_test.go | 8 +- internal/handler/webhook_test.go | 6 +- internal/service/apikey.go | 6 +- internal/service/apikey_test.go | 10 +- internal/service/audience.go | 6 +- internal/service/audience_test.go | 2 +- internal/service/broadcast.go | 12 +- internal/service/contact_property.go | 6 +- internal/service/contact_property_test.go | 4 +- internal/service/domain.go | 12 +- internal/service/domain_test.go | 2 +- internal/service/segment.go | 6 +- internal/service/segment_test.go | 4 +- internal/service/template.go | 23 +- internal/service/template_test.go | 6 + internal/service/topic.go | 6 +- internal/service/topic_test.go | 4 +- internal/service/webhook.go | 6 +- internal/service/webhook_test.go | 4 +- internal/testutil/mock/services.go | 46 ++- plan/v1.1-bugfixes.md | 275 ++++++++++++++++ 35 files changed, 432 insertions(+), 475 deletions(-) delete mode 100644 PLAN.md create mode 100644 plan/v1.1-bugfixes.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index d884d8c..0000000 --- a/PLAN.md +++ /dev/null @@ -1,385 +0,0 @@ -# MailIt v1.0 — Road to Production - -All 5 original build phases are complete. This plan covers everything needed to reach -a fully functional, self-hostable v1.0 release. - -Based on a full end-to-end test of every page and API endpoint (Feb 2026). - ---- - -## Phase 1 — Critical Bug Fixes - -These block the core email sending flow. Nothing else matters until these work. - -### 1.1 Fix worker queue name mismatch -- **Bug**: `internal/service/email.go` enqueues to `asynq.Queue("email")` but the worker - mux in `internal/worker/server.go` only monitors queues `critical`, `default`, `low` -- **Impact**: Emails are enqueued but **never processed** -- **Fix**: Replace all hardcoded queue strings in services with constants from - `internal/worker/tasks.go` (`QueueCritical`, `QueueDefault`, `QueueLow`). - Route email sends to `QueueCritical`. -- **Files**: `internal/service/email.go`, `internal/service/broadcast.go`, - `internal/service/domain.go` - -### 1.2 Fix domain DNS records varchar overflow -- **Bug**: `record_type VARCHAR(10)` in migration 000005 but `RETURN_PATH` is 11 chars -- **Impact**: Domain creation crashes with 500 -- **Fix**: New migration — `ALTER COLUMN record_type TYPE VARCHAR(20)` -- **Files**: New `db/migrations/000019_fix_dns_record_type_length.{up,down}.sql` - -### 1.3 Fix suppression list "not found" handling -- **Bug**: `internal/service/email.go` treats ALL suppression lookup errors as failures. - `ErrNotFound` should mean "not suppressed, proceed". -- **Impact**: Email sending returns 500 for every recipient -- **Fix**: Check for `ErrNotFound` and treat as "not suppressed". Only error on real DB failures. -- **Files**: `internal/service/email.go` - -### 1.4 Add batch email send handler -- **Bug**: `TaskEmailBatchSend` is defined in `internal/worker/tasks.go` but **no handler - is registered** in `NewMux()` -- **Impact**: `POST /emails/batch` enqueues a task that is silently dropped -- **Fix**: Implement `BatchEmailSendHandler` that expands the batch into individual - `TaskEmailSend` tasks -- **Files**: New `internal/worker/batch_email_handler.go`, update `internal/worker/server.go` - -### 1.5 Fix CORS defaults -- **Bug**: Default `cors_origins` is `["http://localhost:3000"]`. Any other frontend port - (or production domain) fails silently with no error. -- **Fix**: Add `cors_origins` to `config/mailit.example.yaml` with clear documentation. - Default should include both `:3000` and `:3001` for dev. -- **Files**: `internal/config/config.go`, `config/mailit.example.yaml` - -### 1.6 Fix API key permission values in frontend -- **Bug**: Frontend sends `"full_access"` / `"sending_access"`, backend validates - `oneof=full sending` -- **Fix**: Change SelectItem values to `"full"` and `"sending"` -- **Files**: `web/src/app/(dashboard)/api-keys/page.tsx` - ---- - -## Phase 2 — Frontend Foundations - -Fix the dashboard so every page works end-to-end with the backend. - -### 2.1 Root page redirect -- Replace `web/src/app/page.tsx` (still the default Next.js starter) with a redirect - to `/login` -- **Files**: `web/src/app/page.tsx` - -### 2.2 Add logout -- Add a logout button to the sidebar (bottom, near the version string) -- On click: clear `localStorage` token, clear `mailit_token` cookie, redirect to `/login` -- **Files**: `web/src/components/layout/app-sidebar.tsx` - -### 2.3 Fix token storage consistency -- **Problem**: Login sets both `localStorage` and cookie. API client reads `localStorage`. - Middleware reads cookie. 401 interceptor clears `localStorage` but not cookie. -- **Fix**: Keep dual storage but ensure both are always set AND cleared together. - Fix the 401 interceptor to clear both. Add `HttpOnly` in a future iteration. -- **Files**: `web/src/lib/api.ts`, `web/src/app/(auth)/login/page.tsx`, - `web/src/app/(auth)/register/page.tsx` - -### 2.4 Add error toasts to all mutations -- **Problem**: No mutation anywhere has an `onError` callback. The Toaster (sonner) - is mounted in `layout.tsx` but never used. -- **Fix**: Add `onError` with `toast.error()` and `onSuccess` with `toast.success()` - to every `useMutation` across all pages. -- **Pages**: `domains/page.tsx`, `api-keys/page.tsx`, `audience/page.tsx`, - `broadcasts/new/page.tsx`, `templates/new/page.tsx`, `templates/[id]/page.tsx`, - `webhooks/new/page.tsx` - -### 2.5 Fix template edit form state -- **Bug**: Form state initializes as `null`, setter functions crash with `...f!` - when `f` is null -- **Fix**: Initialize form from fetched template data via `useEffect` after query loads -- **Files**: `web/src/app/(dashboard)/templates/[id]/page.tsx` - -### 2.6 Add missing delete operations to UI -- Add delete buttons with confirmation dialogs to: Domains, Broadcasts, Webhooks -- Backend already supports `DELETE` for all of these — just needs frontend UI -- **Files**: `domains/page.tsx`, `broadcasts/page.tsx`, `webhooks/page.tsx` - ---- - -## Phase 3 — Metrics & Analytics - -Build the metrics backend and wire the frontend. - -### 3.1 Create metrics aggregation table -```sql -CREATE TABLE email_metrics ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - team_id UUID NOT NULL REFERENCES teams(id), - period_start TIMESTAMPTZ NOT NULL, - period_type VARCHAR(10) NOT NULL CHECK (period_type IN ('hourly', 'daily')), - sent INT DEFAULT 0, - delivered INT DEFAULT 0, - bounced INT DEFAULT 0, - failed INT DEFAULT 0, - opened INT DEFAULT 0, - clicked INT DEFAULT 0, - complained INT DEFAULT 0, - UNIQUE (team_id, period_start, period_type) -); -CREATE INDEX idx_email_metrics_team_period ON email_metrics(team_id, period_type, period_start); -``` -- **Files**: New migration pair (next available number after Phase 1 migrations) - -### 3.2 Metrics worker -- New asynq periodic task (`TaskMetricsAggregate`) that runs hourly -- Queries `email_events` table grouped by hour/day and upserts into `email_metrics` -- Also increment counters in real-time from `EmailSendHandler` on delivery/bounce events -- **Files**: New `internal/worker/metrics_handler.go`, update `internal/worker/tasks.go`, - `internal/worker/server.go` - -### 3.3 Metrics API endpoint -- `GET /metrics?period=7d|30d|24h` — returns time-series + aggregate totals for the team -- Response: stat card values (total sent, delivery rate, open rate, bounce rate) + - chart data (per-day or per-hour breakdown) -- **Files**: New `internal/handler/metrics.go`, new `internal/service/metrics.go`, - new `internal/repository/postgres/metrics.go`, update `internal/server/server.go` - -### 3.4 Wire frontend metrics page -- Replace empty data arrays with `useQuery` fetching from `GET /metrics` -- Populate stat cards with real totals, charts with time-series data -- Show empty state when no data, charts when data exists -- **Files**: `web/src/app/(dashboard)/metrics/page.tsx` - ---- - -## Phase 4 — Settings & Team Management - -### 4.1 Settings API endpoints -- `GET /settings/usage` — counts (emails today/month, domains, API keys, webhooks, contacts) -- `GET /settings/team` — team info + members list with roles -- `PATCH /settings/team` — update team name -- `GET /settings/smtp` — SMTP config for the team (hostname, ports, auth display) -- **Files**: New `internal/handler/settings.go`, new `internal/service/settings.go`, - update `internal/server/server.go` - -### 4.2 Team invitations & roles -- New `team_invitations` table: id, team_id, email, role, token, expires_at, accepted_at -- `POST /settings/team/invite` — send invitation email -- `POST /auth/accept-invite?token=...` — accept invitation, create account, join team -- Roles: `owner` (full control), `admin` (manage keys/domains), `member` (read + send) -- Role-based checks in auth middleware for destructive operations -- **Files**: New migration, new `internal/handler/team.go`, new `internal/service/team.go`, - new `internal/repository/postgres/team_invitation.go` - -### 4.3 Wire frontend settings -- **Usage tab**: Fetch from `GET /settings/usage` (currently hardcoded zeros are fine - since it's computing from real data) -- **Team tab**: Fetch members from `GET /settings/team`. Wire "Invite Member" dialog - to `POST /settings/team/invite`. Show role badges. Allow owner to remove members. -- **SMTP tab**: Fetch from `GET /settings/smtp` instead of hardcoded values -- **Integrations tab**: Remove for v1.0 (or keep "Coming soon" placeholder) -- **Files**: `web/src/app/(dashboard)/settings/page.tsx` - ---- - -## Phase 5 — Email Delivery Features - -### 5.1 Unsubscribe support -- Generate unique unsubscribe tokens per contact per audience -- Add `List-Unsubscribe` and `List-Unsubscribe-Post` headers to all outbound emails - (RFC 8058 one-click unsubscribe) -- New public endpoint: `POST /unsubscribe?token=...` (no auth required) -- On unsubscribe: mark contact as unsubscribed, create `unsubscribed` event, - dispatch webhook -- **Files**: New `internal/handler/unsubscribe.go`, update `internal/engine/sender.go`, - new migration for unsubscribe tokens table - -### 5.2 Open tracking -- When `domain.open_tracking` is enabled, inject a 1x1 transparent tracking pixel - at the bottom of HTML email bodies -- Pixel URL: `GET /track/open/{tracking_id}` (public, no auth) -- On load: create `opened` event, increment metrics counter, dispatch webhook -- Return a transparent 1x1 GIF -- **Files**: New `internal/handler/tracking.go`, update `internal/engine/sender.go` - (pixel injection), update `internal/server/server.go` (public route) - -### 5.3 Click tracking -- When `domain.click_tracking` is enabled, rewrite all `` links - in HTML email bodies -- Rewritten URL: `GET /track/click/{tracking_id}?url={encoded_original_url}` -- On click: create `clicked` event, increment metrics, dispatch webhook, - 302 redirect to original URL -- **Files**: Update `internal/handler/tracking.go`, update `internal/engine/sender.go` - (link rewriting logic) - -### 5.4 Fix template-based broadcasts -- **Bug**: `internal/worker/broadcast_handler.go` builds emails from inline broadcast - fields (FromAddress, Subject, HTMLBody, TextBody), completely ignoring `TemplateID` -- **Fix**: When broadcast has a `TemplateID`, fetch the published template version - and use its subject/HTML/text body. Support variable substitution - (e.g., `{{contact.first_name}}`, `{{contact.email}}`) -- **Files**: `internal/worker/broadcast_handler.go` - ---- - -## Phase 6 — Contact Management - -### 6.1 Bulk contact import (CSV) -- New endpoint: `POST /audiences/{audienceId}/contacts/import` (multipart form upload) -- Accept CSV with columns: email (required), first_name, last_name, + custom properties -- Process in background via asynq task (large files can have 100k+ rows) -- Return import job ID, allow polling status via `GET /audiences/{id}/contacts/import/{jobId}` -- Handle duplicates: skip existing or update on match -- **Files**: New `internal/handler/contact_import.go`, - new `internal/worker/import_handler.go`, new `internal/service/contact_import.go` - -### 6.2 Contact export -- `GET /audiences/{audienceId}/contacts/export?format=csv` -- Stream CSV download with all contacts + property values -- **Files**: Update `internal/handler/contact.go` - -### 6.3 Segment-based broadcast targeting -- **Gap**: Broadcasts reference an audience but ignore segments entirely -- **Fix**: Add optional `SegmentID` to broadcast model + DTO. When set, only - expand contacts matching the segment filter during broadcast send. -- **Files**: Update `internal/worker/broadcast_handler.go`, - update `internal/dto/broadcast.go`, update `internal/model/broadcast.go` - -### 6.4 Frontend: import/export UI -- Add "Import CSV" button on Audience page → file upload dialog with column mapping -- Add "Export" button → triggers CSV download -- Progress indicator for large imports -- **Files**: Update `web/src/app/(dashboard)/audience/page.tsx` - ---- - -## Phase 7 — Inbound SMTP - -### 7.1 Attachment parsing -- **Gap**: `internal/smtp/backend.go` always returns empty attachments array -- **Fix**: Parse MIME multipart body, extract attachments (filename, content-type, size) -- Store attachment content based on `storage.type` config (local filesystem or S3) -- Link attachments to inbound email record -- **Files**: `internal/smtp/backend.go`, new `internal/service/attachment.go` - -### 7.2 Inbound email webhook forwarding -- When an inbound email is received and stored, dispatch a webhook event (`email.inbound`) -- Payload: from, to, subject, text body, HTML body, attachment metadata (names, sizes, URLs) -- **Files**: Update `internal/worker/inbound_handler.go`, - update `internal/webhook/dispatcher.go` (add `email.inbound` event type) - ---- - -## Phase 8 — Polish & Hardening - -### 8.1 Error handling consistency -- Standardize repository error wrapping across all postgres repos -- Ensure all services return typed errors that handlers map to correct HTTP status codes -- **Files**: All `internal/repository/postgres/*.go`, all `internal/service/*.go` - -### 8.2 Missing database indexes -- Add compound index on `domain_dns_records(domain_id, record_type)` -- Add index on `email_events(recipient)` -- Review slow-query patterns and add indexes as needed -- **Files**: New migration - -### 8.3 API documentation -- OpenAPI/Swagger spec for all endpoints -- Serve interactive docs at `GET /docs` -- **Files**: New `api/openapi.yaml` or code-generated spec - -### 8.4 Rate limiting on sensitive endpoints -- Stricter limits on: `POST /auth/register` (5/min), `POST /auth/login` (10/min), - `POST /api-keys` (10/min) -- Prevent brute-force and enumeration -- **Files**: Update `internal/server/server.go` - -### 8.5 Frontend accessibility & UX -- Add `autocomplete` attributes to auth forms -- Add loading skeletons during data fetch (replace blank states during loading) -- Dynamic page titles (browser tab shows current section, e.g., "Domains — MailIt") -- **Files**: Various frontend components - -### 8.6 Integration tests -- Testcontainers-based tests: register → create domain → create API key → send email - → verify delivery event → check metrics -- SMTP backend tests for inbound email parsing + attachment extraction -- **Files**: New `internal/integration_test/` directory - ---- - -## Phase Dependency Map - -``` -Phase 1 (Bug Fixes) ──→ Phase 2 (Frontend) ──→ Phase 4 (Teams) - │ │ - ├──→ Phase 3 (Metrics) ────────────────────────┤ - │ │ - ├──→ Phase 5 (Delivery Features) ──→ Phase 6 (Contacts) - │ │ - ├──→ Phase 7 (Inbound SMTP) ───────────────────┤ - │ │ - └──────────────────────────────────────→ Phase 8 (Polish) -``` - -Phases 3, 5, 7 can run in parallel after Phase 1. Phase 4 depends on Phase 2. -Phase 6 depends on Phases 1 + 5. Phase 8 is ongoing but formalized last. - ---- - -## Bugs Found During Testing (Reference) - -| # | Severity | Description | Phase | -|---|----------|-------------|-------| -| 1 | CRITICAL | Worker queue name mismatch — emails never processed | 1.1 | -| 2 | CRITICAL | `varchar(10)` overflow on `RETURN_PATH` in DNS records | 1.2 | -| 3 | CRITICAL | Suppression "not found" treated as error — all sends fail | 1.3 | -| 4 | CRITICAL | `TaskEmailBatchSend` has no handler — batch silently dropped | 1.4 | -| 5 | CRITICAL | CORS not configured by default — frontend can't reach API | 1.5 | -| 6 | MEDIUM | Frontend sends `full_access`, backend expects `full` | 1.6 | -| 7 | MEDIUM | Template edit form crashes (null state) | 2.5 | -| 8 | MEDIUM | No error toasts on any mutation — failures are silent | 2.4 | -| 9 | MEDIUM | No logout button anywhere in the dashboard | 2.2 | -| 10 | MEDIUM | Token storage inconsistency (localStorage vs cookie) | 2.3 | -| 11 | MEDIUM | Settings page: dead invite button, hardcoded SMTP | 4.3 | -| 12 | MEDIUM | Broadcast handler ignores TemplateID | 5.4 | -| 13 | LOW | Root page shows Next.js default starter | 2.1 | -| 14 | LOW | No delete UI for domains, broadcasts, webhooks | 2.6 | -| 15 | LOW | Metrics page was hardcoded dummy data (now zeroed) | 3.4 | -| 16 | LOW | Missing DB indexes on dns_records and email_events | 8.2 | - -## Missing Features (Reference) - -| # | Feature | Phase | -|---|---------|-------| -| 1 | Metrics aggregation backend | 3 | -| 2 | Multi-user team management + invites | 4 | -| 3 | List-Unsubscribe headers (RFC 8058) | 5.1 | -| 4 | Open tracking (pixel injection) | 5.2 | -| 5 | Click tracking (link rewriting) | 5.3 | -| 6 | Template variable substitution in broadcasts | 5.4 | -| 7 | CSV contact import/export | 6.1, 6.2 | -| 8 | Segment-based broadcast targeting | 6.3 | -| 9 | Inbound SMTP attachment parsing | 7.1 | -| 10 | Inbound email webhook events | 7.2 | -| 11 | OpenAPI documentation | 8.3 | -| 12 | Integration tests | 8.6 | - ---- - -## Definition of Done (v1.0) - -A user can: - -1. Register an account and log in (and log out) -2. Add a sending domain and verify DNS records (SPF, DKIM, MX, DMARC) -3. Create API keys with scoped permissions -4. Send transactional emails via REST API with DKIM signing -5. Send batch emails via REST API -6. Create and manage versioned email templates -7. Import contacts via CSV and organize into audiences -8. Create broadcasts targeting audiences or segments with template support -9. View real delivery metrics (sent, delivered, bounced, opened, clicked) -10. Receive webhook notifications for all email events -11. Manage unsubscribes with RFC 8058 one-click support -12. Receive inbound emails with attachment parsing -13. Invite team members with role-based access -14. Self-host the entire stack via Docker Compose or Helm -15. Read API documentation at `/docs` - -All of the above work through both the REST API and the dashboard UI. diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index af7d05f..2165e73 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git ca-certificates diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a4208fc..6d2ee09 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: mailit-api: build: diff --git a/docker-compose.yml b/docker-compose.yml index 59bd7d0..4477bc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: mailit-api: build: diff --git a/internal/dto/broadcast.go b/internal/dto/broadcast.go index a23a492..eb5747e 100644 --- a/internal/dto/broadcast.go +++ b/internal/dto/broadcast.go @@ -22,11 +22,12 @@ type UpdateBroadcastRequest struct { } type BroadcastResponse struct { - ID string `json:"id"` - Name string `json:"name"` - AudienceID *string `json:"audience_id,omitempty"` - Status string `json:"status"` - TotalRecipients int `json:"total_recipients"` - SentCount int `json:"sent_count"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + AudienceID *string `json:"audience_id,omitempty"` + AudienceName *string `json:"audience_name,omitempty"` + Status string `json:"status"` + Recipients int `json:"recipients"` + Sent int `json:"sent"` + CreatedAt string `json:"created_at"` } diff --git a/internal/dto/common.go b/internal/dto/common.go index 0b5017c..f0dcacf 100644 --- a/internal/dto/common.go +++ b/internal/dto/common.go @@ -31,6 +31,11 @@ type PaginatedResponse[T any] struct { HasMore bool `json:"has_more"` } +// ListResponse wraps a slice for non-paginated list endpoints. +type ListResponse[T any] struct { + Data []T `json:"data"` +} + type ErrorResponse struct { StatusCode int `json:"statusCode"` Message string `json:"message"` diff --git a/internal/dto/domain.go b/internal/dto/domain.go index 9a979ef..afa78cb 100644 --- a/internal/dto/domain.go +++ b/internal/dto/domain.go @@ -9,7 +9,7 @@ type DomainResponse struct { Name string `json:"name"` Status string `json:"status"` Region *string `json:"region,omitempty"` - Records []DNSRecordResponse `json:"records"` + DNSRecords []DNSRecordResponse `json:"dns_records"` CreatedAt string `json:"created_at"` } diff --git a/internal/dto/template.go b/internal/dto/template.go index 1df741f..b2122fc 100644 --- a/internal/dto/template.go +++ b/internal/dto/template.go @@ -22,3 +22,15 @@ type TemplateResponse struct { Description *string `json:"description,omitempty"` CreatedAt string `json:"created_at"` } + +type TemplateDetailResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Subject *string `json:"subject,omitempty"` + HTML *string `json:"html,omitempty"` + Text *string `json:"text,omitempty"` + Published bool `json:"published"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/handler/apikey_test.go b/internal/handler/apikey_test.go index 736bff1..815ae24 100644 --- a/internal/handler/apikey_test.go +++ b/internal/handler/apikey_test.go @@ -94,8 +94,10 @@ func TestAPIKeyHandler_List_Success(t *testing.T) { mockSvc := new(mockpkg.MockAPIKeyService) h := NewAPIKeyHandler(mockSvc) - expected := []dto.APIKeyResponse{ - {ID: uuid.New().String(), Name: "Key 1", Permission: "full"}, + expected := &dto.ListResponse[dto.APIKeyResponse]{ + Data: []dto.APIKeyResponse{ + {ID: uuid.New().String(), Name: "Key 1", Permission: "full"}, + }, } mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return(expected, nil) diff --git a/internal/handler/audience_test.go b/internal/handler/audience_test.go index 6bde290..f0c8483 100644 --- a/internal/handler/audience_test.go +++ b/internal/handler/audience_test.go @@ -83,8 +83,10 @@ func TestAudienceHandler_List_Success(t *testing.T) { mockSvc := new(mockpkg.MockAudienceService) h := NewAudienceHandler(mockSvc) - expected := []dto.AudienceResponse{ - {ID: uuid.New().String(), Name: "Audience 1"}, + expected := &dto.ListResponse[dto.AudienceResponse]{ + Data: []dto.AudienceResponse{ + {ID: uuid.New().String(), Name: "Audience 1"}, + }, } mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return(expected, nil) diff --git a/internal/handler/contact_property_test.go b/internal/handler/contact_property_test.go index 677a0cf..5d8c6af 100644 --- a/internal/handler/contact_property_test.go +++ b/internal/handler/contact_property_test.go @@ -97,8 +97,10 @@ func TestContactPropertyHandler_List_Success(t *testing.T) { mockSvc := new(mockpkg.MockContactPropertyService) h := NewContactPropertyHandler(mockSvc) - expected := []dto.ContactPropertyResponse{ - {ID: uuid.New().String(), Name: "company", Label: "Company", Type: "string"}, + expected := &dto.ListResponse[dto.ContactPropertyResponse]{ + Data: []dto.ContactPropertyResponse{ + {ID: uuid.New().String(), Name: "company", Label: "Company", Type: "string"}, + }, } mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return(expected, nil) diff --git a/internal/handler/domain.go b/internal/handler/domain.go index c8fc958..244a676 100644 --- a/internal/handler/domain.go +++ b/internal/handler/domain.go @@ -82,7 +82,7 @@ func (h *DomainHandler) Get(w http.ResponseWriter, r *http.Request) { pkg.HandleError(w, err) return } - pkg.JSON(w, http.StatusOK, resp) + pkg.JSON(w, http.StatusOK, map[string]interface{}{"data": resp}) } // Update handles PATCH /domains/{domainId}. diff --git a/internal/handler/segment_test.go b/internal/handler/segment_test.go index 6679449..44cf43d 100644 --- a/internal/handler/segment_test.go +++ b/internal/handler/segment_test.go @@ -71,8 +71,10 @@ func TestSegmentHandler_List_Success(t *testing.T) { h := NewSegmentHandler(mockSvc) audienceID := uuid.New() - expected := []dto.SegmentResponse{ - {ID: uuid.New().String(), Name: "Segment 1"}, + expected := &dto.ListResponse[dto.SegmentResponse]{ + Data: []dto.SegmentResponse{ + {ID: uuid.New().String(), Name: "Segment 1"}, + }, } mockSvc.On("List", mock.Anything, testutil.TestTeamID, audienceID).Return(expected, nil) diff --git a/internal/handler/template_test.go b/internal/handler/template_test.go index a0dfd45..0d9c1a0 100644 --- a/internal/handler/template_test.go +++ b/internal/handler/template_test.go @@ -87,7 +87,7 @@ func TestTemplateHandler_Get_Success(t *testing.T) { h := NewTemplateHandler(mockSvc) templateID := uuid.New() - expected := &dto.TemplateResponse{ID: templateID.String(), Name: "My Template"} + expected := &dto.TemplateDetailResponse{ID: templateID.String(), Name: "My Template"} mockSvc.On("Get", mock.Anything, testutil.TestTeamID, templateID).Return(expected, nil) req := httptest.NewRequest(http.MethodGet, "/templates/"+templateID.String(), nil) diff --git a/internal/handler/topic_test.go b/internal/handler/topic_test.go index 5a13c18..fb11932 100644 --- a/internal/handler/topic_test.go +++ b/internal/handler/topic_test.go @@ -62,8 +62,10 @@ func TestTopicHandler_List_Success(t *testing.T) { mockSvc := new(mockpkg.MockTopicService) h := NewTopicHandler(mockSvc) - expected := []dto.TopicResponse{ - {ID: uuid.New().String(), Name: "Topic 1"}, + expected := &dto.ListResponse[dto.TopicResponse]{ + Data: []dto.TopicResponse{ + {ID: uuid.New().String(), Name: "Topic 1"}, + }, } mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return(expected, nil) @@ -82,7 +84,7 @@ func TestTopicHandler_List_ServiceError(t *testing.T) { mockSvc := new(mockpkg.MockTopicService) h := NewTopicHandler(mockSvc) - mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return([]dto.TopicResponse(nil), errors.New("db error")) + mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return((*dto.ListResponse[dto.TopicResponse])(nil), errors.New("db error")) req := httptest.NewRequest(http.MethodGet, "/topics", nil) req = testutil.AuthenticatedRequest(req, testutil.TestTeamID, testutil.TestUserID) diff --git a/internal/handler/webhook_test.go b/internal/handler/webhook_test.go index 05f6985..113e0b2 100644 --- a/internal/handler/webhook_test.go +++ b/internal/handler/webhook_test.go @@ -71,8 +71,10 @@ func TestWebhookHandler_List_Success(t *testing.T) { mockSvc := new(mockpkg.MockWebhookService) h := NewWebhookHandler(mockSvc) - expected := []dto.WebhookResponse{ - {ID: uuid.New().String(), URL: "https://example.com/webhook", Events: []string{"email.sent"}, Active: true}, + expected := &dto.ListResponse[dto.WebhookResponse]{ + Data: []dto.WebhookResponse{ + {ID: uuid.New().String(), URL: "https://example.com/webhook", Events: []string{"email.sent"}, Active: true}, + }, } mockSvc.On("List", mock.Anything, testutil.TestTeamID).Return(expected, nil) diff --git a/internal/service/apikey.go b/internal/service/apikey.go index 3d8bdcb..33b50f2 100644 --- a/internal/service/apikey.go +++ b/internal/service/apikey.go @@ -16,7 +16,7 @@ import ( // APIKeyService defines operations for managing API keys. type APIKeyService interface { Create(ctx context.Context, teamID uuid.UUID, req *dto.CreateAPIKeyRequest) (*dto.APIKeyResponse, error) - List(ctx context.Context, teamID uuid.UUID) ([]dto.APIKeyResponse, error) + List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.APIKeyResponse], error) Delete(ctx context.Context, teamID uuid.UUID, apiKeyID uuid.UUID) error } @@ -88,7 +88,7 @@ func (s *apiKeyService) Create(ctx context.Context, teamID uuid.UUID, req *dto.C }, nil } -func (s *apiKeyService) List(ctx context.Context, teamID uuid.UUID) ([]dto.APIKeyResponse, error) { +func (s *apiKeyService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.APIKeyResponse], error) { keys, err := s.apiKeyRepo.ListByTeamID(ctx, teamID) if err != nil { return nil, fmt.Errorf("listing API keys: %w", err) @@ -105,7 +105,7 @@ func (s *apiKeyService) List(ctx context.Context, teamID uuid.UUID) ([]dto.APIKe }) } - return responses, nil + return &dto.ListResponse[dto.APIKeyResponse]{Data: responses}, nil } func (s *apiKeyService) Delete(ctx context.Context, teamID uuid.UUID, apiKeyID uuid.UUID) error { diff --git a/internal/service/apikey_test.go b/internal/service/apikey_test.go index 8e8d242..634596c 100644 --- a/internal/service/apikey_test.go +++ b/internal/service/apikey_test.go @@ -54,12 +54,12 @@ func TestAPIKeyService_List_ReturnsKeysWithoutPlaintext(t *testing.T) { resp, err := svc.List(ctx, teamID) require.NoError(t, err) - assert.Len(t, resp, 2) + assert.Len(t, resp.Data, 2) // Token should be empty on list (only returned on create). - assert.Empty(t, resp[0].Token) - assert.Empty(t, resp[1].Token) - assert.Equal(t, "Test Key", resp[0].Name) - assert.Equal(t, "Second Key", resp[1].Name) + assert.Empty(t, resp.Data[0].Token) + assert.Empty(t, resp.Data[1].Token) + assert.Equal(t, "Test Key", resp.Data[0].Name) + assert.Equal(t, "Second Key", resp.Data[1].Name) apiKeyRepo.AssertExpectations(t) } diff --git a/internal/service/audience.go b/internal/service/audience.go index dc86d96..64deacf 100644 --- a/internal/service/audience.go +++ b/internal/service/audience.go @@ -16,7 +16,7 @@ import ( // AudienceService defines operations for managing audiences (contact lists). type AudienceService interface { Create(ctx context.Context, teamID uuid.UUID, req *dto.CreateAudienceRequest) (*dto.AudienceResponse, error) - List(ctx context.Context, teamID uuid.UUID) ([]dto.AudienceResponse, error) + List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.AudienceResponse], error) Get(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) (*dto.AudienceResponse, error) Delete(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) error } @@ -54,7 +54,7 @@ func (s *audienceService) Create(ctx context.Context, teamID uuid.UUID, req *dto return audienceToResponse(audience), nil } -func (s *audienceService) List(ctx context.Context, teamID uuid.UUID) ([]dto.AudienceResponse, error) { +func (s *audienceService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.AudienceResponse], error) { audiences, err := s.audienceRepo.ListByTeamID(ctx, teamID) if err != nil { return nil, fmt.Errorf("listing audiences: %w", err) @@ -65,7 +65,7 @@ func (s *audienceService) List(ctx context.Context, teamID uuid.UUID) ([]dto.Aud responses = append(responses, *audienceToResponse(&a)) } - return responses, nil + return &dto.ListResponse[dto.AudienceResponse]{Data: responses}, nil } func (s *audienceService) Get(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) (*dto.AudienceResponse, error) { diff --git a/internal/service/audience_test.go b/internal/service/audience_test.go index 2e26ae2..8090ce6 100644 --- a/internal/service/audience_test.go +++ b/internal/service/audience_test.go @@ -49,7 +49,7 @@ func TestAudienceService_List(t *testing.T) { resp, err := svc.List(ctx, teamID) require.NoError(t, err) - assert.Len(t, resp, 2) + assert.Len(t, resp.Data, 2) audienceRepo.AssertExpectations(t) } diff --git a/internal/service/broadcast.go b/internal/service/broadcast.go index c8c6722..dd8a5e9 100644 --- a/internal/service/broadcast.go +++ b/internal/service/broadcast.go @@ -270,12 +270,12 @@ func (s *broadcastService) Send(ctx context.Context, teamID uuid.UUID, broadcast // broadcastToResponse converts a model.Broadcast to a dto.BroadcastResponse. func broadcastToResponse(b *model.Broadcast) *dto.BroadcastResponse { resp := &dto.BroadcastResponse{ - ID: b.ID.String(), - Name: b.Name, - Status: b.Status, - TotalRecipients: b.TotalRecipients, - SentCount: b.SentCount, - CreatedAt: b.CreatedAt.Format(time.RFC3339), + ID: b.ID.String(), + Name: b.Name, + Status: b.Status, + Recipients: b.TotalRecipients, + Sent: b.SentCount, + CreatedAt: b.CreatedAt.Format(time.RFC3339), } if b.AudienceID != nil { diff --git a/internal/service/contact_property.go b/internal/service/contact_property.go index 1ebd89d..b7390c9 100644 --- a/internal/service/contact_property.go +++ b/internal/service/contact_property.go @@ -16,7 +16,7 @@ import ( // ContactPropertyService defines operations for managing custom contact properties. type ContactPropertyService interface { Create(ctx context.Context, teamID uuid.UUID, req *dto.CreateContactPropertyRequest) (*dto.ContactPropertyResponse, error) - List(ctx context.Context, teamID uuid.UUID) ([]dto.ContactPropertyResponse, error) + List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.ContactPropertyResponse], error) Update(ctx context.Context, teamID uuid.UUID, propertyID uuid.UUID, req *dto.UpdateContactPropertyRequest) (*dto.ContactPropertyResponse, error) Delete(ctx context.Context, teamID uuid.UUID, propertyID uuid.UUID) error } @@ -56,7 +56,7 @@ func (s *contactPropertyService) Create(ctx context.Context, teamID uuid.UUID, r return contactPropertyToResponse(property), nil } -func (s *contactPropertyService) List(ctx context.Context, teamID uuid.UUID) ([]dto.ContactPropertyResponse, error) { +func (s *contactPropertyService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.ContactPropertyResponse], error) { properties, err := s.propertyRepo.ListByTeamID(ctx, teamID) if err != nil { return nil, fmt.Errorf("listing contact properties: %w", err) @@ -67,7 +67,7 @@ func (s *contactPropertyService) List(ctx context.Context, teamID uuid.UUID) ([] responses = append(responses, *contactPropertyToResponse(&p)) } - return responses, nil + return &dto.ListResponse[dto.ContactPropertyResponse]{Data: responses}, nil } func (s *contactPropertyService) Update(ctx context.Context, teamID uuid.UUID, propertyID uuid.UUID, req *dto.UpdateContactPropertyRequest) (*dto.ContactPropertyResponse, error) { diff --git a/internal/service/contact_property_test.go b/internal/service/contact_property_test.go index 18a82c7..41f0396 100644 --- a/internal/service/contact_property_test.go +++ b/internal/service/contact_property_test.go @@ -65,8 +65,8 @@ func TestContactPropertyService_List(t *testing.T) { resp, err := svc.List(ctx, teamID) require.NoError(t, err) - assert.Len(t, resp, 1) - assert.Equal(t, "company", resp[0].Name) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "company", resp.Data[0].Name) propRepo.AssertExpectations(t) } diff --git a/internal/service/domain.go b/internal/service/domain.go index a58107e..1583aca 100644 --- a/internal/service/domain.go +++ b/internal/service/domain.go @@ -338,12 +338,12 @@ func (s *domainService) buildDomainResponse(domain *model.Domain, records []mode } return &dto.DomainResponse{ - ID: domain.ID.String(), - Name: domain.Name, - Status: domain.Status, - Region: domain.Region, - Records: dnsRecords, - CreatedAt: domain.CreatedAt.Format(time.RFC3339), + ID: domain.ID.String(), + Name: domain.Name, + Status: domain.Status, + Region: domain.Region, + DNSRecords: dnsRecords, + CreatedAt: domain.CreatedAt.Format(time.RFC3339), } } diff --git a/internal/service/domain_test.go b/internal/service/domain_test.go index 28db4e1..32e7959 100644 --- a/internal/service/domain_test.go +++ b/internal/service/domain_test.go @@ -43,7 +43,7 @@ func TestDomainService_Create_HappyPath(t *testing.T) { require.NoError(t, err) assert.Equal(t, "example.com", resp.Name) assert.Equal(t, model.DomainStatusPending, resp.Status) - assert.Len(t, resp.Records, 5) + assert.Len(t, resp.DNSRecords, 5) domainRepo.AssertExpectations(t) dnsRepo.AssertExpectations(t) diff --git a/internal/service/segment.go b/internal/service/segment.go index e4d111f..b7b946e 100644 --- a/internal/service/segment.go +++ b/internal/service/segment.go @@ -16,7 +16,7 @@ import ( // SegmentService defines operations for managing audience segments. type SegmentService interface { Create(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID, req *dto.CreateSegmentRequest) (*dto.SegmentResponse, error) - List(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) ([]dto.SegmentResponse, error) + List(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) (*dto.ListResponse[dto.SegmentResponse], error) Update(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID, segmentID uuid.UUID, req *dto.UpdateSegmentRequest) (*dto.SegmentResponse, error) Delete(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID, segmentID uuid.UUID) error } @@ -79,7 +79,7 @@ func (s *segmentService) Create(ctx context.Context, teamID uuid.UUID, audienceI return segmentToResponse(segment), nil } -func (s *segmentService) List(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) ([]dto.SegmentResponse, error) { +func (s *segmentService) List(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) (*dto.ListResponse[dto.SegmentResponse], error) { if err := s.verifyAudienceOwnership(ctx, teamID, audienceID); err != nil { return nil, err } @@ -94,7 +94,7 @@ func (s *segmentService) List(ctx context.Context, teamID uuid.UUID, audienceID responses = append(responses, *segmentToResponse(&seg)) } - return responses, nil + return &dto.ListResponse[dto.SegmentResponse]{Data: responses}, nil } func (s *segmentService) Update(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID, segmentID uuid.UUID, req *dto.UpdateSegmentRequest) (*dto.SegmentResponse, error) { diff --git a/internal/service/segment_test.go b/internal/service/segment_test.go index 890b4a6..dd0242f 100644 --- a/internal/service/segment_test.go +++ b/internal/service/segment_test.go @@ -96,8 +96,8 @@ func TestSegmentService_List(t *testing.T) { resp, err := svc.List(ctx, teamID, aud.ID) require.NoError(t, err) - assert.Len(t, resp, 1) - assert.Equal(t, "Active Users", resp[0].Name) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "Active Users", resp.Data[0].Name) segmentRepo.AssertExpectations(t) audienceRepo.AssertExpectations(t) diff --git a/internal/service/template.go b/internal/service/template.go index cd58ba3..4747995 100644 --- a/internal/service/template.go +++ b/internal/service/template.go @@ -17,7 +17,7 @@ import ( type TemplateService interface { Create(ctx context.Context, teamID uuid.UUID, req *dto.CreateTemplateRequest) (*dto.TemplateResponse, error) List(ctx context.Context, teamID uuid.UUID, params *dto.PaginationParams) (*dto.PaginatedResponse[dto.TemplateResponse], error) - Get(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateResponse, error) + Get(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateDetailResponse, error) Update(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID, req *dto.UpdateTemplateRequest) (*dto.TemplateResponse, error) Delete(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) error Publish(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateResponse, error) @@ -105,7 +105,7 @@ func (s *templateService) List(ctx context.Context, teamID uuid.UUID, params *dt }, nil } -func (s *templateService) Get(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateResponse, error) { +func (s *templateService) Get(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateDetailResponse, error) { template, err := s.templateRepo.GetByID(ctx, templateID) if err != nil { return nil, fmt.Errorf("template not found: %w", err) @@ -116,7 +116,24 @@ func (s *templateService) Get(ctx context.Context, teamID uuid.UUID, templateID return nil, fmt.Errorf("template not found: %w", postgres.ErrNotFound) } - return templateToResponse(template), nil + resp := &dto.TemplateDetailResponse{ + ID: template.ID.String(), + Name: template.Name, + Description: template.Description, + CreatedAt: template.CreatedAt.Format(time.RFC3339), + UpdatedAt: template.UpdatedAt.Format(time.RFC3339), + } + + // Fetch latest version to include content fields. + latest, err := s.templateVersionRepo.GetLatestByTemplateID(ctx, template.ID) + if err == nil && latest != nil { + resp.Subject = latest.Subject + resp.HTML = latest.HTMLBody + resp.Text = latest.TextBody + resp.Published = latest.Published + } + + return resp, nil } func (s *templateService) Update(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID, req *dto.UpdateTemplateRequest) (*dto.TemplateResponse, error) { diff --git a/internal/service/template_test.go b/internal/service/template_test.go index b287e50..457fcf0 100644 --- a/internal/service/template_test.go +++ b/internal/service/template_test.go @@ -69,14 +69,19 @@ func TestTemplateService_Get_HappyPath(t *testing.T) { teamID := testutil.TestTeamID tmpl := testutil.NewTestTemplate() + version := testutil.NewTestTemplateVersion(tmpl.ID) templateRepo.On("GetByID", ctx, tmpl.ID).Return(tmpl, nil) + versionRepo.On("GetLatestByTemplateID", ctx, tmpl.ID).Return(version, nil) resp, err := svc.Get(ctx, teamID, tmpl.ID) require.NoError(t, err) assert.Equal(t, tmpl.ID.String(), resp.ID) + assert.Equal(t, version.Subject, resp.Subject) + assert.Equal(t, version.HTMLBody, resp.HTML) templateRepo.AssertExpectations(t) + versionRepo.AssertExpectations(t) } func TestTemplateService_Get_WrongTeam(t *testing.T) { @@ -96,6 +101,7 @@ func TestTemplateService_Get_WrongTeam(t *testing.T) { assert.Contains(t, err.Error(), "not found") templateRepo.AssertExpectations(t) + // versionRepo should not have been called since team check failed first. } func TestTemplateService_Update_WithContentCreatesNewVersion(t *testing.T) { diff --git a/internal/service/topic.go b/internal/service/topic.go index 99a697f..7f6290c 100644 --- a/internal/service/topic.go +++ b/internal/service/topic.go @@ -16,7 +16,7 @@ import ( // TopicService defines operations for managing subscription topics. type TopicService interface { Create(ctx context.Context, teamID uuid.UUID, req *dto.CreateTopicRequest) (*dto.TopicResponse, error) - List(ctx context.Context, teamID uuid.UUID) ([]dto.TopicResponse, error) + List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.TopicResponse], error) Update(ctx context.Context, teamID uuid.UUID, topicID uuid.UUID, req *dto.UpdateTopicRequest) (*dto.TopicResponse, error) Delete(ctx context.Context, teamID uuid.UUID, topicID uuid.UUID) error } @@ -55,7 +55,7 @@ func (s *topicService) Create(ctx context.Context, teamID uuid.UUID, req *dto.Cr return topicToResponse(topic), nil } -func (s *topicService) List(ctx context.Context, teamID uuid.UUID) ([]dto.TopicResponse, error) { +func (s *topicService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.TopicResponse], error) { topics, err := s.topicRepo.ListByTeamID(ctx, teamID) if err != nil { return nil, fmt.Errorf("listing topics: %w", err) @@ -66,7 +66,7 @@ func (s *topicService) List(ctx context.Context, teamID uuid.UUID) ([]dto.TopicR responses = append(responses, *topicToResponse(&t)) } - return responses, nil + return &dto.ListResponse[dto.TopicResponse]{Data: responses}, nil } func (s *topicService) Update(ctx context.Context, teamID uuid.UUID, topicID uuid.UUID, req *dto.UpdateTopicRequest) (*dto.TopicResponse, error) { diff --git a/internal/service/topic_test.go b/internal/service/topic_test.go index e2b5887..c928cb6 100644 --- a/internal/service/topic_test.go +++ b/internal/service/topic_test.go @@ -62,8 +62,8 @@ func TestTopicService_List(t *testing.T) { resp, err := svc.List(ctx, teamID) require.NoError(t, err) - assert.Len(t, resp, 1) - assert.Equal(t, "Monthly Newsletter", resp[0].Name) + assert.Len(t, resp.Data, 1) + assert.Equal(t, "Monthly Newsletter", resp.Data[0].Name) topicRepo.AssertExpectations(t) } diff --git a/internal/service/webhook.go b/internal/service/webhook.go index f2c0e01..41ddbe7 100644 --- a/internal/service/webhook.go +++ b/internal/service/webhook.go @@ -16,7 +16,7 @@ import ( // WebhookService defines operations for managing webhook endpoints. type WebhookService interface { Create(ctx context.Context, teamID uuid.UUID, req *dto.CreateWebhookRequest) (*dto.WebhookResponse, error) - List(ctx context.Context, teamID uuid.UUID) ([]dto.WebhookResponse, error) + List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.WebhookResponse], error) Get(ctx context.Context, teamID uuid.UUID, webhookID uuid.UUID) (*dto.WebhookResponse, error) Update(ctx context.Context, teamID uuid.UUID, webhookID uuid.UUID, req *dto.UpdateWebhookRequest) (*dto.WebhookResponse, error) Delete(ctx context.Context, teamID uuid.UUID, webhookID uuid.UUID) error @@ -64,7 +64,7 @@ func (s *webhookService) Create(ctx context.Context, teamID uuid.UUID, req *dto. return webhookToResponse(webhook), nil } -func (s *webhookService) List(ctx context.Context, teamID uuid.UUID) ([]dto.WebhookResponse, error) { +func (s *webhookService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.WebhookResponse], error) { webhooks, err := s.webhookRepo.ListByTeamID(ctx, teamID) if err != nil { return nil, fmt.Errorf("listing webhooks: %w", err) @@ -75,7 +75,7 @@ func (s *webhookService) List(ctx context.Context, teamID uuid.UUID) ([]dto.Webh responses = append(responses, *webhookToResponse(&w)) } - return responses, nil + return &dto.ListResponse[dto.WebhookResponse]{Data: responses}, nil } func (s *webhookService) Get(ctx context.Context, teamID uuid.UUID, webhookID uuid.UUID) (*dto.WebhookResponse, error) { diff --git a/internal/service/webhook_test.go b/internal/service/webhook_test.go index dc88a00..a62eca8 100644 --- a/internal/service/webhook_test.go +++ b/internal/service/webhook_test.go @@ -52,8 +52,8 @@ func TestWebhookService_List_ReturnsWebhooks(t *testing.T) { resp, err := svc.List(ctx, teamID) require.NoError(t, err) - assert.Len(t, resp, 1) - assert.Equal(t, wh.URL, resp[0].URL) + assert.Len(t, resp.Data, 1) + assert.Equal(t, wh.URL, resp.Data[0].URL) webhookRepo.AssertExpectations(t) } diff --git a/internal/testutil/mock/services.go b/internal/testutil/mock/services.go index 29e1522..ab08f93 100644 --- a/internal/testutil/mock/services.go +++ b/internal/testutil/mock/services.go @@ -130,9 +130,12 @@ func (m *MockAPIKeyService) Create(ctx context.Context, teamID uuid.UUID, req *d } return args.Get(0).(*dto.APIKeyResponse), args.Error(1) } -func (m *MockAPIKeyService) List(ctx context.Context, teamID uuid.UUID) ([]dto.APIKeyResponse, error) { +func (m *MockAPIKeyService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.APIKeyResponse], error) { args := m.Called(ctx, teamID) - return args.Get(0).([]dto.APIKeyResponse), args.Error(1) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.ListResponse[dto.APIKeyResponse]), args.Error(1) } func (m *MockAPIKeyService) Delete(ctx context.Context, teamID uuid.UUID, apiKeyID uuid.UUID) error { return m.Called(ctx, teamID, apiKeyID).Error(0) @@ -149,9 +152,12 @@ func (m *MockAudienceService) Create(ctx context.Context, teamID uuid.UUID, req } return args.Get(0).(*dto.AudienceResponse), args.Error(1) } -func (m *MockAudienceService) List(ctx context.Context, teamID uuid.UUID) ([]dto.AudienceResponse, error) { +func (m *MockAudienceService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.AudienceResponse], error) { args := m.Called(ctx, teamID) - return args.Get(0).([]dto.AudienceResponse), args.Error(1) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.ListResponse[dto.AudienceResponse]), args.Error(1) } func (m *MockAudienceService) Get(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) (*dto.AudienceResponse, error) { args := m.Called(ctx, teamID, audienceID) @@ -211,9 +217,12 @@ func (m *MockContactPropertyService) Create(ctx context.Context, teamID uuid.UUI } return args.Get(0).(*dto.ContactPropertyResponse), args.Error(1) } -func (m *MockContactPropertyService) List(ctx context.Context, teamID uuid.UUID) ([]dto.ContactPropertyResponse, error) { +func (m *MockContactPropertyService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.ContactPropertyResponse], error) { args := m.Called(ctx, teamID) - return args.Get(0).([]dto.ContactPropertyResponse), args.Error(1) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.ListResponse[dto.ContactPropertyResponse]), args.Error(1) } func (m *MockContactPropertyService) Update(ctx context.Context, teamID uuid.UUID, propertyID uuid.UUID, req *dto.UpdateContactPropertyRequest) (*dto.ContactPropertyResponse, error) { args := m.Called(ctx, teamID, propertyID, req) @@ -237,9 +246,12 @@ func (m *MockTopicService) Create(ctx context.Context, teamID uuid.UUID, req *dt } return args.Get(0).(*dto.TopicResponse), args.Error(1) } -func (m *MockTopicService) List(ctx context.Context, teamID uuid.UUID) ([]dto.TopicResponse, error) { +func (m *MockTopicService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.TopicResponse], error) { args := m.Called(ctx, teamID) - return args.Get(0).([]dto.TopicResponse), args.Error(1) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.ListResponse[dto.TopicResponse]), args.Error(1) } func (m *MockTopicService) Update(ctx context.Context, teamID uuid.UUID, topicID uuid.UUID, req *dto.UpdateTopicRequest) (*dto.TopicResponse, error) { args := m.Called(ctx, teamID, topicID, req) @@ -263,9 +275,12 @@ func (m *MockSegmentService) Create(ctx context.Context, teamID uuid.UUID, audie } return args.Get(0).(*dto.SegmentResponse), args.Error(1) } -func (m *MockSegmentService) List(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) ([]dto.SegmentResponse, error) { +func (m *MockSegmentService) List(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID) (*dto.ListResponse[dto.SegmentResponse], error) { args := m.Called(ctx, teamID, audienceID) - return args.Get(0).([]dto.SegmentResponse), args.Error(1) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.ListResponse[dto.SegmentResponse]), args.Error(1) } func (m *MockSegmentService) Update(ctx context.Context, teamID uuid.UUID, audienceID uuid.UUID, segmentID uuid.UUID, req *dto.UpdateSegmentRequest) (*dto.SegmentResponse, error) { args := m.Called(ctx, teamID, audienceID, segmentID, req) @@ -296,12 +311,12 @@ func (m *MockTemplateService) List(ctx context.Context, teamID uuid.UUID, params } return args.Get(0).(*dto.PaginatedResponse[dto.TemplateResponse]), args.Error(1) } -func (m *MockTemplateService) Get(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateResponse, error) { +func (m *MockTemplateService) Get(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID) (*dto.TemplateDetailResponse, error) { args := m.Called(ctx, teamID, templateID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*dto.TemplateResponse), args.Error(1) + return args.Get(0).(*dto.TemplateDetailResponse), args.Error(1) } func (m *MockTemplateService) Update(ctx context.Context, teamID uuid.UUID, templateID uuid.UUID, req *dto.UpdateTemplateRequest) (*dto.TemplateResponse, error) { args := m.Called(ctx, teamID, templateID, req) @@ -375,9 +390,12 @@ func (m *MockWebhookService) Create(ctx context.Context, teamID uuid.UUID, req * } return args.Get(0).(*dto.WebhookResponse), args.Error(1) } -func (m *MockWebhookService) List(ctx context.Context, teamID uuid.UUID) ([]dto.WebhookResponse, error) { +func (m *MockWebhookService) List(ctx context.Context, teamID uuid.UUID) (*dto.ListResponse[dto.WebhookResponse], error) { args := m.Called(ctx, teamID) - return args.Get(0).([]dto.WebhookResponse), args.Error(1) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.ListResponse[dto.WebhookResponse]), args.Error(1) } func (m *MockWebhookService) Get(ctx context.Context, teamID uuid.UUID, webhookID uuid.UUID) (*dto.WebhookResponse, error) { args := m.Called(ctx, teamID, webhookID) diff --git a/plan/v1.1-bugfixes.md b/plan/v1.1-bugfixes.md new file mode 100644 index 0000000..573f50e --- /dev/null +++ b/plan/v1.1-bugfixes.md @@ -0,0 +1,275 @@ +# MailIt v1.1 — Bugfix & Polish Plan + +Addresses all issues found during dogfood QA (8 issues) and the earlier grading audit. + +## Issue Summary + +| # | Issue | Severity | Root Cause | Phase | +|---|-------|----------|------------|-------| +| D-001 | Email rows not clickable | false positive | Dogfood tool limitation — `onRowClick` IS wired in DataTable | N/A | +| D-002 | Broadcasts page crashes (TypeError) | critical | Frontend expects `recipients`/`sent`/`audience_name`, backend sends `total_recipients`/`sent_count`/no audience_name | 2 | +| D-003 | Template rows not clickable | false positive | Same as D-001 — DataTable `onRowClick` works | N/A | +| D-004 | Audience dropdown empty | critical | `GET /audiences` returns bare `[...]`, frontend expects `{data: [...]}` | 1 | +| D-005 | API Keys shows empty | high | `GET /api-keys` returns bare `[...]`, frontend expects `{data: [...]}` | 1 | +| D-006 | Domain detail "not found" | high | `GET /domains/{id}` returns bare object `{...}`, frontend expects `{data: {...}}`; also field name mismatch `records` vs `dns_records` | 2 | +| D-007 | Logs shows empty | medium | Actually paginated — works. Logs just aren't populated (feature gap, not a bug) | N/A | +| D-008 | Metrics shows 0 sent | medium | Correct behavior — email failed delivery, so `sent: 0` is accurate | N/A | +| G-001 | Template detail API missing content | high | `TemplateResponse` lacks `subject`, `html`, `text`, `published`, `updated_at` | 3 | +| G-002 | 6 list endpoints return bare arrays | high | Services return `[]dto.XxxResponse` not wrapped | 1 | +| G-003 | Dockerfile Go version mismatch | low | `golang:1.23-alpine` should be `golang:1.25-alpine` | 4 | +| G-004 | docker-compose `version` deprecated | low | Both compose files have `version: '3.8'` | 4 | + +**Real bugs to fix: 8** (D-002, D-004, D-005, D-006, G-001, G-002, G-003, G-004) +**False positives / working as designed: 4** (D-001, D-003, D-007, D-008) + +--- + +## Phase 1: List Response Wrapping (6 endpoints) + +**Problem**: 6 list endpoints return bare JSON arrays `[...]`. The frontend uniformly does `data?.data ?? []` expecting `{data: [...]}`. + +**Fix**: Add a `ListResponse[T]` generic wrapper to the DTO layer and update 6 services + their interfaces. + +### Step 1.1 — Add ListResponse to DTO + +**File**: `internal/dto/pagination.go` + +Add after the existing `PaginatedResponse`: +```go +type ListResponse[T any] struct { + Data []T `json:"data"` +} +``` + +### Step 1.2 — Update 6 service return types + +Each service's `List()` method changes from returning `([]dto.XxxResponse, error)` to `(*dto.ListResponse[dto.XxxResponse], error)`, and wraps the slice in the struct. + +| File | Interface Method | Old Return | New Return | +|------|-----------------|------------|------------| +| `internal/service/audience.go` | `List(ctx, teamID)` | `([]dto.AudienceResponse, error)` | `(*dto.ListResponse[dto.AudienceResponse], error)` | +| `internal/service/apikey.go` | `List(ctx, teamID)` | `([]dto.APIKeyResponse, error)` | `(*dto.ListResponse[dto.APIKeyResponse], error)` | +| `internal/service/webhook.go` | `List(ctx, teamID)` | `([]dto.WebhookResponse, error)` | `(*dto.ListResponse[dto.WebhookResponse], error)` | +| `internal/service/topic.go` | `List(ctx, teamID)` | `([]dto.TopicResponse, error)` | `(*dto.ListResponse[dto.TopicResponse], error)` | +| `internal/service/contact_property.go` | `List(ctx, teamID)` | `([]dto.ContactPropertyResponse, error)` | `(*dto.ListResponse[dto.ContactPropertyResponse], error)` | +| `internal/service/segment.go` | `List(ctx, teamID, audienceID)` | `([]dto.SegmentResponse, error)` | `(*dto.ListResponse[dto.SegmentResponse], error)` | + +For each, the implementation change is minimal — wrap the existing slice: +```go +// Before: +return data, nil + +// After: +return &dto.ListResponse[dto.XxxResponse]{Data: data}, nil +``` + +### Step 1.3 — Update service interfaces + +Each interface definition (at the top of each service file) must match the new return type. + +### Step 1.4 — Update mock services + +**File**: `internal/testutil/mock/services.go` + +Update the 6 mock methods to match the new return types. Each mock's `List()` method changes its `.Get(0)` type assertion. + +### Step 1.5 — Update handler tests + +Any handler tests that reference the old return types need updated assertions. + +**Verify**: `go build ./... && go test ./...` + +--- + +## Phase 2: Field Name Mismatches (Broadcasts + Domain Detail) + +### Step 2.1 — Broadcast response field names + +**Problem**: Frontend expects `recipients`, `sent`, `audience_name`. Backend sends `total_recipients`, `sent_count`, no `audience_name`. + +**File**: `internal/dto/broadcast.go` + +Change `BroadcastResponse`: +```go +// Before: +TotalRecipients int `json:"total_recipients"` +SentCount int `json:"sent_count"` + +// After: +Recipients int `json:"recipients"` +Sent int `json:"sent"` +AudienceName *string `json:"audience_name,omitempty"` +``` + +**File**: `internal/service/broadcast.go` + +Update `broadcastToResponse()` to: +- Map `model.Broadcast.TotalRecipients` → `dto.BroadcastResponse.Recipients` +- Map `model.Broadcast.SentCount` → `dto.BroadcastResponse.Sent` +- Look up audience name from the audience repository and include it + +If the audience lookup is expensive for list operations, an alternative is to do a bulk-fetch of audience names. Check if the broadcast model already stores the audience name, or if a join is needed. + +**Simpler alternative**: If adding the join is complex, just add `AudienceName` as empty for now and update the frontend to handle nil gracefully. The field name fixes (`recipients`, `sent`) are the critical part that fix the crash. + +### Step 2.2 — Domain response field name + detail wrapping + +**Problem 1**: Frontend expects `dns_records`, backend sends `records`. + +**File**: `internal/dto/domain.go` + +```go +// Before: +Records []DNSRecordResponse `json:"records"` + +// After: +DNSRecords []DNSRecordResponse `json:"dns_records"` +``` + +Then update all references to `Records` in the domain service to `DNSRecords`. + +**Problem 2**: `GET /domains/{id}` returns bare `{id, name, status, ...}` but frontend expects `{data: {id, name, ...}}`. + +**File**: `internal/handler/domain.go` — `Get()` method + +Wrap the response: +```go +// Before: +pkg.JSON(w, http.StatusOK, resp) + +// After: +pkg.JSON(w, http.StatusOK, map[string]interface{}{"data": resp}) +``` + +Or better, use a generic wrapper: +```go +type SingleResponse[T any] struct { + Data T `json:"data"` +} +``` + +**Note**: Check if other single-object GET endpoints need the same wrapping. The frontend pattern `data?.data` is used on domain detail. Verify if email detail and broadcast detail also use this pattern — if so, wrap those too. + +### Step 2.3 — Update tests + +Update any handler/service tests that assert on the old field names. + +**Verify**: `go build ./... && go test ./...` + +--- + +## Phase 3: Template Detail Response + +**Problem**: `GET /templates/{id}` returns `{id, name, description, created_at}` — missing `subject`, `html`, `text`, `published`, `updated_at` that the frontend editor needs. + +### Step 3.1 — Add TemplateDetailResponse DTO + +**File**: `internal/dto/template.go` + +```go +type TemplateDetailResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Subject *string `json:"subject,omitempty"` + HTML *string `json:"html,omitempty"` + Text *string `json:"text,omitempty"` + Published bool `json:"published"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} +``` + +### Step 3.2 — Update TemplateService.Get() return type + +**File**: `internal/service/template.go` + +Change the interface: +```go +Get(ctx context.Context, teamID, templateID uuid.UUID) (*dto.TemplateDetailResponse, error) +``` + +Update the implementation to also fetch the latest template version: +```go +func (s *templateService) Get(ctx context.Context, teamID, templateID uuid.UUID) (*dto.TemplateDetailResponse, error) { + template, err := s.templateRepo.GetByID(ctx, templateID) + // ... team check ... + + latest, err := s.templateVersionRepo.GetLatestByTemplateID(ctx, template.ID) + // ... handle error (might not have a version yet) ... + + return &dto.TemplateDetailResponse{ + ID: template.ID.String(), + Name: template.Name, + Description: template.Description, + Subject: latest.Subject, + HTML: latest.HTMLBody, + Text: latest.TextBody, + Published: latest.Published, + CreatedAt: template.CreatedAt.Format(time.RFC3339), + UpdatedAt: template.UpdatedAt.Format(time.RFC3339), + }, nil +} +``` + +### Step 3.3 — Update mock and tests + +Update the mock service in `internal/testutil/mock/services.go` and any template handler tests. + +**Verify**: `go build ./... && go test ./...` + +--- + +## Phase 4: Config / Infrastructure + +### Step 4.1 — Dockerfile Go version + +**File**: `deploy/docker/Dockerfile` + +```dockerfile +# Before: +FROM golang:1.23-alpine AS builder + +# After: +FROM golang:1.25-alpine AS builder +``` + +### Step 4.2 — Remove deprecated docker-compose version field + +**Files**: `docker-compose.yml`, `docker-compose.dev.yml` + +Remove the first line `version: '3.8'` from both files. + +**Verify**: `docker compose -f docker-compose.dev.yml config 2>&1 | grep -i version` should produce no output. + +--- + +## Phase 5: End-to-End Verification + +1. Start postgres + redis via docker compose +2. Build and start Go server +3. Start Next.js dev server +4. Run the dogfood skill against localhost:3001 +5. Verify: + - Broadcasts page loads without crashing + - Audience dropdown shows "Newsletter Subscribers" + - API Keys page shows existing keys + - Domain detail page loads with DNS records table + - Template detail page shows subject/html/text content +6. Run `go test ./...` and `npx vitest run` +7. Stop everything + +--- + +## Execution Order + +Phases 1-4 can be done in sequence (each builds cleanly on prior state). Phase 5 is the final validation. + +| Phase | Effort | Files Changed | Tests Changed | +|-------|--------|---------------|---------------| +| 1: List wrapping | ~30 min | 7 Go files (1 DTO + 6 services) + interfaces + mocks | ~6 mock methods | +| 2: Field mismatches | ~30 min | 3 Go files (broadcast DTO + domain DTO + domain handler) + service | ~4 tests | +| 3: Template detail | ~20 min | 2 Go files (DTO + service) + mock | ~2 tests | +| 4: Config/infra | ~5 min | 3 files (Dockerfile + 2 compose) | 0 | +| 5: E2E verify | ~15 min | 0 | 0 | +| **Total** | **~1.5 hr** | **~15 files** | **~12 test updates** | From f5c9d0b11c1d27e852208e892572e16e0f13ad52 Mon Sep 17 00:00:00 2001 From: Victor Ivanov Date: Thu, 26 Feb 2026 01:33:17 +0000 Subject: [PATCH 2/3] improve: Enhance deployment readiness and configuration - Expand .env.example with comprehensive documentation for all production environment variables, organized by service with clear descriptions - Improve setup.sh with robust validation, better error handling, and support for both 'docker compose' and legacy 'docker-compose' command - Enhance generate-dkim.sh with validation, file permission handling, and clearer DNS record instructions - Update docker-compose.yml with full configuration documentation, container names, enhanced healthchecks, and network isolation - Expand README deployment section with step-by-step Docker Compose setup, manual configuration steps, production recommendations, DNS setup guide, and Kubernetes/Helm examples All improvements maintain backward compatibility while making deployment and initial setup significantly clearer and more robust. Co-Authored-By: Claude Opus 4.6 --- .env.example | 92 +++++++++++++++++------- README.md | 152 ++++++++++++++++++++++++++++++++++++--- docker-compose.yml | 114 ++++++++++++++++++++++++++--- scripts/generate-dkim.sh | 72 +++++++++++++------ scripts/setup.sh | 97 ++++++++++++++++++++----- 5 files changed, 444 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index 8e77967..dd4689f 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,78 @@ # MailIt Environment Variables -# Copy to .env and edit before running +# Copy to .env and edit before running. All values below are required for production deployment. +# For development, many have sensible defaults. -# Database +# ─── PORTS ────────────────────────────────────────────────────────────── +# Expose these ports in your firewall/load balancer +HTTP_PORT=8080 # MailIt API server (expose to internet if using direct connection) +SMTP_PORT=25 # SMTP inbound server (expose to internet for bounce/reply handling) +SMTP_SUBMISSION_PORT=587 # SMTP submission port (optional, for client submissions) +WEB_PORT=3000 # Next.js dashboard web UI + +# ─── DATABASE (PostgreSQL) ────────────────────────────────────────────── POSTGRES_USER=mailit -POSTGRES_PASSWORD=changeme +POSTGRES_PASSWORD=changeme # CHANGE THIS - use a secure password POSTGRES_DB=mailit -POSTGRES_HOST=localhost +POSTGRES_HOST=postgres # If using Docker Compose, use service name POSTGRES_PORT=5432 -# Redis -REDIS_ADDR=localhost:6379 -REDIS_PASSWORD= +# ─── CACHE (Redis) ────────────────────────────────────────────────────── +REDIS_ADDR=redis:6379 # If using Docker Compose, use service name +REDIS_PASSWORD= # Leave empty if not required; set a strong password in production + +# ─── AUTHENTICATION ───────────────────────────────────────────────────── +JWT_SECRET=changeme-generate-a-random-string # CHANGE THIS - min 32 chars, use a random secure string +BCRYPT_COST=12 # bcrypt hashing cost (10-14 recommended, default 12) +API_KEY_PREFIX=re_ # Prefix for generated API keys (Resend-compatible) + +# ─── EMAIL DOMAIN & SMTP ──────────────────────────────────────────────── +MAILIT_DOMAIN=mail.example.com # CHANGE THIS - your mail server's FQDN +MAILIT_HOSTNAME=mail.example.com # CHANGE THIS - same as MAILIT_DOMAIN +CORS_ORIGINS=http://localhost:3000 # CHANGE THIS - comma-separated list of allowed dashboard origins + +# ─── DKIM SIGNING ────────────────────────────────────────────────────── +DKIM_SELECTOR=mailit # DKIM selector (used in DNS records: selector._domainkey.domain) +DKIM_MASTER_KEY=changeme-32-byte-hex-key # CHANGE THIS - generate with: openssl rand -hex 16 for 32 bytes (64 chars) +DKIM_KEY_BITS=2048 # RSA key size (2048 or 4096) + +# ─── LOGGING ──────────────────────────────────────────────────────────── +LOG_LEVEL=info # debug | info | warn | error +LOG_FORMAT=json # json | text + +# ─── RATE LIMITING ────────────────────────────────────────────────────── +RATE_LIMIT_ENABLED=true # Enable rate limiting +RATE_LIMIT_DEFAULT_RPS=10 # Default requests per second +RATE_LIMIT_SEND_RPS=2 # Requests per second for send endpoint +RATE_LIMIT_BATCH_RPS=1 # Requests per second for batch endpoint + +# ─── STORAGE (Attachments) ────────────────────────────────────────────── +STORAGE_TYPE=local # local | s3 +STORAGE_LOCAL_PATH=/data/attachments # Path for local storage (in Docker, typically /data/attachments) -# Auth -JWT_SECRET=changeme-generate-a-random-string -API_KEY_PREFIX=re_ +# S3 Storage (only if STORAGE_TYPE=s3) +# STORAGE_S3_BUCKET= +# STORAGE_S3_REGION= +# STORAGE_S3_ENDPOINT= +# STORAGE_S3_ACCESS_KEY= +# STORAGE_S3_SECRET_KEY= -# SMTP -MAILIT_DOMAIN=mail.example.com -MAILIT_HOSTNAME=mail.example.com -SMTP_LISTEN_ADDR=:25 -SMTP_SUBMISSION_ADDR=:587 +# ─── NEXT.JS DASHBOARD ────────────────────────────────────────────────── +NEXT_PUBLIC_API_URL=http://localhost:8080 # CHANGE THIS - API URL as seen by browser (e.g., https://api.example.com) +NEXTAUTH_SECRET=changeme-generate-a-random-string # CHANGE THIS - min 32 chars, use a random secure string +NEXTAUTH_URL=http://localhost:3000 # CHANGE THIS - dashboard URL (e.g., https://mail.example.com) +NODE_ENV=production # production | development -# DKIM -DKIM_SELECTOR=mailit -DKIM_MASTER_KEY=changeme-32-byte-hex-key +# ─── WEBHOOK DELIVERY ─────────────────────────────────────────────────── +WEBHOOK_SIGNING_ALGORITHM=hmac-sha256 # Algorithm for signing webhook payloads +WEBHOOK_TIMEOUT=30s # HTTP timeout for webhook delivery +WEBHOOK_MAX_RETRIES=5 # Maximum number of delivery attempts -# Server -HTTP_ADDR=:8080 -LOG_LEVEL=info +# ─── SUPPRESSION LISTS ────────────────────────────────────────────────── +SUPPRESSION_AUTO_ADD_HARD_BOUNCES=true # Automatically suppress hard-bounced addresses +SUPPRESSION_AUTO_ADD_COMPLAINTS=true # Automatically suppress spam complaint addresses -# Next.js Dashboard -NEXT_PUBLIC_API_URL=http://localhost:8080 -NEXTAUTH_SECRET=changeme-generate-a-random-string -NEXTAUTH_URL=http://localhost:3000 +# ─── OPTIONAL: DNS CONFIGURATION ──────────────────────────────────────── +# Uncomment to use a custom DNS resolver (e.g., Cloudflare 1.1.1.1) +# DNS_RESOLVER=1.1.1.1:53 +# DNS_TIMEOUT=10s +# DNS_CACHE_TTL=5m diff --git a/README.md b/README.md index d6aac18..2472461 100644 --- a/README.md +++ b/README.md @@ -171,29 +171,161 @@ Pages: Overview, Emails, Domains, API Keys, Audiences, Templates, Broadcasts, We ## Deployment -### Docker +### Prerequisites + +- Docker & Docker Compose (4.0+) +- A domain name you control +- A server with public IP address +- Ports 25, 587, 3000, and 8080 accessible (adjust as needed) + +### Docker Compose (Quick Start) + +1. Clone the repository and enter the directory: + ```bash + git clone https://github.com/mailit-dev/mailit.git + cd mailit + ``` + +2. Create `.env` from the example and configure your settings: + ```bash + cp .env.example .env + # Edit .env with your password, secrets, and domain + ``` + +3. Run the setup script: + ```bash + chmod +x scripts/setup.sh scripts/generate-dkim.sh + ./scripts/setup.sh + ``` + + This will: + - Validate your environment variables + - Generate DKIM keys + - Start PostgreSQL, Redis, API, and Dashboard containers + - Run database migrations + - Create an admin account + +4. Configure DNS records (output by setup script): + ``` + TXT @ v=spf1 a mx ip4:YOUR_IP ~all + TXT mailit._domainkey v=DKIM1; k=rsa; p=YOUR_PUBLIC_KEY + A mail YOUR_IP + MX @ mail.yourdomain.com (priority 10) + ``` + +5. Open the dashboard at `http://localhost:3000` and verify your domain + +### Manual Docker Compose + +If you prefer to run commands manually: ```bash -# Build images -make docker-build +# Create environment file +cp .env.example .env +# Edit .env with your settings -# Run with Docker Compose +# Generate DKIM keys +./scripts/generate-dkim.sh + +# Start all services (PostgreSQL, Redis, API, Dashboard) docker compose up -d + +# Wait for services to be healthy +sleep 10 + +# Run database migrations +docker compose exec -T mailit-api mailit migrate --up + +# Create admin account +docker compose exec -T mailit-api mailit setup + +# View logs +docker compose logs -f mailit-api ``` -The API image exposes ports 8080 (HTTP), 25 (SMTP inbound), and 587 (submission). +### Manage Services -### Kubernetes +```bash +# View logs +docker compose logs -f -A Helm chart is included: +# Stop all services +docker compose down + +# Stop and remove all data (WARNING: destructive) +docker compose down -v + +# Restart API server +docker compose restart mailit-api + +# Update to latest code and rebuild +git pull +docker compose up -d --build +``` + +### Environment Variables Reference + +See `.env.example` for comprehensive configuration documentation. Key production variables: + +- `POSTGRES_PASSWORD` — Database password (change from default) +- `JWT_SECRET` — API authentication secret (min 32 chars) +- `NEXTAUTH_SECRET` — Dashboard auth secret (min 32 chars) +- `MAILIT_DOMAIN` — Your mail server FQDN (e.g., mail.yourdomain.com) +- `DKIM_MASTER_KEY` — 32-byte hex key for DKIM encryption +- `NEXT_PUBLIC_API_URL` — API URL as seen by browser (for reverse proxy) +- `NEXTAUTH_URL` — Dashboard URL (for reverse proxy) + +### Production Recommendations + +1. **Reverse Proxy** — Use Nginx or Caddy for TLS termination: + - Proxy `mail.yourdomain.com:443` → `localhost:8080` (API) + - Proxy `yourdomain.com:443` → `localhost:3000` (Dashboard) + - Enable HTTPS with Let's Encrypt + +2. **Database Backups** — Regular backups of PostgreSQL: + ```bash + docker compose exec -T postgres pg_dump -U mailit mailit > backup.sql + docker compose exec -T postgres pg_restore -U mailit mailit < backup.sql + ``` + +3. **Monitoring** — Monitor container health: + ```bash + docker compose ps # Check service status + docker compose logs # View logs + ``` + +4. **Resource Limits** — Set memory/CPU limits in docker-compose.yml + ```yaml + mailit-api: + deploy: + resources: + limits: + cpus: '2' + memory: 1G + ``` + +5. **Data Persistence** — Ensure volumes are on reliable storage: + - `mailit-data` — Attachment storage + - `postgres-data` — Database files + - `redis-data` — Cache and job queue + +### Kubernetes (Helm) + +A Helm chart is included for Kubernetes deployments: ```bash helm install mailit deploy/helm/mailit \ - --set database.password=your-password \ - --set auth.jwtSecret=your-secret \ - --set dkim.masterEncryptionKey=your-32-byte-hex-key + --set database.password=$(openssl rand -base64 32) \ + --set auth.jwtSecret=$(openssl rand -base64 32) \ + --set auth.nextauthSecret=$(openssl rand -base64 32) \ + --set dkim.masterEncryptionKey=$(openssl rand -hex 16) \ + --set domain=mail.yourdomain.com \ + --set ingress.enabled=true \ + --set ingress.host=mail.yourdomain.com ``` +See `deploy/helm/mailit/values.yaml` for all available Helm options. + ## DNS Setup For sending emails from your domain, configure these DNS records: diff --git a/docker-compose.yml b/docker-compose.yml index 4477bc4..b149152 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,82 +1,176 @@ +# MailIt Docker Compose Configuration +# Production-ready setup with API, dashboard, PostgreSQL, and Redis +# Required env vars: POSTGRES_PASSWORD, JWT_SECRET, NEXTAUTH_SECRET, MAILIT_DOMAIN, DKIM_MASTER_KEY +# See .env.example for all available configuration options + services: + # ─── MailIt API Server ────────────────────────────────────────────── mailit-api: build: context: . dockerfile: deploy/docker/Dockerfile + args: + VERSION: ${VERSION:-dev} + container_name: mailit-api + restart: unless-stopped ports: - - "${HTTP_PORT:-8080}:8080" - - "${SMTP_PORT:-25}:25" - - "${SMTP_SUBMISSION_PORT:-587}:587" + - "${HTTP_PORT:-8080}:8080" # API server + - "${SMTP_PORT:-25}:25" # Inbound SMTP (bounces/replies) + - "${SMTP_SUBMISSION_PORT:-587}:587" # SMTP submission port environment: + # Database - MAILIT_DATABASE_HOST=postgres - MAILIT_DATABASE_PORT=5432 - MAILIT_DATABASE_USER=${POSTGRES_USER:-mailit} - MAILIT_DATABASE_PASSWORD=${POSTGRES_PASSWORD} - MAILIT_DATABASE_DBNAME=${POSTGRES_DB:-mailit} + - MAILIT_DATABASE_SSLMODE=${DATABASE_SSLMODE:-disable} + + # Redis - MAILIT_REDIS_ADDR=redis:6379 + - MAILIT_REDIS_PASSWORD=${REDIS_PASSWORD} + - MAILIT_REDIS_DB=0 + + # Authentication - MAILIT_AUTH_JWT_SECRET=${JWT_SECRET} + - MAILIT_AUTH_BCRYPT_COST=${BCRYPT_COST:-12} + + # SMTP Configuration - MAILIT_SMTP_OUTBOUND_HOSTNAME=${MAILIT_DOMAIN} - MAILIT_SMTP_INBOUND_DOMAIN=${MAILIT_DOMAIN} + - MAILIT_SMTP_INBOUND_ENABLED=true + + # DKIM + - MAILIT_DKIM_SELECTOR=${DKIM_SELECTOR:-mailit} - MAILIT_DKIM_MASTER_ENCRYPTION_KEY=${DKIM_MASTER_KEY} + - MAILIT_DKIM_KEY_BITS=${DKIM_KEY_BITS:-2048} + + # Server + - MAILIT_SERVER_HTTP_ADDR=:8080 - MAILIT_SERVER_CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000} + + # Logging + - MAILIT_LOGGING_LEVEL=${LOG_LEVEL:-info} + - MAILIT_LOGGING_FORMAT=${LOG_FORMAT:-json} + + # Rate Limiting + - MAILIT_RATE_LIMIT_ENABLED=${RATE_LIMIT_ENABLED:-true} + - MAILIT_RATE_LIMIT_DEFAULT_RPS=${RATE_LIMIT_DEFAULT_RPS:-10} + + # Storage + - MAILIT_STORAGE_TYPE=${STORAGE_TYPE:-local} + - MAILIT_STORAGE_LOCAL_PATH=${STORAGE_LOCAL_PATH:-/data/attachments} + + # Webhooks + - MAILIT_WEBHOOKS_SIGNING_ALGORITHM=${WEBHOOK_SIGNING_ALGORITHM:-hmac-sha256} + depends_on: postgres: condition: service_healthy redis: condition: service_healthy + healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] interval: 10s timeout: 5s retries: 5 start_period: 10s - restart: unless-stopped + volumes: - mailit-data:/data + networks: + - mailit-network + + # ─── MailIt Dashboard (Next.js) ───────────────────────────────────── mailit-web: build: context: ./web dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} + container_name: mailit-web + restart: unless-stopped ports: - "${WEB_PORT:-3000}:3000" environment: - - NEXT_PUBLIC_API_URL=${API_URL:-http://localhost:8080} + - NODE_ENV=${NODE_ENV:-production} + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8080} - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} + - NEXT_TELEMETRY_DISABLED=1 + depends_on: mailit-api: condition: service_healthy - restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + networks: + - mailit-network + + # ─── PostgreSQL Database ──────────────────────────────────────────── postgres: image: postgres:16-alpine + container_name: mailit-postgres + restart: unless-stopped environment: - POSTGRES_USER=${POSTGRES_USER:-mailit} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB:-mailit} + volumes: - postgres-data:/var/lib/postgresql/data + healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mailit}"] interval: 10s timeout: 5s retries: 5 - restart: unless-stopped + networks: + - mailit-network + + # ─── Redis Cache & Job Queue ─────────────────────────────────────── redis: image: redis:7-alpine - command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD} + container_name: mailit-redis + restart: unless-stopped + command: > + redis-server + --appendonly yes + ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD} + --maxmemory 512mb + --maxmemory-policy allkeys-lru + volumes: - redis-data:/data + healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "${REDIS_PASSWORD:+--auth $REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 5 - restart: unless-stopped + networks: + - mailit-network + +# Volumes volumes: mailit-data: + driver: local postgres-data: + driver: local redis-data: + driver: local + +# Networks +networks: + mailit-network: + driver: bridge diff --git a/scripts/generate-dkim.sh b/scripts/generate-dkim.sh index 44bdbac..b7964b7 100755 --- a/scripts/generate-dkim.sh +++ b/scripts/generate-dkim.sh @@ -5,45 +5,75 @@ set -euo pipefail SELECTOR="${DKIM_SELECTOR:-mailit}" DOMAIN="${MAILIT_DOMAIN:-example.com}" KEY_DIR="./data/dkim" +KEY_BITS="${DKIM_KEY_BITS:-2048}" + +# Validate inputs +if [ "$DOMAIN" = "example.com" ]; then + echo "Warning: MAILIT_DOMAIN is still set to example.com" + echo "Please set MAILIT_DOMAIN to your actual domain before running this script." + exit 1 +fi + +if ! command -v openssl &> /dev/null; then + echo "Error: openssl is required but not installed" + exit 1 +fi mkdir -p "$KEY_DIR" -echo "Generating 2048-bit RSA DKIM key pair..." -openssl genrsa -out "$KEY_DIR/dkim_private.pem" 2048 2>/dev/null +echo "Generating ${KEY_BITS}-bit RSA DKIM key pair for domain: $DOMAIN" +openssl genrsa -out "$KEY_DIR/dkim_private.pem" "$KEY_BITS" 2>/dev/null openssl rsa -in "$KEY_DIR/dkim_private.pem" -pubout -outform DER 2>/dev/null | openssl base64 -A > "$KEY_DIR/dkim_public.txt" +# Verify files were created +if [ ! -f "$KEY_DIR/dkim_private.pem" ] || [ ! -f "$KEY_DIR/dkim_public.txt" ]; then + echo "Error: Failed to generate DKIM keys" + exit 1 +fi + +# Set restrictive permissions on private key +chmod 600 "$KEY_DIR/dkim_private.pem" + PUBLIC_KEY=$(cat "$KEY_DIR/dkim_public.txt") echo "" -echo "=== DKIM Key Generated ===" +echo "════════════════════════════════════════════════════════════════" +echo "✓ DKIM Keys Generated Successfully" +echo "════════════════════════════════════════════════════════════════" echo "" echo "Private key saved to: $KEY_DIR/dkim_private.pem" +echo "Public key saved to: $KEY_DIR/dkim_public.txt" echo "" -echo "=== Add these DNS records ===" +echo "════════════════════════════════════════════════════════════════" +echo "ADD THESE DNS RECORDS TO YOUR DOMAIN REGISTRAR" +echo "════════════════════════════════════════════════════════════════" echo "" -echo "1. SPF Record:" -echo " Type: TXT" -echo " Name: $DOMAIN" +echo "1. SPF Record (recommended, existing SPF may vary):" +echo " Type: TXT" +echo " Name: @ (or your domain)" echo " Value: v=spf1 a mx ip4:YOUR_SERVER_IP ~all" echo "" -echo "2. DKIM Record:" -echo " Type: TXT" -echo " Name: ${SELECTOR}._domainkey.${DOMAIN}" +echo "2. DKIM Record (IMPORTANT - required for email delivery):" +echo " Type: TXT" +echo " Name: ${SELECTOR}._domainkey" echo " Value: v=DKIM1; k=rsa; p=${PUBLIC_KEY}" echo "" -echo "3. DMARC Record:" -echo " Type: TXT" -echo " Name: _dmarc.${DOMAIN}" +echo "3. DMARC Record (recommended):" +echo " Type: TXT" +echo " Name: _dmarc" echo " Value: v=DMARC1; p=none; rua=mailto:dmarc@${DOMAIN}" echo "" -echo "4. MX Record:" -echo " Type: MX" -echo " Name: ${DOMAIN}" -echo " Value: ${DOMAIN}" +echo "4. MX Record (if not already set):" +echo " Type: MX" +echo " Name: @ (or your domain)" +echo " Value: ${DOMAIN}" echo " Priority: 10" echo "" -echo "5. Return-Path:" -echo " Type: CNAME" -echo " Name: bounce.${DOMAIN}" -echo " Value: ${DOMAIN}" +echo "5. A Record (if not already set):" +echo " Type: A" +echo " Name: mail (creates mail.${DOMAIN})" +echo " Value: YOUR_SERVER_IP" +echo "" +echo "Note: DNS changes may take up to 24 hours to propagate." +echo "════════════════════════════════════════════════════════════════" echo "" diff --git a/scripts/setup.sh b/scripts/setup.sh index d0b10fc..1598145 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -5,14 +5,37 @@ echo "=== MailIt First-Run Setup ===" echo "" # Check dependencies -command -v docker >/dev/null 2>&1 || { echo "Error: docker is required"; exit 1; } -command -v docker compose >/dev/null 2>&1 || command -v docker-compose >/dev/null 2>&1 || { echo "Error: docker compose is required"; exit 1; } +if ! command -v docker &> /dev/null; then + echo "Error: docker is required but not installed" + exit 1 +fi + +# Check for docker compose (supports both 'docker compose' and 'docker-compose') +if docker compose version &> /dev/null 2>&1; then + DOCKER_COMPOSE="docker compose" +elif command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE="docker-compose" +else + echo "Error: 'docker compose' is required but not installed" + echo "Install Docker Desktop 4.0+ or docker-compose plugin" + exit 1 +fi # Check for .env if [ ! -f .env ]; then + echo "Error: .env file not found" echo "Creating .env from .env.example..." cp .env.example .env - echo "Please edit .env with your settings, then run this script again." + echo "" + echo "IMPORTANT: Please edit .env with your settings:" + echo " - Set POSTGRES_PASSWORD to a strong password" + echo " - Set JWT_SECRET to a random 32+ char string" + echo " - Set NEXTAUTH_SECRET to a random 32+ char string" + echo " - Set DKIM_MASTER_KEY to 32 bytes of hex (openssl rand -hex 16)" + echo " - Change MAILIT_DOMAIN to your domain" + echo " - Update NEXT_PUBLIC_API_URL and NEXTAUTH_URL" + echo "" + echo "Then run this script again." exit 0 fi @@ -22,39 +45,79 @@ source .env set +a # Validate required vars -: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}" -: "${JWT_SECRET:?JWT_SECRET must be set in .env}" -: "${MAILIT_DOMAIN:?MAILIT_DOMAIN must be set in .env}" +echo "Validating environment variables..." +MISSING_VARS=() +[ -z "${POSTGRES_PASSWORD}" ] && MISSING_VARS+=("POSTGRES_PASSWORD") +[ -z "${JWT_SECRET}" ] && MISSING_VARS+=("JWT_SECRET") +[ -z "${NEXTAUTH_SECRET}" ] && MISSING_VARS+=("NEXTAUTH_SECRET") +[ -z "${MAILIT_DOMAIN}" ] && MISSING_VARS+=("MAILIT_DOMAIN") +[ -z "${DKIM_MASTER_KEY}" ] && MISSING_VARS+=("DKIM_MASTER_KEY") + +if [ ${#MISSING_VARS[@]} -gt 0 ]; then + echo "Error: The following required variables are not set in .env:" + printf ' - %s\n' "${MISSING_VARS[@]}" + exit 1 +fi + +# Validate secret lengths +if [ ${#JWT_SECRET} -lt 32 ]; then + echo "Warning: JWT_SECRET should be at least 32 characters (currently ${#JWT_SECRET})" +fi + +if [ ${#NEXTAUTH_SECRET} -lt 32 ]; then + echo "Warning: NEXTAUTH_SECRET should be at least 32 characters (currently ${#NEXTAUTH_SECRET})" +fi # Generate DKIM keys +echo "" echo "Generating DKIM keys..." ./scripts/generate-dkim.sh # Start services echo "" -echo "Starting services..." -docker compose up -d +echo "Starting services with: $DOCKER_COMPOSE" +$DOCKER_COMPOSE up -d echo "" echo "Waiting for services to be healthy..." -sleep 10 +for i in {1..30}; do + if $DOCKER_COMPOSE exec -T postgres pg_isready -U "${POSTGRES_USER}" &> /dev/null; then + echo "✓ Database is healthy" + break + fi + if [ $i -eq 30 ]; then + echo "Error: Database did not become healthy in time" + $DOCKER_COMPOSE logs postgres + exit 1 + fi + echo " Waiting for database... ($i/30)" + sleep 1 +done + +sleep 2 # Run migrations +echo "" echo "Running database migrations..." -docker compose exec mailit-api mailit migrate --up --config /etc/mailit/mailit.yaml +$DOCKER_COMPOSE exec -T mailit-api mailit migrate --up --config /etc/mailit/mailit.yaml # Create admin account echo "" echo "Creating admin account..." -docker compose exec -T mailit-api mailit setup --config /etc/mailit/mailit.yaml +$DOCKER_COMPOSE exec -T mailit-api mailit setup --config /etc/mailit/mailit.yaml echo "" -echo "=== Setup Complete ===" +echo "═══════════════════════════════════════════════════════════════" +echo "✓ Setup Complete!" +echo "═══════════════════════════════════════════════════════════════" echo "" -echo "Dashboard: http://localhost:${WEB_PORT:-3000}" -echo "API: http://localhost:${HTTP_PORT:-8080}" +echo "Dashboard: ${NEXTAUTH_URL:-http://localhost:3000}" +echo "API: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}" echo "" echo "Next steps:" -echo "1. Add the DNS records printed above" -echo "2. Open the dashboard and verify your domain" -echo "3. Create an API key and start sending!" +echo "1. Review the DNS records printed above" +echo "2. Add the DNS records to your domain registrar" +echo "3. Open the dashboard and verify your domain setup" +echo "4. Create an API key to start sending emails" +echo "" +echo "View logs with: $DOCKER_COMPOSE logs -f" From 2a3b64db438397c23bbd8ecab8c07104b4c9bb3c Mon Sep 17 00:00:00 2001 From: Victor Ivanov Date: Sat, 28 Feb 2026 00:24:34 -0500 Subject: [PATCH 3/3] fix: Resolve 4 remaining dogfood issues - Broadcast audience_name: LEFT JOIN audiences in list query, populate AudienceName in response DTO - Domain detail: remove redundant {"data": resp} wrapper from GET /domains/{id}, align frontend to read bare response - Metrics: relax hasData condition to show charts when data points exist even if totals.sent is 0 - Template versions: add version_count subquery to list query, surface Versions field in TemplateResponse DTO - Gitignore: add .agent/, .agents/, skills-lock.json, dogfood-output/ --- .gitignore | 4 ++++ internal/dto/template.go | 1 + internal/handler/domain.go | 2 +- internal/model/broadcast.go | 1 + internal/model/template.go | 5 +++-- internal/repository/postgres/broadcast.go | 16 +++++++++++----- internal/repository/postgres/template.go | 12 +++++++----- internal/service/broadcast.go | 1 + internal/service/template.go | 1 + .../app/(dashboard)/domains/[domainId]/page.tsx | 2 +- web/src/app/(dashboard)/metrics/page.tsx | 2 +- 11 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 15c09d8..e6340de 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,10 @@ coverage.html .claude/ CLAUDE.md context-audit-report.txt +.agent/ +.agents/ +skills-lock.json +dogfood-output/ # OS .DS_Store diff --git a/internal/dto/template.go b/internal/dto/template.go index b2122fc..b6dc149 100644 --- a/internal/dto/template.go +++ b/internal/dto/template.go @@ -20,6 +20,7 @@ type TemplateResponse struct { ID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` + Versions int `json:"versions"` CreatedAt string `json:"created_at"` } diff --git a/internal/handler/domain.go b/internal/handler/domain.go index 244a676..c8fc958 100644 --- a/internal/handler/domain.go +++ b/internal/handler/domain.go @@ -82,7 +82,7 @@ func (h *DomainHandler) Get(w http.ResponseWriter, r *http.Request) { pkg.HandleError(w, err) return } - pkg.JSON(w, http.StatusOK, map[string]interface{}{"data": resp}) + pkg.JSON(w, http.StatusOK, resp) } // Update handles PATCH /domains/{domainId}. diff --git a/internal/model/broadcast.go b/internal/model/broadcast.go index 2d8620f..81c2880 100644 --- a/internal/model/broadcast.go +++ b/internal/model/broadcast.go @@ -24,6 +24,7 @@ type Broadcast struct { SentCount int `json:"sent_count" db:"sent_count"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + AudienceName *string `json:"-" db:"-"` // populated via JOIN, not a DB column } const ( diff --git a/internal/model/template.go b/internal/model/template.go index 9bc4132..b85f4d0 100644 --- a/internal/model/template.go +++ b/internal/model/template.go @@ -11,8 +11,9 @@ type Template struct { TeamID uuid.UUID `json:"team_id" db:"team_id"` Name string `json:"name" db:"name"` Description *string `json:"description,omitempty" db:"description"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + VersionCount int `json:"-" db:"-"` // populated via subquery, not a DB column } type TemplateVersion struct { diff --git a/internal/repository/postgres/broadcast.go b/internal/repository/postgres/broadcast.go index f392d38..b455b2d 100644 --- a/internal/repository/postgres/broadcast.go +++ b/internal/repository/postgres/broadcast.go @@ -88,10 +88,16 @@ func (r *broadcastRepository) List(ctx context.Context, teamID uuid.UUID, limit, return nil, 0, fmt.Errorf("count broadcasts: %w", err) } - query := fmt.Sprintf(` - SELECT %s FROM broadcasts WHERE team_id = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, broadcastColumns) + query := ` + SELECT b.id, b.team_id, b.name, b.audience_id, b.segment_id, b.template_id, + b.from_address, b.subject, b.html_body, b.text_body, b.status, + b.scheduled_at, b.sent_at, b.total_recipients, b.sent_count, + b.created_at, b.updated_at, a.name AS audience_name + FROM broadcasts b + LEFT JOIN audiences a ON a.id = b.audience_id + WHERE b.team_id = $1 + ORDER BY b.created_at DESC + LIMIT $2 OFFSET $3` rows, err := r.pool.Query(ctx, query, teamID, limit, offset) if err != nil { @@ -105,7 +111,7 @@ func (r *broadcastRepository) List(ctx context.Context, teamID uuid.UUID, limit, &b.ID, &b.TeamID, &b.Name, &b.AudienceID, &b.SegmentID, &b.TemplateID, &b.FromAddress, &b.Subject, &b.HTMLBody, &b.TextBody, &b.Status, &b.ScheduledAt, &b.SentAt, &b.TotalRecipients, &b.SentCount, - &b.CreatedAt, &b.UpdatedAt, + &b.CreatedAt, &b.UpdatedAt, &b.AudienceName, ) return b, err }) diff --git a/internal/repository/postgres/template.go b/internal/repository/postgres/template.go index 523b17b..a314915 100644 --- a/internal/repository/postgres/template.go +++ b/internal/repository/postgres/template.go @@ -74,10 +74,12 @@ func (r *templateRepository) List(ctx context.Context, teamID uuid.UUID, limit, return nil, 0, fmt.Errorf("count templates: %w", err) } - query := fmt.Sprintf(` - SELECT %s FROM templates WHERE team_id = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, templateColumns) + query := ` + SELECT t.id, t.team_id, t.name, t.description, t.created_at, t.updated_at, + (SELECT COUNT(*) FROM template_versions tv WHERE tv.template_id = t.id) AS version_count + FROM templates t WHERE t.team_id = $1 + ORDER BY t.created_at DESC + LIMIT $2 OFFSET $3` rows, err := r.pool.Query(ctx, query, teamID, limit, offset) if err != nil { @@ -87,7 +89,7 @@ func (r *templateRepository) List(ctx context.Context, teamID uuid.UUID, limit, templates, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.Template, error) { var t model.Template - err := row.Scan(&t.ID, &t.TeamID, &t.Name, &t.Description, &t.CreatedAt, &t.UpdatedAt) + err := row.Scan(&t.ID, &t.TeamID, &t.Name, &t.Description, &t.CreatedAt, &t.UpdatedAt, &t.VersionCount) return t, err }) if err != nil { diff --git a/internal/service/broadcast.go b/internal/service/broadcast.go index dd8a5e9..48f5d09 100644 --- a/internal/service/broadcast.go +++ b/internal/service/broadcast.go @@ -282,6 +282,7 @@ func broadcastToResponse(b *model.Broadcast) *dto.BroadcastResponse { aid := b.AudienceID.String() resp.AudienceID = &aid } + resp.AudienceName = b.AudienceName return resp } diff --git a/internal/service/template.go b/internal/service/template.go index 4747995..d97fb23 100644 --- a/internal/service/template.go +++ b/internal/service/template.go @@ -256,6 +256,7 @@ func templateToResponse(t *model.Template) *dto.TemplateResponse { ID: t.ID.String(), Name: t.Name, Description: t.Description, + Versions: t.VersionCount, CreatedAt: t.CreatedAt.Format(time.RFC3339), } } diff --git a/web/src/app/(dashboard)/domains/[domainId]/page.tsx b/web/src/app/(dashboard)/domains/[domainId]/page.tsx index b07ceee..614df44 100644 --- a/web/src/app/(dashboard)/domains/[domainId]/page.tsx +++ b/web/src/app/(dashboard)/domains/[domainId]/page.tsx @@ -46,7 +46,7 @@ export default function DomainDetailPage() { api.get(`/domains/${params.domainId}`).then((res) => res.data), }); - const domain: DomainDetail | undefined = data?.data; + const domain: DomainDetail | undefined = data; const verifyMutation = useMutation({ mutationFn: () => diff --git a/web/src/app/(dashboard)/metrics/page.tsx b/web/src/app/(dashboard)/metrics/page.tsx index 2228b26..c002c59 100644 --- a/web/src/app/(dashboard)/metrics/page.tsx +++ b/web/src/app/(dashboard)/metrics/page.tsx @@ -101,7 +101,7 @@ export default function MetricsPage() { const totals = metricsData?.totals; const chartData = metricsData?.data ?? []; - const hasData = chartData.length > 0 && (totals?.sent ?? 0) > 0; + const hasData = chartData.length > 0; const periodLabel = period === "24h"