diff --git a/api/private.go b/api/private.go index fcd054d..7b2c27f 100644 --- a/api/private.go +++ b/api/private.go @@ -30,7 +30,7 @@ type ListManager interface { // MailDispatcher is the private API's view of the mail service. type MailDispatcher interface { - SendToList(ctx context.Context, listName string, raw string, data map[string]any) error + SendToList(ctx context.Context, listName string, raw string, topicNames []string, data map[string]any) error SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error } @@ -43,7 +43,7 @@ type NewsletterArchive interface { // 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) + Schedule(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64, topicNames []string) (*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 @@ -53,19 +53,28 @@ type ScheduleManager interface { // PrivateHandler serves the private admin API. // When publicKey is non-nil, every request must carry a valid Ed25519 signature. +type TopicManager interface { + Create(ctx context.Context, mailingListName, name, displayName string, defaultEnabled, subscribeExisting bool) (*domain.Topic, error) + List(ctx context.Context, mailingListName string) ([]domain.Topic, error) + Get(ctx context.Context, mailingListName, name string) (*domain.Topic, error) + Update(ctx context.Context, mailingListName, name string, displayName *string, defaultEnabled *bool) (*domain.Topic, error) + Delete(ctx context.Context, mailingListName, name string) error +} + type PrivateHandler struct { lists ListManager mail MailDispatcher newsletters NewsletterArchive scheduler ScheduleManager + topics TopicManager 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, 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} +func NewPrivateHandler(lists ListManager, mail MailDispatcher, newsletters NewsletterArchive, scheduler ScheduleManager, topics TopicManager, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler { + return &PrivateHandler{lists: lists, mail: mail, newsletters: newsletters, scheduler: scheduler, topics: topics, publicKey: publicKey, logger: logger} } // Routes returns the mux for all private API endpoints. @@ -79,6 +88,11 @@ func (h *PrivateHandler) Routes() *http.ServeMux { 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("GET /lists/{name}/topics", h.auth(h.handleListTopics)) + mux.Handle("POST /lists/{name}/topics", h.auth(h.handleCreateTopic)) + mux.Handle("GET /lists/{name}/topics/{topic}", h.auth(h.handleGetTopic)) + mux.Handle("PUT /lists/{name}/topics/{topic}", h.auth(h.handleUpdateTopic)) + mux.Handle("DELETE /lists/{name}/topics/{topic}", h.auth(h.handleDeleteTopic)) 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)) @@ -113,8 +127,9 @@ type userResponse struct { } type sendRequest struct { - Raw string `json:"raw"` - Data map[string]any `json:"data"` + Raw string `json:"raw"` + Topics []string `json:"topics"` + Data map[string]any `json:"data"` } type testMailRequest struct { @@ -145,15 +160,23 @@ type newsletterDetailResponse struct { } type scheduleRequest struct { - Raw string `json:"raw"` - ScheduledAt int64 `json:"scheduledAt"` + Raw string `json:"raw"` + Topics []string `json:"topics"` + ScheduledAt int64 `json:"scheduledAt"` } type scheduledMailResponse struct { - ID uint `json:"id"` - MailingListName string `json:"mailingListName"` - ScheduledAt int64 `json:"scheduledAt"` - SentAt *int64 `json:"sentAt"` + ID uint `json:"id"` + MailingListName string `json:"mailingListName"` + ScheduledAt int64 `json:"scheduledAt"` + SentAt *int64 `json:"sentAt"` + TopicNames []string `json:"topicNames,omitempty"` +} + +type topicResponse struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + DefaultEnabled bool `json:"defaultEnabled"` } type rescheduleRequest struct { @@ -281,7 +304,7 @@ func (h *PrivateHandler) handleSendToList(w http.ResponseWriter, r *http.Request return } - if err := h.mail.SendToList(r.Context(), name, body.Raw, body.Data); err != nil { + if err := h.mail.SendToList(r.Context(), name, body.Raw, body.Topics, body.Data); err != nil { h.logger.ErrorContext(r.Context(), "send to list failed", slog.String("list", name), slog.Any("error", err)) writeError(w, http.StatusInternalServerError, "failed to send mail") return @@ -389,7 +412,7 @@ func (h *PrivateHandler) handleScheduleMail(w http.ResponseWriter, r *http.Reque return } - m, err := h.scheduler.Schedule(r.Context(), name, body.Raw, body.ScheduledAt) + m, err := h.scheduler.Schedule(r.Context(), name, body.Raw, body.ScheduledAt, body.Topics) 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") @@ -401,9 +424,92 @@ func (h *PrivateHandler) handleScheduleMail(w http.ResponseWriter, r *http.Reque MailingListName: m.MailingListName, ScheduledAt: m.ScheduledAt, SentAt: m.SentAt, + TopicNames: m.TopicNames, }) } +func (h *PrivateHandler) handleListTopics(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + topics, err := h.topics.List(r.Context(), name) + if err != nil { + h.logger.ErrorContext(r.Context(), "list topics failed", slog.String("list", name), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load topics") + return + } + resp := make([]topicResponse, len(topics)) + for i, t := range topics { + resp[i] = topicResponse{Name: t.Name, DisplayName: t.DisplayName, DefaultEnabled: t.DefaultEnabled} + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *PrivateHandler) handleCreateTopic(w http.ResponseWriter, r *http.Request) { + listName := r.PathValue("name") + var body struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + DefaultEnabled bool `json:"defaultEnabled"` + SubscribeExisting bool `json:"subscribeExisting"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if body.DisplayName == "" { + body.DisplayName = body.Name + } + t, err := h.topics.Create(r.Context(), listName, body.Name, body.DisplayName, body.DefaultEnabled, body.SubscribeExisting) + if err != nil { + h.logger.ErrorContext(r.Context(), "create topic failed", slog.String("list", listName), slog.String("topic", body.Name), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to create topic") + return + } + writeJSON(w, http.StatusCreated, topicResponse{Name: t.Name, DisplayName: t.DisplayName, DefaultEnabled: t.DefaultEnabled}) +} + +func (h *PrivateHandler) handleGetTopic(w http.ResponseWriter, r *http.Request) { + listName := r.PathValue("name") + topicName := r.PathValue("topic") + t, err := h.topics.Get(r.Context(), listName, topicName) + if err != nil { + h.logger.ErrorContext(r.Context(), "get topic failed", slog.String("list", listName), slog.String("topic", topicName), slog.Any("error", err)) + writeError(w, http.StatusNotFound, "topic not found") + return + } + writeJSON(w, http.StatusOK, topicResponse{Name: t.Name, DisplayName: t.DisplayName, DefaultEnabled: t.DefaultEnabled}) +} + +func (h *PrivateHandler) handleUpdateTopic(w http.ResponseWriter, r *http.Request) { + listName := r.PathValue("name") + topicName := r.PathValue("topic") + var body struct { + DisplayName *string `json:"displayName"` + DefaultEnabled *bool `json:"defaultEnabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + t, err := h.topics.Update(r.Context(), listName, topicName, body.DisplayName, body.DefaultEnabled) + if err != nil { + h.logger.ErrorContext(r.Context(), "update topic failed", slog.String("list", listName), slog.String("topic", topicName), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to update topic") + return + } + writeJSON(w, http.StatusOK, topicResponse{Name: t.Name, DisplayName: t.DisplayName, DefaultEnabled: t.DefaultEnabled}) +} + +func (h *PrivateHandler) handleDeleteTopic(w http.ResponseWriter, r *http.Request) { + listName := r.PathValue("name") + topicName := r.PathValue("topic") + if err := h.topics.Delete(r.Context(), listName, topicName); err != nil { + h.logger.ErrorContext(r.Context(), "delete topic failed", slog.String("list", listName), slog.String("topic", topicName), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to delete topic") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *PrivateHandler) handleAllScheduled(w http.ResponseWriter, r *http.Request) { mails, err := h.scheduler.List(r.Context()) if err != nil { @@ -418,6 +524,7 @@ func (h *PrivateHandler) handleAllScheduled(w http.ResponseWriter, r *http.Reque MailingListName: m.MailingListName, ScheduledAt: m.ScheduledAt, SentAt: m.SentAt, + TopicNames: m.TopicNames, } } writeJSON(w, http.StatusOK, resp) @@ -440,6 +547,7 @@ func (h *PrivateHandler) handleGetScheduled(w http.ResponseWriter, r *http.Reque MailingListName: m.MailingListName, ScheduledAt: m.ScheduledAt, SentAt: m.SentAt, + TopicNames: m.TopicNames, }) } @@ -479,6 +587,7 @@ func (h *PrivateHandler) handleRescheduleMail(w http.ResponseWriter, r *http.Req MailingListName: m.MailingListName, ScheduledAt: m.ScheduledAt, SentAt: m.SentAt, + TopicNames: m.TopicNames, }) } @@ -504,6 +613,7 @@ func (h *PrivateHandler) handleReplaceScheduledContent(w http.ResponseWriter, r MailingListName: m.MailingListName, ScheduledAt: m.ScheduledAt, SentAt: m.SentAt, + TopicNames: m.TopicNames, }) } diff --git a/api/private_client.go b/api/private_client.go index 94b49c8..136fd6b 100644 --- a/api/private_client.go +++ b/api/private_client.go @@ -171,8 +171,8 @@ func (c *PrivateClient) GetUsers(ctx context.Context, listName string) ([]UserIt // SendToList dispatches a rendered markdown mail to all confirmed subscribers // of the named list. -func (c *PrivateClient) SendToList(ctx context.Context, listName string, raw string, data map[string]any) error { - resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/lists/%s/send", listName), map[string]any{"raw": raw, "data": data}) +func (c *PrivateClient) SendToList(ctx context.Context, listName string, raw string, topicNames []string, data map[string]any) error { + resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/lists/%s/send", listName), map[string]any{"raw": raw, "topics": topicNames, "data": data}) if err != nil { return fmt.Errorf("send to list: %w", err) } @@ -205,16 +205,17 @@ func (c *PrivateClient) SendTestMail(ctx context.Context, recipient RecipientInp // ScheduledMailItem describes a single scheduled mail as returned by the API. type ScheduledMailItem struct { - ID uint `json:"id"` - MailingListName string `json:"mailingListName"` - ScheduledAt int64 `json:"scheduledAt"` - SentAt *int64 `json:"sentAt"` + ID uint `json:"id"` + MailingListName string `json:"mailingListName"` + ScheduledAt int64 `json:"scheduledAt"` + SentAt *int64 `json:"sentAt"` + TopicNames []string `json:"topicNames,omitempty"` } // ScheduleMail creates a new scheduled mail for the given list. // scheduledAt is a unix timestamp (UTC). -func (c *PrivateClient) ScheduleMail(ctx context.Context, listName string, raw string, scheduledAt int64) (*ScheduledMailItem, error) { - payload := map[string]any{"raw": raw, "scheduledAt": scheduledAt} +func (c *PrivateClient) ScheduleMail(ctx context.Context, listName string, raw string, scheduledAt int64, topicNames []string) (*ScheduledMailItem, error) { + payload := map[string]any{"raw": raw, "scheduledAt": scheduledAt, "topics": topicNames} resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/lists/%s/schedule", listName), payload) if err != nil { return nil, fmt.Errorf("schedule mail: %w", err) @@ -318,7 +319,101 @@ func (c *PrivateClient) ReplaceScheduledContent(ctx context.Context, id uint, ra return &out, nil } -// do builds and executes a signed HTTP request. +type TopicItem struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + DefaultEnabled bool `json:"defaultEnabled"` +} + +func (c *PrivateClient) ListTopics(ctx context.Context, listName string) ([]TopicItem, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%s/topics", listName), nil) + if err != nil { + return nil, fmt.Errorf("list topics: %w", err) + } + defer resp.Body.Close() + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("list topics: %w", err) + } + var out []TopicItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("list topics: decode response: %w", err) + } + return out, nil +} + +func (c *PrivateClient) CreateTopic(ctx context.Context, listName, name, displayName string, defaultEnabled, subscribeExisting bool) (*TopicItem, error) { + payload := map[string]any{ + "name": name, + "displayName": displayName, + "defaultEnabled": defaultEnabled, + "subscribeExisting": subscribeExisting, + } + resp, err := c.do(ctx, http.MethodPost, fmt.Sprintf("/lists/%s/topics", listName), payload) + if err != nil { + return nil, fmt.Errorf("create topic: %w", err) + } + defer resp.Body.Close() + if err := expectStatus(resp, http.StatusCreated); err != nil { + return nil, fmt.Errorf("create topic: %w", err) + } + var out TopicItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("create topic: decode response: %w", err) + } + return &out, nil +} + +func (c *PrivateClient) GetTopic(ctx context.Context, listName, topicName string) (*TopicItem, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%s/topics/%s", listName, topicName), nil) + if err != nil { + return nil, fmt.Errorf("get topic: %w", err) + } + defer resp.Body.Close() + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("get topic: %w", err) + } + var out TopicItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("get topic: decode response: %w", err) + } + return &out, nil +} + +func (c *PrivateClient) UpdateTopic(ctx context.Context, listName, topicName string, displayName *string, defaultEnabled *bool) (*TopicItem, error) { + payload := map[string]any{} + if displayName != nil { + payload["displayName"] = *displayName + } + if defaultEnabled != nil { + payload["defaultEnabled"] = *defaultEnabled + } + resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/lists/%s/topics/%s", listName, topicName), payload) + if err != nil { + return nil, fmt.Errorf("update topic: %w", err) + } + defer resp.Body.Close() + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("update topic: %w", err) + } + var out TopicItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("update topic: decode response: %w", err) + } + return &out, nil +} + +func (c *PrivateClient) DeleteTopic(ctx context.Context, listName, topicName string) error { + resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/lists/%s/topics/%s", listName, topicName), nil) + if err != nil { + return fmt.Errorf("delete topic: %w", err) + } + defer resp.Body.Close() + if err := expectStatus(resp, http.StatusNoContent); err != nil { + return fmt.Errorf("delete topic: %w", err) + } + return nil +} + func (c *PrivateClient) do(ctx context.Context, method, path string, payload any) (*http.Response, error) { var bodyBytes []byte if payload != nil { diff --git a/api/private_test.go b/api/private_test.go index b53dd2d..3f43aec 100644 --- a/api/private_test.go +++ b/api/private_test.go @@ -135,7 +135,7 @@ type fakeMailDispatcher struct { lastRecipient domain.User } -func (f *fakeMailDispatcher) SendToList(_ context.Context, listName, raw string, _ map[string]any) error { +func (f *fakeMailDispatcher) SendToList(_ context.Context, listName, raw string, _ []string, _ map[string]any) error { f.lastListName = listName f.lastRaw = raw return f.sendToListErr @@ -204,7 +204,7 @@ func (f *fakeNewsletterArchive) DeleteNewsletter(_ context.Context, id uint) err type fakeScheduleManager struct{} -func (f *fakeScheduleManager) Schedule(_ context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) { +func (f *fakeScheduleManager) Schedule(_ context.Context, mailingListName, rawMarkdown string, scheduledAt int64, _ []string) (*domain.ScheduledMail, error) { return &domain.ScheduledMail{ID: 1, MailingListName: mailingListName, RawMarkdown: rawMarkdown, ScheduledAt: scheduledAt}, nil } func (f *fakeScheduleManager) List(_ context.Context) ([]domain.ScheduledMail, error) { @@ -222,7 +222,7 @@ func (f *fakeScheduleManager) ReplaceContent(_ context.Context, id uint, rawMark } func newPrivateTestHandler(lists *fakeListManager, mail *fakeMailDispatcher, pub ed25519.PublicKey) *PrivateHandler { - return NewPrivateHandler(lists, mail, newFakeNewsletterArchive(), &fakeScheduleManager{}, pub, slog.Default()) + return NewPrivateHandler(lists, mail, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, pub, slog.Default()) } func privateRequest(t *testing.T, h *PrivateHandler, method, target string, body any) *httptest.ResponseRecorder { @@ -579,7 +579,7 @@ func TestPrivateHandler_AllNewsletters(t *testing.T) { t.Run("returns all newsletters", func(t *testing.T) { nl := newFakeNewsletterArchive(n) - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, nil, slog.Default()) w := privateRequest(t, h, http.MethodGet, "/newsletters", nil) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) @@ -600,7 +600,7 @@ func TestPrivateHandler_AllNewsletters(t *testing.T) { t.Run("returns 500 on service error", func(t *testing.T) { nl := newFakeNewsletterArchive() nl.getAllErr = errors.New("db failure") - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, nil, slog.Default()) w := privateRequest(t, h, http.MethodGet, "/newsletters", nil) if w.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d", w.Code) @@ -623,7 +623,7 @@ func TestPrivateHandler_GetNewsletter(t *testing.T) { t.Run("returns newsletter detail", func(t *testing.T) { nl := newFakeNewsletterArchive(n) - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, nil, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/newsletters/42", nil) req.SetPathValue("id", "42") w := httptest.NewRecorder() @@ -649,7 +649,7 @@ func TestPrivateHandler_GetNewsletter(t *testing.T) { }) t.Run("returns 404 when not found", func(t *testing.T) { - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, nil, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/newsletters/99", nil) req.SetPathValue("id", "99") w := httptest.NewRecorder() @@ -660,7 +660,7 @@ func TestPrivateHandler_GetNewsletter(t *testing.T) { }) t.Run("returns 400 on invalid id", func(t *testing.T) { - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, nil, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/newsletters/abc", nil) req.SetPathValue("id", "abc") w := httptest.NewRecorder() @@ -677,7 +677,7 @@ func TestPrivateHandler_DeleteNewsletter(t *testing.T) { t.Run("returns 204 on success", func(t *testing.T) { nl := newFakeNewsletterArchive(n) - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, nil, slog.Default()) req := httptest.NewRequest(http.MethodDelete, "/newsletters/7", nil) req.SetPathValue("id", "7") w := httptest.NewRecorder() @@ -693,7 +693,7 @@ func TestPrivateHandler_DeleteNewsletter(t *testing.T) { t.Run("returns 500 on service error", func(t *testing.T) { nl := newFakeNewsletterArchive(n) nl.deleteErr = errors.New("db down") - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, nil, slog.Default()) req := httptest.NewRequest(http.MethodDelete, "/newsletters/7", nil) req.SetPathValue("id", "7") w := httptest.NewRecorder() @@ -704,7 +704,7 @@ func TestPrivateHandler_DeleteNewsletter(t *testing.T) { }) t.Run("returns 400 on invalid id", func(t *testing.T) { - h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, nil, slog.Default()) req := httptest.NewRequest(http.MethodDelete, "/newsletters/bad", nil) req.SetPathValue("id", "bad") w := httptest.NewRecorder() @@ -772,7 +772,7 @@ func TestPrivateClient_Integration(t *testing.T) { } mail := &fakeMailDispatcher{} - srv := httptest.NewServer(NewPrivateHandler(m, mail, newFakeNewsletterArchive(), &fakeScheduleManager{}, pub, slog.Default()).Routes()) + srv := httptest.NewServer(NewPrivateHandler(m, mail, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, pub, slog.Default()).Routes()) defer srv.Close() client := NewPrivateClient(srv.URL, priv) @@ -812,7 +812,7 @@ func TestPrivateClient_Integration(t *testing.T) { }) t.Run("SendToList", func(t *testing.T) { - if err := client.SendToList(ctx, "weekly", "# Hello", nil); err != nil { + if err := client.SendToList(ctx, "weekly", "# Hello", nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } if mail.lastListName != "weekly" { diff --git a/api/public.go b/api/public.go index a5175e2..6a5f81b 100644 --- a/api/public.go +++ b/api/public.go @@ -13,7 +13,7 @@ import ( ) type Subscriber interface { - Subscribe(ctx context.Context, listName, userName, email string) (*domain.User, error) + Subscribe(ctx context.Context, listName, userName, email string, topicNames []string) (*domain.User, error) Confirm(ctx context.Context, token string) error Unsubscribe(ctx context.Context, unsubscribeToken string) error } @@ -22,6 +22,12 @@ type NewsletterPreviewer interface { RenderNewsletter(ctx context.Context, id uint, unsubscribeToken string) (string, error) } +type PreferencesManager interface { + GetUserTopics(ctx context.Context, mailingListName string, userID uint) ([]domain.Topic, error) + SetUserTopics(ctx context.Context, mailingListName string, userID uint, topicIDs []uint) error + List(ctx context.Context, mailingListName string) ([]domain.Topic, error) +} + type RedirectPages struct { SubscribeSuccess string SubscribeError string @@ -34,12 +40,15 @@ type RedirectPages struct { type PublicHandler struct { subscriptions Subscriber newsletters NewsletterPreviewer + preferences PreferencesManager + users domain.UserRepository + renderer domain.Renderer redirects RedirectPages logger *slog.Logger } -func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, redirects RedirectPages, logger *slog.Logger) *PublicHandler { - return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, redirects: redirects, logger: logger} +func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, preferences PreferencesManager, users domain.UserRepository, renderer domain.Renderer, redirects RedirectPages, logger *slog.Logger) *PublicHandler { + return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, preferences: preferences, users: users, renderer: renderer, redirects: redirects, logger: logger} } func (h *PublicHandler) Routes() *http.ServeMux { @@ -48,6 +57,8 @@ func (h *PublicHandler) Routes() *http.ServeMux { mux.HandleFunc("GET /confirm/{token}", h.handleConfirm) mux.HandleFunc("GET /unsubscribe/{token}", h.handleUnsubscribe) mux.HandleFunc("GET /mail/{id}", h.handleNewsletterPreview) + mux.HandleFunc("GET /preferences/{listName}/{token}", h.handlePreferencesPage) + mux.HandleFunc("POST /preferences/{listName}/{token}", h.handleSavePreferences) return mux } @@ -60,7 +71,7 @@ func (h *PublicHandler) handleSubscribe(w http.ResponseWriter, r *http.Request) return } - if _, err := h.subscriptions.Subscribe(r.Context(), listName, name, email); err != nil { + if _, err := h.subscriptions.Subscribe(r.Context(), listName, name, email, nil); err != nil { h.logger.ErrorContext(r.Context(), "subscribe failed", slog.String("list", listName), slog.String("email", email), @@ -127,6 +138,111 @@ func (h *PublicHandler) handleNewsletterPreview(w http.ResponseWriter, r *http.R fmt.Fprint(w, body) } +var preferencesTemplate = `
Preferences saved.
{{end}}` + +type preferencesTopicData struct { + ID uint + Name string + DisplayName string + Subscribed bool +} + +func (h *PublicHandler) handlePreferencesPage(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + saved := r.URL.Query().Get("saved") == "1" + + user, err := h.users.GetUserByUnsubscribeToken(r.Context(), token) + if err != nil { + h.logger.ErrorContext(r.Context(), "preferences: user lookup failed", slog.Any("error", err)) + writeError(w, http.StatusNotFound, "invalid token") + return + } + + allTopics, err := h.preferences.List(r.Context(), user.MailingListName) + if err != nil { + h.logger.ErrorContext(r.Context(), "preferences: list topics failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load topics") + return + } + + userTopics, err := h.preferences.GetUserTopics(r.Context(), user.MailingListName, user.ID) + if err != nil { + h.logger.ErrorContext(r.Context(), "preferences: get user topics failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load preferences") + return + } + + subscribedIDs := make(map[uint]bool) + for _, t := range userTopics { + subscribedIDs[t.ID] = true + } + + topicData := make([]preferencesTopicData, len(allTopics)) + for i, t := range allTopics { + topicData[i] = preferencesTopicData{ + ID: t.ID, + Name: t.Name, + DisplayName: t.DisplayName, + Subscribed: subscribedIDs[t.ID], + } + } + + data := map[string]any{"topics": topicData, "saved": saved} + rendered, err := h.renderer.RenderHTML(preferencesTemplate, data) + if err != nil { + h.logger.ErrorContext(r.Context(), "preferences: render failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to render preferences") + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, rendered) +} + +func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + user, err := h.users.GetUserByUnsubscribeToken(r.Context(), token) + if err != nil { + h.logger.ErrorContext(r.Context(), "save preferences: user lookup failed", slog.Any("error", err)) + writeError(w, http.StatusNotFound, "invalid token") + return + } + + if err := r.ParseForm(); err != nil { + writeError(w, http.StatusBadRequest, "invalid form data") + return + } + + topicIDStrs := r.Form["topic"] + topicIDs := make([]uint, 0, len(topicIDStrs)) + for _, s := range topicIDStrs { + id, err := strconv.ParseUint(s, 10, 64) + if err != nil { + continue + } + topicIDs = append(topicIDs, uint(id)) + } + + if err := h.preferences.SetUserTopics(r.Context(), user.MailingListName, user.ID, topicIDs); err != nil { + h.logger.ErrorContext(r.Context(), "save preferences failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to save preferences") + return + } + + listName := r.PathValue("listName") + + http.Redirect(w, r, fmt.Sprintf("/preferences/%s/%s?saved=1", listName, token), http.StatusSeeOther) +} + func redirectOrJSON(w http.ResponseWriter, r *http.Request, redirectURL string, status int, v any) { if redirectURL != "" { http.Redirect(w, r, redirectURL, http.StatusSeeOther) diff --git a/api/public_test.go b/api/public_test.go index 5c31489..de12519 100644 --- a/api/public_test.go +++ b/api/public_test.go @@ -26,7 +26,7 @@ type fakeSubscriber struct { lastUnsubToken string } -func (f *fakeSubscriber) Subscribe(_ context.Context, listName, _, email string) (*domain.User, error) { +func (f *fakeSubscriber) Subscribe(_ context.Context, listName, _, email string, _ []string) (*domain.User, error) { f.lastListName = listName f.lastEmail = email if f.subscribeErr != nil { @@ -60,11 +60,11 @@ func (f *fakeNewsletterPreviewer) RenderNewsletter(_ context.Context, id uint, t } func newTestHandler(sub *fakeSubscriber) *PublicHandler { - return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "Hello"} - h := NewPublicHandler(&fakeSubscriber{}, previewer, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/42", nil) req.SetPathValue("id", "42") w := httptest.NewRecorder() @@ -421,7 +421,7 @@ func TestHandleNewsletterPreview(t *testing.T) { t.Run("passes token to previewer when provided", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{body: "