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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 268 additions & 6 deletions api/private.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,38 @@ type MailDispatcher interface {
SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error
}

// NewsletterArchive is the private API's view of the newsletter archive.
type NewsletterArchive interface {
AllNewsletters(ctx context.Context) ([]domain.SentNewsletter, error)
GetNewsletter(ctx context.Context, id uint) (*domain.SentNewsletter, error)
DeleteNewsletter(ctx context.Context, id uint) error
}

// ScheduleManager is the private API's view of the scheduling service.
type ScheduleManager interface {
Schedule(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error)
List(ctx context.Context) ([]domain.ScheduledMail, error)
Get(ctx context.Context, id uint) (*domain.ScheduledMail, error)
Delete(ctx context.Context, id uint) error
Reschedule(ctx context.Context, id uint, scheduledAt int64) (*domain.ScheduledMail, error)
ReplaceContent(ctx context.Context, id uint, rawMarkdown string) (*domain.ScheduledMail, error)
}

// PrivateHandler serves the private admin API.
// When publicKey is non-nil, every request must carry a valid Ed25519 signature.
type PrivateHandler struct {
lists ListManager
mail MailDispatcher
publicKey ed25519.PublicKey
logger *slog.Logger
lists ListManager
mail MailDispatcher
newsletters NewsletterArchive
scheduler ScheduleManager
publicKey ed25519.PublicKey
logger *slog.Logger
}

// NewPrivateHandler creates a new PrivateHandler.
// Pass a nil publicKey to disable request authentication.
func NewPrivateHandler(lists ListManager, mail MailDispatcher, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler {
return &PrivateHandler{lists: lists, mail: mail, publicKey: publicKey, logger: logger}
func NewPrivateHandler(lists ListManager, mail MailDispatcher, newsletters NewsletterArchive, scheduler ScheduleManager, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler {
return &PrivateHandler{lists: lists, mail: mail, newsletters: newsletters, scheduler: scheduler, publicKey: publicKey, logger: logger}
}

// Routes returns the mux for all private API endpoints.
Expand All @@ -59,7 +78,16 @@ func (h *PrivateHandler) Routes() *http.ServeMux {
mux.Handle("DELETE /lists/{name}", h.auth(h.handleDeleteList))
mux.Handle("GET /lists/{name}/users", h.auth(h.handleListUsers))
mux.Handle("POST /lists/{name}/send", h.auth(h.handleSendToList))
mux.Handle("POST /lists/{name}/schedule", h.auth(h.handleScheduleMail))
mux.Handle("POST /mail/test", h.auth(h.handleSendTestMail))
mux.Handle("GET /newsletters", h.auth(h.handleAllNewsletters))
mux.Handle("GET /newsletters/{id}", h.auth(h.handleGetNewsletter))
mux.Handle("DELETE /newsletters/{id}", h.auth(h.handleDeleteNewsletter))
mux.Handle("GET /scheduled", h.auth(h.handleAllScheduled))
mux.Handle("GET /scheduled/{id}", h.auth(h.handleGetScheduled))
mux.Handle("DELETE /scheduled/{id}", h.auth(h.handleDeleteScheduled))
mux.Handle("PUT /scheduled/{id}/schedule", h.auth(h.handleRescheduleMail))
mux.Handle("PUT /scheduled/{id}/content", h.auth(h.handleReplaceScheduledContent))
return mux
}

Expand Down Expand Up @@ -98,6 +126,44 @@ type testMailRequest struct {
Data map[string]any `json:"data"`
}

type newsletterSummaryResponse struct {
ID uint `json:"id"`
Subject string `json:"subject"`
SenderName string `json:"senderName"`
SentAt string `json:"sentAt"`
MailingLists []string `json:"mailingLists"`
}

type newsletterDetailResponse struct {
ID uint `json:"id"`
Subject string `json:"subject"`
SenderName string `json:"senderName"`
RawMarkdown string `json:"rawMarkdown"`
SentAt string `json:"sentAt"`
Recipients []userResponse `json:"recipients"`
MailingLists []string `json:"mailingLists"`
}

type scheduleRequest struct {
Raw string `json:"raw"`
ScheduledAt int64 `json:"scheduledAt"`
}

type scheduledMailResponse struct {
ID uint `json:"id"`
MailingListName string `json:"mailingListName"`
ScheduledAt int64 `json:"scheduledAt"`
SentAt *int64 `json:"sentAt"`
}

type rescheduleRequest struct {
ScheduledAt int64 `json:"scheduledAt"`
}

type replaceContentRequest struct {
Raw string `json:"raw"`
}

// --- handlers ---

func (h *PrivateHandler) handleAllLists(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -224,6 +290,75 @@ func (h *PrivateHandler) handleSendToList(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, map[string]string{"message": "mail dispatched"})
}

func (h *PrivateHandler) handleAllNewsletters(w http.ResponseWriter, r *http.Request) {
newsletters, err := h.newsletters.AllNewsletters(r.Context())
if err != nil {
h.logger.ErrorContext(r.Context(), "get all newsletters failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load newsletters")
return
}
resp := make([]newsletterSummaryResponse, len(newsletters))
for i, n := range newsletters {
lists := make([]string, len(n.MailingLists))
for j, l := range n.MailingLists {
lists[j] = l.Name
}
resp[i] = newsletterSummaryResponse{
ID: n.ID,
Subject: n.Subject,
SenderName: n.SenderName,
SentAt: n.SentAt.Format(time.RFC3339),
MailingLists: lists,
}
}
writeJSON(w, http.StatusOK, resp)
}

func (h *PrivateHandler) handleGetNewsletter(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid newsletter id")
return
}
n, err := h.newsletters.GetNewsletter(r.Context(), uint(id))
if err != nil {
h.logger.ErrorContext(r.Context(), "get newsletter failed", slog.Uint64("id", id), slog.Any("error", err))
writeError(w, http.StatusNotFound, "newsletter not found")
return
}
lists := make([]string, len(n.MailingLists))
for i, l := range n.MailingLists {
lists[i] = l.Name
}
recipients := make([]userResponse, len(n.Recipients))
for i, u := range n.Recipients {
recipients[i] = userResponse{ID: u.ID, Name: u.Name, Email: u.Email, Confirmed: u.IsConfirmed()}
}
writeJSON(w, http.StatusOK, newsletterDetailResponse{
ID: n.ID,
Subject: n.Subject,
SenderName: n.SenderName,
RawMarkdown: n.RawMarkdown,
SentAt: n.SentAt.Format(time.RFC3339),
Recipients: recipients,
MailingLists: lists,
})
}

func (h *PrivateHandler) handleDeleteNewsletter(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid newsletter id")
return
}
if err := h.newsletters.DeleteNewsletter(r.Context(), uint(id)); err != nil {
h.logger.ErrorContext(r.Context(), "delete newsletter failed", slog.Uint64("id", id), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to delete newsletter")
return
}
w.WriteHeader(http.StatusNoContent)
}

func (h *PrivateHandler) handleSendTestMail(w http.ResponseWriter, r *http.Request) {
var body testMailRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
Expand All @@ -245,6 +380,133 @@ func (h *PrivateHandler) handleSendTestMail(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, map[string]string{"message": "test mail sent"})
}

func (h *PrivateHandler) handleScheduleMail(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")

var body scheduleRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Raw == "" || body.ScheduledAt == 0 {
writeError(w, http.StatusBadRequest, "raw and scheduledAt are required")
return
}

m, err := h.scheduler.Schedule(r.Context(), name, body.Raw, body.ScheduledAt)
if err != nil {
h.logger.ErrorContext(r.Context(), "schedule mail failed", slog.String("list", name), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to schedule mail")
return
}

writeJSON(w, http.StatusCreated, scheduledMailResponse{
ID: m.ID,
MailingListName: m.MailingListName,
ScheduledAt: m.ScheduledAt,
SentAt: m.SentAt,
})
}

func (h *PrivateHandler) handleAllScheduled(w http.ResponseWriter, r *http.Request) {
mails, err := h.scheduler.List(r.Context())
if err != nil {
h.logger.ErrorContext(r.Context(), "list scheduled mails failed", slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to load scheduled mails")
return
}
resp := make([]scheduledMailResponse, len(mails))
for i, m := range mails {
resp[i] = scheduledMailResponse{
ID: m.ID,
MailingListName: m.MailingListName,
ScheduledAt: m.ScheduledAt,
SentAt: m.SentAt,
}
}
writeJSON(w, http.StatusOK, resp)
}

func (h *PrivateHandler) handleGetScheduled(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
m, err := h.scheduler.Get(r.Context(), uint(id))
if err != nil {
h.logger.ErrorContext(r.Context(), "get scheduled mail failed", slog.Uint64("id", id), slog.Any("error", err))
writeError(w, http.StatusNotFound, "scheduled mail not found")
return
}
writeJSON(w, http.StatusOK, scheduledMailResponse{
ID: m.ID,
MailingListName: m.MailingListName,
ScheduledAt: m.ScheduledAt,
SentAt: m.SentAt,
})
}

func (h *PrivateHandler) handleDeleteScheduled(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.scheduler.Delete(r.Context(), uint(id)); err != nil {
h.logger.ErrorContext(r.Context(), "delete scheduled mail failed", slog.Uint64("id", id), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to delete scheduled mail")
return
}
w.WriteHeader(http.StatusNoContent)
}

func (h *PrivateHandler) handleRescheduleMail(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body rescheduleRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ScheduledAt == 0 {
writeError(w, http.StatusBadRequest, "scheduledAt is required")
return
}
m, err := h.scheduler.Reschedule(r.Context(), uint(id), body.ScheduledAt)
if err != nil {
h.logger.ErrorContext(r.Context(), "reschedule mail failed", slog.Uint64("id", id), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to reschedule mail")
return
}
writeJSON(w, http.StatusOK, scheduledMailResponse{
ID: m.ID,
MailingListName: m.MailingListName,
ScheduledAt: m.ScheduledAt,
SentAt: m.SentAt,
})
}

func (h *PrivateHandler) handleReplaceScheduledContent(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body replaceContentRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Raw == "" {
writeError(w, http.StatusBadRequest, "raw is required")
return
}
m, err := h.scheduler.ReplaceContent(r.Context(), uint(id), body.Raw)
if err != nil {
h.logger.ErrorContext(r.Context(), "replace scheduled content failed", slog.Uint64("id", id), slog.Any("error", err))
writeError(w, http.StatusInternalServerError, "failed to replace content")
return
}
writeJSON(w, http.StatusOK, scheduledMailResponse{
ID: m.ID,
MailingListName: m.MailingListName,
ScheduledAt: m.ScheduledAt,
SentAt: m.SentAt,
})
}

// --- auth middleware ---

const signatureWindow = 5 * time.Minute
Expand Down
Loading
Loading