feat: HA scheduler leadership + templated email log

HA scheduler: the job scheduler and alert evaluator hold session-scoped
PostgreSQL advisory locks on pinned pool connections — replicas scan for
due work exactly once, failover verified live with two replicas. Manual
and hook-triggered runs stay available on any replica.

Email surface: mailer renders registered templates (invitation); every
send is logged to core.email_log with status and error text. New
GET /projects/:id/emails endpoint; Members page shows an Email log card.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 19:27:06 +02:00
1 parent 45a1621909
commit f747b33b46
24 files changed
+669 -16

No files matched your search

+2
View File
@@ -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/<token>` (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 <slug> --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
+22
View File
@@ -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.
@@ -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;
+10
View File
@@ -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;
@@ -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
}
@@ -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"`
@@ -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)
+1
View File
@@ -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)
+20
View File
@@ -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) {
+8 -3
View File
@@ -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
+10 -2
View File
@@ -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
+98
View File
@@ -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
}
}
+27 -3
View File
@@ -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,
@@ -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 <[email protected]>"); got != "[email protected]" {
t.Fatalf("extractEmail = %q", got)
}
if got := extractEmail("[email protected]"); got != "[email protected]" {
t.Fatalf("extractEmail bare = %q", got)
}
}
@@ -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{
+54
View File
@@ -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]
+2
View File
@@ -1722,6 +1722,8 @@ export default function App() {
<Show when={activeView() === "members"}>
<MembersPage
projectID={activeProject()?.id}
demoMode={isDemo}
organizationMembers={organizationMembers()}
organizationInvitations={organizationInvitations()}
projectMembers={projectMembers()}
+61 -5
View File
@@ -1,8 +1,10 @@
import { For, Show, createMemo, createSignal } from "solid-js";
import type {
OrganizationInvitation,
OrganizationMember,
ProjectMember,
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
import {
EmailService,
type EmailLogEntry,
type OrganizationInvitation,
type OrganizationMember,
type ProjectMember,
} from "@primora/api-client";
import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal";
@@ -22,6 +24,8 @@ interface InvitationInput {
}
interface MembersPageProps {
projectID?: string;
demoMode?: boolean;
organizationMembers?: OrganizationMember[];
organizationInvitations?: OrganizationInvitation[];
projectMembers?: ProjectMember[];
@@ -80,6 +84,15 @@ export function MembersPage(props: MembersPageProps) {
const [tab, setTab] = createSignal<"org" | "project">("org");
const [inviteOpen, setInviteOpen] = createSignal(false);
const [query, setQuery] = createSignal("");
const [emails, setEmails] = createSignal<EmailLogEntry[]>([]);
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) {
</div>
</Show>
<Show when={emails().length > 0}>
<div class="card card-flush">
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
<span class="card-header-title">Email log</span>
<span class="card-header-description">
Transactional email sent for this project — every send is recorded, including failures.
</span>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>To</th>
<th>Subject</th>
<th>Template</th>
<th>Status</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
<For each={emails()}>
{(mail) => (
<tr>
<td class="font-medium text-text-1">{mail.to_email}</td>
<td class="text-text-2" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis" title={mail.error || mail.subject}>
{mail.subject}
</td>
<td>
<Badge variant="neutral">{mail.template}</Badge>
</td>
<td>
<Badge variant={mail.status === "sent" ? "success" : "error"}>{mail.status}</Badge>
</td>
<td class="text-text-3">{formatDate(mail.created_at)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
{/* Invite modal */}
<Modal
open={inviteOpen()}
@@ -67,6 +67,8 @@ export type { DeployMarker } from './models/DeployMarker';
export type { Document } from './models/Document';
export type { DocumentID } from './models/DocumentID';
export type { DocumentListResponse } from './models/DocumentListResponse';
export { EmailLogEntry } from './models/EmailLogEntry';
export type { EmailLogListResponse } from './models/EmailLogListResponse';
export type { ErrorEnvelope } from './models/ErrorEnvelope';
export type { EventSeriesBucket } from './models/EventSeriesBucket';
export type { HealthStatus } from './models/HealthStatus';
@@ -207,6 +209,8 @@ export { $DeployMarker } from './schemas/$DeployMarker';
export { $Document } from './schemas/$Document';
export { $DocumentID } from './schemas/$DocumentID';
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
export { $EmailLogEntry } from './schemas/$EmailLogEntry';
export { $EmailLogListResponse } from './schemas/$EmailLogListResponse';
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
export { $HealthStatus } from './schemas/$HealthStatus';
@@ -291,6 +295,7 @@ export { AlertsService } from './services/AlertsService';
export { AutomationService } from './services/AutomationService';
export { CollectionsService } from './services/CollectionsService';
export { DatabasesService } from './services/DatabasesService';
export { EmailService } from './services/EmailService';
export { HealthService } from './services/HealthService';
export { InboundService } from './services/InboundService';
export { InstanceService } from './services/InstanceService';
@@ -0,0 +1,20 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type EmailLogEntry = {
id: string;
template: string;
to_email: string;
subject: string;
status: EmailLogEntry.status;
error?: string;
created_at: string;
};
export namespace EmailLogEntry {
export enum status {
SENT = 'sent',
FAILED = 'failed',
}
}
@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { EmailLogEntry } from './EmailLogEntry';
export type EmailLogListResponse = {
items: Array<EmailLogEntry>;
};
@@ -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;
@@ -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;
@@ -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<EmailLogListResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/projects/{projectID}/emails',
path: {
'projectID': projectId,
},
query: {
'limit': limit,
},
errors: {
401: `Error response`,
},
});
}
}