diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a983..cf2cc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components. - **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks. - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection --filter … --order …`. +- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test. +- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index e970fe4..f402f2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -331,6 +331,28 @@ Project-scoped public ingest for external systems. the received body as payload (`triggered_by: "hook"`). - 256 KiB body cap; `last_received_at` tracked; managed from Integrations. +## HA scheduler (unreleased) + +Multi-replica safety for the job scheduler and alert evaluator. + +- PostgreSQL advisory locks (session-scoped, separate keys per component) + on a dedicated pinned pool connection; only the leader scans for due work. +- Manual runs and hook-triggered runs work on any replica — the lock gates + only the scheduled scan, so leadership loss never blocks on-demand work. +- Released on shutdown; failover verified with a live two-replica test — + the survivor acquired leadership and fired the due job. + +## Email surface (unreleased) + +Reusable templates + an operational send log. + +- `core.email_log` (migration 00014) — every send recorded: template, + recipient, subject, `sent`/`failed` status, error text. +- `Mailer` stays transport-only (Resend when configured, SMTP otherwise); + templates render subject + plaintext body. First template: `invitation`. +- `GET /projects/:id/emails` lists recent sends; Members page shows an + "Email log" card under pending invitations. + ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. diff --git a/apps/backend/db/migrations/00014_email_log.sql b/apps/backend/db/migrations/00014_email_log.sql new file mode 100644 index 0000000..fcf22ad --- /dev/null +++ b/apps/backend/db/migrations/00014_email_log.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE core.email_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE, + template TEXT NOT NULL, + to_email TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('sent','failed')), + error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS core.email_log; diff --git a/apps/backend/db/queries/email_log.sql b/apps/backend/db/queries/email_log.sql new file mode 100644 index 0000000..d1c2d50 --- /dev/null +++ b/apps/backend/db/queries/email_log.sql @@ -0,0 +1,10 @@ +-- name: InsertEmailLog :one +INSERT INTO core.email_log (project_id, template, to_email, subject, status, error) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: ListEmailLog :many +SELECT * FROM core.email_log +WHERE project_id = $1 +ORDER BY created_at DESC +LIMIT $2; diff --git a/apps/backend/internal/database/db/email_log.sql.go b/apps/backend/internal/database/db/email_log.sql.go new file mode 100644 index 0000000..6ec05dd --- /dev/null +++ b/apps/backend/internal/database/db/email_log.sql.go @@ -0,0 +1,91 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: email_log.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertEmailLog = `-- name: InsertEmailLog :one +INSERT INTO core.email_log (project_id, template, to_email, subject, status, error) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, project_id, template, to_email, subject, status, error, created_at +` + +type InsertEmailLogParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error"` +} + +func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) { + row := q.db.QueryRow(ctx, insertEmailLog, + arg.ProjectID, + arg.Template, + arg.ToEmail, + arg.Subject, + arg.Status, + arg.Error, + ) + var i CoreEmailLog + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Template, + &i.ToEmail, + &i.Subject, + &i.Status, + &i.Error, + &i.CreatedAt, + ) + return i, err +} + +const listEmailLog = `-- name: ListEmailLog :many +SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log +WHERE project_id = $1 +ORDER BY created_at DESC +LIMIT $2 +` + +type ListEmailLogParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) { + rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreEmailLog + for rows.Next() { + var i CoreEmailLog + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Template, + &i.ToEmail, + &i.Subject, + &i.Status, + &i.Error, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index c1d6e94..23f06dd 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -243,6 +243,17 @@ type CoreDocument struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type CoreEmailLog struct { + ID uuid.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type CoreEvent struct { ID int64 `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 53e32dc..e99b449 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -84,6 +84,7 @@ type Querier interface { GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) + InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) @@ -100,6 +101,7 @@ type Querier interface { ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) + ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index a3d0002..c6e486f 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -83,6 +83,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks) api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook) api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook) + api.GET("/projects/:projectID/emails", h.listEmailLog) api.GET("/projects/:projectID/events", h.listEvents) api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/components", h.listComponents) diff --git a/apps/backend/internal/handlers/inbound.go b/apps/backend/internal/handlers/inbound.go index acc9a87..bafa9d5 100644 --- a/apps/backend/internal/handlers/inbound.go +++ b/apps/backend/internal/handlers/inbound.go @@ -4,6 +4,7 @@ import ( "errors" "io" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -80,6 +81,25 @@ func (h *HTTPHandler) deleteInboundHook(c *gin.Context) { c.Status(http.StatusNoContent) } +func (h *HTTPHandler) listEmailLog(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var limit int64 + if raw := c.Query("limit"); raw != "" { + if n, err := strconv.ParseInt(raw, 10, 64); err == nil { + limit = n + } + } + result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + // receiveInboundHook is the public ingest endpoint — the URL token is the // credential, so no actor is required. func (h *HTTPHandler) receiveInboundHook(c *gin.Context) { diff --git a/apps/backend/internal/services/alerting.go b/apps/backend/internal/services/alerting.go index 587a3d4..01a2327 100644 --- a/apps/backend/internal/services/alerting.go +++ b/apps/backend/internal/services/alerting.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" db "github.com/tdvorak/primora/apps/backend/internal/database/db" "github.com/tdvorak/primora/apps/backend/internal/models" @@ -239,10 +240,11 @@ type alertEvaluator struct { quit chan struct{} wg sync.WaitGroup started sync.Once + leader *leaderState } -func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger) *alertEvaluator { - return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{})} +func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator { + return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)} } func (e *alertEvaluator) Start(ctx context.Context) { @@ -255,8 +257,11 @@ func (e *alertEvaluator) Start(ctx context.Context) { for { select { case <-t.C: - e.scan(ctx) + if e.leader.hold(ctx) { + e.scan(ctx) + } case <-e.quit: + e.leader.stop(context.Background()) return case <-ctx.Done(): return diff --git a/apps/backend/internal/services/jobs.go b/apps/backend/internal/services/jobs.go index 8421fd9..0a55ae8 100644 --- a/apps/backend/internal/services/jobs.go +++ b/apps/backend/internal/services/jobs.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/robfig/cron/v3" db "github.com/tdvorak/primora/apps/backend/internal/database/db" @@ -406,6 +407,7 @@ type JobScheduler struct { quit chan struct{} wg sync.WaitGroup started sync.Once + leader *leaderState // onEvent fans a finished run out to webhooks and the realtime stream. onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) } @@ -415,7 +417,7 @@ type jobExec struct { run db.CoreScheduledJobRun } -func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler { +func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler { if logger == nil { logger = slog.Default() } @@ -428,6 +430,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, queue: make(chan jobExec, 256), quit: make(chan struct{}), onEvent: onEvent, + leader: newLeaderState(pool, leaderKeyScheduler), } } @@ -452,8 +455,13 @@ func (j *JobScheduler) run(ctx context.Context) { case exec := <-j.queue: j.execute(exec.job, exec.run) case <-ticker.C: - j.scan() + // Only the leader fires scheduled runs — manual/hook enqueues are + // already scoped to whichever replica received the request. + if j.leader.hold(ctx) { + j.scan() + } case <-j.quit: + j.leader.stop(context.Background()) return case <-ctx.Done(): return diff --git a/apps/backend/internal/services/leader.go b/apps/backend/internal/services/leader.go new file mode 100644 index 0000000..1e268b9 --- /dev/null +++ b/apps/backend/internal/services/leader.go @@ -0,0 +1,98 @@ +package services + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Leader election via Postgres advisory locks. The lock is session-scoped, so +// the lease pins its pool connection for its entire lifetime — a handoff back +// to the pool would silently drop it. A single-replica deployment simply +// always holds the lock; there is no feature flag. +// +// Distinct keys per component so the job scheduler and the alert evaluator +// elect independently. +const ( + leaderKeyScheduler int64 = 728201 + leaderKeyAlerts int64 = 728202 +) + +type leaderLease struct { + conn *pgxpool.Conn + key int64 +} + +// acquireLeadership returns a lease when this process wins the lock, nil when +// another replica holds it, or an error when the check itself failed. +func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) { + conn, err := pool.Acquire(ctx) + if err != nil { + return nil, err + } + var got bool + if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil { + conn.Release() + return nil, err + } + if !got { + conn.Release() + return nil, nil + } + return &leaderLease{conn: conn, key: key}, nil +} + +// alive pings the pinned connection — a dead conn means Postgres dropped the +// session and released the lock, so leadership is lost even though the lease +// object still exists. +func (l *leaderLease) alive(ctx context.Context) bool { + var one int + return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil +} + +func (l *leaderLease) release(ctx context.Context) { + _, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key) + l.conn.Release() +} + +// leaderState tracks a lease inside a ticker loop — acquire when absent, +// verify when held, drop and re-elect when the connection dies. +type leaderState struct { + pool *pgxpool.Pool + key int64 + lease *leaderLease +} + +func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState { + return &leaderState{pool: pool, key: key} +} + +// hold reports whether this process currently leads. A nil pool means no +// election configured (tests, embedded use) — always lead. +func (l *leaderState) hold(ctx context.Context) bool { + if l == nil || l.pool == nil { + return true + } + if l.lease != nil { + if l.lease.alive(ctx) { + return true + } + l.lease.release(ctx) + l.lease = nil + } + lease, err := acquireLeadership(ctx, l.pool, l.key) + if err != nil { + return false + } + l.lease = lease + return lease != nil +} + +func (l *leaderState) stop(ctx context.Context) { + if l != nil && l.lease != nil { + l.lease.release(ctx) + l.lease = nil + } +} + + diff --git a/apps/backend/internal/services/mailer.go b/apps/backend/internal/services/mailer.go index 49d940b..7652258 100644 --- a/apps/backend/internal/services/mailer.go +++ b/apps/backend/internal/services/mailer.go @@ -11,6 +11,9 @@ import ( "github.com/tdvorak/primora/apps/backend/internal/config" ) +// Mailer is transport-only: Resend when configured, plain SMTP otherwise. +// Templates render (subject, body) from data; sends are logged to +// core.email_log by the service layer so operators can see what went out. type Mailer struct { cfg config.Config resend *resend.Client @@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer { return &Mailer{cfg: cfg, resend: resendClient} } -func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error { - subject := fmt.Sprintf("You were invited to %s on Primora", organizationName) - text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n" +// mailTemplate renders a subject + plaintext body from template data. +type mailTemplate func(data map[string]string) (subject string, body string) + +var mailTemplates = map[string]mailTemplate{ + "invitation": func(d map[string]string) (string, string) { + subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"]) + body := fmt.Sprintf( + "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n", + d["invite_url"], + ) + return subject, body + }, +} + +func renderMailTemplate(template string, data map[string]string) (string, string, error) { + tpl, ok := mailTemplates[template] + if !ok { + return "", "", fmt.Errorf("unknown email template %q", template) + } + subject, body := tpl(data) + return subject, body, nil +} + +func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error { if m.resend != nil { _, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ From: m.cfg.MailFrom, diff --git a/apps/backend/internal/services/mailer_test.go b/apps/backend/internal/services/mailer_test.go new file mode 100644 index 0000000..c1912ef --- /dev/null +++ b/apps/backend/internal/services/mailer_test.go @@ -0,0 +1,37 @@ +package services + +import ( + "strings" + "testing" +) + +func TestRenderMailTemplateInvitation(t *testing.T) { + subject, body, err := renderMailTemplate("invitation", map[string]string{ + "organization": "Acme Corp", + "invite_url": "https://app.primora.dev/accept?token=abc", + }) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(subject, "Acme Corp") { + t.Fatalf("subject missing organization: %q", subject) + } + if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") { + t.Fatalf("body missing invite url: %q", body) + } +} + +func TestRenderMailTemplateUnknown(t *testing.T) { + if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil { + t.Fatal("expected error for unknown template") + } +} + +func TestExtractEmail(t *testing.T) { + if got := extractEmail("Primora "); got != "no-reply@primora.dev" { + t.Fatalf("extractEmail = %q", got) + } + if got := extractEmail("no-reply@primora.dev"); got != "no-reply@primora.dev" { + t.Fatalf("extractEmail bare = %q", got) + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 6b3c6d5..4996b9b 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -229,14 +229,78 @@ func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalS if enc != nil { s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher.Start(context.Background()) - s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) + s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool()) s.scheduler.Start(context.Background()) - s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger) + s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool()) s.alerts.Start(context.Background()) } return s } +// sendTemplatedEmail renders a registered template, sends it, and writes an +// email_log row with the outcome — failed sends are still logged so the +// dashboard shows them. +func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error { + subject, body, err := renderMailTemplate(template, data) + status, errText := "sent", "" + if err == nil { + if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil { + status, errText, err = "failed", sendErr.Error(), sendErr + } + } else { + status, errText = "failed", err.Error() + } + var pid pgtype.UUID + if projectID != nil { + pid = pgtype.UUID{Bytes: *projectID, Valid: true} + } + _, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{ + ProjectID: pid, + Template: template, + ToEmail: to, + Subject: subject, + Status: status, + Error: errText, + }) + return err +} + +type EmailLogSummary struct { + ID string `json:"id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)}) + if err != nil { + return nil, err + } + out := make([]EmailLogSummary, len(rows)) + for i, r := range rows { + out[i] = EmailLogSummary{ + ID: r.ID.String(), + Template: r.Template, + ToEmail: r.ToEmail, + Subject: r.Subject, + Status: r.Status, + Error: r.Error, + CreatedAt: r.CreatedAt.Time, + } + } + return out, nil +} + // Close stops the webhook dispatcher and job scheduler. Idempotent. func (s *PlatformService) Close() { if s.dispatcher != nil { @@ -913,7 +977,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac if err != nil { inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token } - if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil { + var inviteProject *uuid.UUID + if projectUUID.Valid { + id := uuid.UUID(projectUUID.Bytes) + inviteProject = &id + } + if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{ + "organization": "Primora", + "invite_url": inviteURL, + }); err != nil { return nil, err } return map[string]any{ diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index 74948bf..feb7283 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -2142,6 +2142,31 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/emails: + get: + tags: [Email] + operationId: listEmailLog + summary: Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 200 + responses: + "200": + description: Email log entries + content: + application/json: + schema: + $ref: "#/components/schemas/EmailLogListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -4159,3 +4184,32 @@ components: run_id: type: string required: [received] + EmailLogEntry: + type: object + properties: + id: + type: string + format: uuid + template: + type: string + to_email: + type: string + subject: + type: string + status: + type: string + enum: [sent, failed] + error: + type: string + created_at: + type: string + format: date-time + required: [id, template, to_email, subject, status, created_at] + EmailLogListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/EmailLogEntry" + required: [items] diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index b1d65ae..1829d5c 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1722,6 +1722,8 @@ export default function App() { ("org"); const [inviteOpen, setInviteOpen] = createSignal(false); const [query, setQuery] = createSignal(""); + const [emails, setEmails] = createSignal([]); + + createEffect(() => { + const pid = props.projectID; + if (!pid || props.demoMode) return; + void EmailService.listEmailLog({ projectId: pid, limit: 20 }) + .then((res) => setEmails(res.items ?? [])) + .catch(() => {}); + }); const filteredOrgMembers = createMemo(() => { const q = query().toLowerCase(); @@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) { + 0}> +
+
+ Email log + + Transactional email sent for this project — every send is recorded, including failures. + +
+
+ + + + + + + + + + + + + {(mail) => ( + + + + + + + + )} + + +
ToSubjectTemplateStatusSent
{mail.to_email} + {mail.subject} + + {mail.template} + + {mail.status} + {formatDate(mail.created_at)}
+
+
+
+ {/* Invite modal */} ; +}; + diff --git a/packages/api-client/src/generated/schemas/$EmailLogEntry.ts b/packages/api-client/src/generated/schemas/$EmailLogEntry.ts new file mode 100644 index 0000000..900a9f2 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$EmailLogEntry.ts @@ -0,0 +1,37 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $EmailLogEntry = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + template: { + type: 'string', + isRequired: true, + }, + to_email: { + type: 'string', + isRequired: true, + }, + subject: { + type: 'string', + isRequired: true, + }, + status: { + type: 'Enum', + isRequired: true, + }, + error: { + type: 'string', + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$EmailLogListResponse.ts b/packages/api-client/src/generated/schemas/$EmailLogListResponse.ts new file mode 100644 index 0000000..82b0f24 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$EmailLogListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $EmailLogListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'EmailLogEntry', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/services/EmailService.ts b/packages/api-client/src/generated/services/EmailService.ts new file mode 100644 index 0000000..116e585 --- /dev/null +++ b/packages/api-client/src/generated/services/EmailService.ts @@ -0,0 +1,36 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EmailLogListResponse } from '../models/EmailLogListResponse'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class EmailService { + /** + * Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome. + * @returns EmailLogListResponse Email log entries + * @throws ApiError + */ + public static listEmailLog({ + projectId, + limit = 50, + }: { + projectId: string, + limit?: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/emails', + path: { + 'projectID': projectId, + }, + query: { + 'limit': limit, + }, + errors: { + 401: `Error response`, + }, + }); + } +}