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 = `

Topic Preferences

+
+{{range .topics}} +
+{{end}} + +
+{{if .saved}}

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: "", err: nil}, RedirectPages{}, slog.Default()) + return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, nil, nil, nil, RedirectPages{}, slog.Default()) } func newTestHandlerWithRedirects(sub *fakeSubscriber, redirects RedirectPages) *PublicHandler { - return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, redirects, slog.Default()) + return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, nil, nil, nil, redirects, slog.Default()) } func TestHandleSubscribe(t *testing.T) { @@ -396,7 +396,7 @@ func TestRedirectPages(t *testing.T) { func TestHandleNewsletterPreview(t *testing.T) { t.Run("returns rendered HTML for valid id without token", func(t *testing.T) { previewer := &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: ""} - h := NewPublicHandler(&fakeSubscriber{}, previewer, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/7?token=abc123", nil) req.SetPathValue("id", "7") w := httptest.NewRecorder() @@ -436,7 +436,7 @@ func TestHandleNewsletterPreview(t *testing.T) { }) t.Run("returns 400 on non-numeric id", func(t *testing.T) { - h := NewPublicHandler(&fakeSubscriber{}, &fakeNewsletterPreviewer{}, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, &fakeNewsletterPreviewer{}, nil, nil, nil, RedirectPages{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/abc", nil) req.SetPathValue("id", "abc") w := httptest.NewRecorder() @@ -449,7 +449,7 @@ func TestHandleNewsletterPreview(t *testing.T) { t.Run("returns 404 when newsletter not found regardless of token", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{err: errors.New("not found")} - h := NewPublicHandler(&fakeSubscriber{}, previewer, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) for _, token := range []string{"", "some-token"} { req := httptest.NewRequest(http.MethodGet, "/mail/99?token="+token, nil) @@ -465,7 +465,7 @@ func TestHandleNewsletterPreview(t *testing.T) { t.Run("preview is routed via Routes()", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{body: "

hi

"} - h := NewPublicHandler(&fakeSubscriber{}, previewer, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/1", nil) w := httptest.NewRecorder() h.Routes().ServeHTTP(w, req) diff --git a/cli/cli.go b/cli/cli.go index 38ce2af..4654704 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -28,7 +28,19 @@ Commands: list delete --name NAME Delete a mailing list list users --name NAME List subscribers + topic list --list NAME List topics for a list + topic create --list NAME --name NAME Create a topic + [--display-name DISPLAY] + [--default-enabled] + [--subscribe-existing] + topic get --list NAME --name NAME Get topic details + topic update --list NAME --name NAME Update a topic + [--display-name DISPLAY] + [--default-enabled BOOL] + topic delete --list NAME --name NAME Delete a topic + send list --list NAME --raw-path PATH Send mail immediately + [--topics T1,T2,...] Target specific topics [--at ISO8601] [--timezone TZ] Schedule instead of sending immediately send test --email EMAIL --raw-path PATH Send a test mail [--name NAME] @@ -75,6 +87,8 @@ func Run(args []string, stdout, stderr io.Writer) int { switch command { case "list": return runList(rest, serverURL, keyPath, stdout, stderr) + case "topic": + return runTopic(rest, serverURL, keyPath, stdout, stderr) case "send": return runSend(rest, serverURL, keyPath, stdout, stderr) case "schedule": @@ -267,7 +281,7 @@ func sendList(args []string, client *api.PrivateClient, stdout, stderr io.Writer listName := flagValue(args, "--list") rawPath := flagValue(args, "--raw-path") if listName == "" || rawPath == "" { - fmt.Fprintln(stderr, "usage: 5kmcli send list --list NAME --raw-path PATH [--at ISO8601] [--timezone TZ] [--data KEY=VALUE ...]") + fmt.Fprintln(stderr, "usage: 5kmcli send list --list NAME --raw-path PATH [--topics T1,T2,...] [--at ISO8601] [--timezone TZ] [--data KEY=VALUE ...]") return 1 } raw, err := os.ReadFile(rawPath) @@ -276,6 +290,8 @@ func sendList(args []string, client *api.PrivateClient, stdout, stderr io.Writer return 1 } + topicNames := collectTopics(args) + atStr := flagValue(args, "--at") if atStr != "" { scheduledAt, err := parseTimestamp(atStr, flagValue(args, "--timezone")) @@ -283,7 +299,7 @@ func sendList(args []string, client *api.PrivateClient, stdout, stderr io.Writer fmt.Fprintf(stderr, "error parsing --at: %v\n", err) return 1 } - m, err := client.ScheduleMail(context.Background(), listName, string(raw), scheduledAt) + m, err := client.ScheduleMail(context.Background(), listName, string(raw), scheduledAt, topicNames) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 @@ -293,7 +309,7 @@ func sendList(args []string, client *api.PrivateClient, stdout, stderr io.Writer } data := collectData(args) - if err := client.SendToList(context.Background(), listName, string(raw), data); err != nil { + if err := client.SendToList(context.Background(), listName, string(raw), topicNames, data); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } @@ -324,6 +340,130 @@ func sendTest(args []string, client *api.PrivateClient, stdout, stderr io.Writer return 0 } +func runTopic(args []string, serverURL, keyPath string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "usage: 5kmcli topic [flags]") + return 1 + } + + client, err := buildClient(serverURL, keyPath) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + sub := args[0] + flags := args[1:] + + switch sub { + case "list": + return topicList(flags, client, stdout, stderr) + case "create": + return topicCreate(flags, client, stdout, stderr) + case "get": + return topicGet(flags, client, stdout, stderr) + case "update": + return topicUpdate(flags, client, stdout, stderr) + case "delete": + return topicDelete(flags, client, stderr) + default: + fmt.Fprintf(stderr, "unknown topic subcommand: %s\n", sub) + return 1 + } +} + +func topicList(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + listName := flagValue(args, "--list") + if listName == "" { + fmt.Fprintln(stderr, "usage: 5kmcli topic list --list NAME") + return 1 + } + topics, err := client.ListTopics(context.Background(), listName) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, topics) + return 0 +} + +func topicCreate(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + listName := flagValue(args, "--list") + name := flagValue(args, "--name") + if listName == "" || name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli topic create --list NAME --name NAME [--display-name DISPLAY] [--default-enabled] [--subscribe-existing]") + return 1 + } + displayName := flagValue(args, "--display-name") + if displayName == "" { + displayName = name + } + defaultEnabled := flagBool(args, "--default-enabled") + subscribeExisting := flagBool(args, "--subscribe-existing") + t, err := client.CreateTopic(context.Background(), listName, name, displayName, defaultEnabled, subscribeExisting) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, t) + return 0 +} + +func topicGet(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + listName := flagValue(args, "--list") + name := flagValue(args, "--name") + if listName == "" || name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli topic get --list NAME --name NAME") + return 1 + } + t, err := client.GetTopic(context.Background(), listName, name) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, t) + return 0 +} + +func topicUpdate(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + listName := flagValue(args, "--list") + name := flagValue(args, "--name") + if listName == "" || name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli topic update --list NAME --name NAME [--display-name DISPLAY] [--default-enabled BOOL]") + return 1 + } + var displayName *string + if dn := flagValue(args, "--display-name"); dn != "" { + displayName = &dn + } + var defaultEnabled *bool + if de := flagValue(args, "--default-enabled"); de != "" { + b := de == "true" + defaultEnabled = &b + } + t, err := client.UpdateTopic(context.Background(), listName, name, displayName, defaultEnabled) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, t) + return 0 +} + +func topicDelete(args []string, client *api.PrivateClient, stderr io.Writer) int { + listName := flagValue(args, "--list") + name := flagValue(args, "--name") + if listName == "" || name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli topic delete --list NAME --name NAME") + return 1 + } + if err := client.DeleteTopic(context.Background(), listName, name); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 +} + func buildClient(serverURL, keyPath string) (*api.PrivateClient, error) { var key ed25519.PrivateKey if keyPath != "" { @@ -383,6 +523,30 @@ func collectData(args []string) map[string]any { return data } +func collectTopics(args []string) []string { + raw := flagValue(args, "--topics") + if raw == "" { + return nil + } + var out []string + for _, t := range strings.Split(raw, ",") { + t = strings.TrimSpace(t) + if t != "" { + out = append(out, t) + } + } + return out +} + +func flagBool(args []string, name string) bool { + for _, a := range args { + if a == name { + return true + } + } + return false +} + func printJSON(w io.Writer, v any) { enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/cli/cli_test.go b/cli/cli_test.go index 4d0aefd..c1bf968 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -104,7 +104,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 nil @@ -134,13 +134,13 @@ func (f *fakeNewsletterArchive) DeleteNewsletter(_ context.Context, _ uint) erro func startTestServer(t *testing.T, lm *fakeListManager, md *fakeMailDispatcher, pub ed25519.PublicKey) *httptest.Server { t.Helper() - h := api.NewPrivateHandler(lm, md, &fakeNewsletterArchive{}, &fakeScheduleManager{}, pub, slog.Default()) + h := api.NewPrivateHandler(lm, md, &fakeNewsletterArchive{}, &fakeScheduleManager{}, nil, pub, slog.Default()) return httptest.NewServer(h.Routes()) } type fakeScheduleManager struct{} -func (f *fakeScheduleManager) Schedule(_ context.Context, list, raw string, at int64) (*domain.ScheduledMail, error) { +func (f *fakeScheduleManager) Schedule(_ context.Context, list, raw string, at int64, _ []string) (*domain.ScheduledMail, error) { return &domain.ScheduledMail{ID: 1, MailingListName: list, RawMarkdown: raw, ScheduledAt: at}, nil } func (f *fakeScheduleManager) List(_ context.Context) ([]domain.ScheduledMail, error) { diff --git a/db/connect.go b/db/connect.go index 49349d8..d62d24a 100644 --- a/db/connect.go +++ b/db/connect.go @@ -32,7 +32,7 @@ func Connect(dbType string, dsn string) (*gorm.DB, error) { } func AutoMigrate(database *gorm.DB) error { - if err := database.AutoMigrate(&MailingList{}, &User{}, &Confirmation{}, &SentNewsletter{}, &ScheduledMail{}); err != nil { + if err := database.AutoMigrate(&MailingList{}, &User{}, &Confirmation{}, &Topic{}, &SentNewsletter{}, &ScheduledMail{}, &ScheduledMailTopic{}); err != nil { return fmt.Errorf("auto-migrating database: %w", err) } return nil diff --git a/db/model.go b/db/model.go index 148af5e..5cfafdd 100644 --- a/db/model.go +++ b/db/model.go @@ -8,8 +8,9 @@ import ( ) type MailingList struct { - Name string `gorm:"primaryKey"` - Users []User `gorm:"foreignKey:MailingListName"` + Name string `gorm:"primaryKey"` + Users []User `gorm:"foreignKey:MailingListName"` + Topics []Topic `gorm:"foreignKey:MailingListName"` } type User struct { @@ -17,8 +18,9 @@ type User struct { Name string `gorm:"not null"` Email string `gorm:"not null;uniqueIndex:idx_user_email_list"` ConfirmedAt *time.Time - MailingListName string `gorm:"not null;uniqueIndex:idx_user_email_list"` - UnsubscribeToken string `gorm:"not null;uniqueIndex"` + MailingListName string `gorm:"not null;uniqueIndex:idx_user_email_list"` + UnsubscribeToken string `gorm:"not null;uniqueIndex"` + Topics []Topic `gorm:"many2many:user_topic_subscriptions;"` } func ToGORMUser(u *domain.User) *User { @@ -56,6 +58,32 @@ func ToDomainList(l *MailingList) *domain.MailingList { } } +type Topic struct { + gorm.Model + Name string `gorm:"not null;uniqueIndex:idx_topic_list_name"` + DisplayName string `gorm:"not null"` + MailingListName string `gorm:"not null;uniqueIndex:idx_topic_list_name"` + DefaultEnabled bool `gorm:"not null;default:false"` +} + +func ToDomainTopic(t *Topic) *domain.Topic { + return &domain.Topic{ + ID: t.ID, + Name: t.Name, + DisplayName: t.DisplayName, + MailingListName: t.MailingListName, + DefaultEnabled: t.DefaultEnabled, + } +} + +func ToDomainTopics(topics []Topic) []domain.Topic { + result := make([]domain.Topic, len(topics)) + for i, t := range topics { + result[i] = *ToDomainTopic(&t) + } + return result +} + type Confirmation struct { gorm.Model UserID uint `gorm:"not null;index"` @@ -84,6 +112,7 @@ type SentNewsletter struct { RawMarkdown string Recipients []User `gorm:"many2many:sent_newsletter_recipients;"` MailingLists []MailingList `gorm:"many2many:sent_newsletter_mailing_lists;"` + Topics []Topic `gorm:"many2many:sent_newsletter_topics;"` } func ToDomainSentNewsletter(n *SentNewsletter) *domain.SentNewsletter { @@ -99,6 +128,7 @@ func ToDomainSentNewsletter(n *SentNewsletter) *domain.SentNewsletter { SentAt: n.CreatedAt, Recipients: ToDomainUsers(n.Recipients), MailingLists: lists, + Topics: ToDomainTopics(n.Topics), } } @@ -110,21 +140,32 @@ func ToDomainSentNewsletters(newsletters []SentNewsletter) []domain.SentNewslett return result } +type ScheduledMailTopic struct { + ScheduledMailID uint `gorm:"primaryKey"` + TopicName string `gorm:"primaryKey"` +} + type ScheduledMail struct { gorm.Model MailingListName string `gorm:"not null;index"` RawMarkdown string `gorm:"not null"` ScheduledAt int64 `gorm:"not null;index"` SentAt *int64 + Topics []ScheduledMailTopic `gorm:"foreignKey:ScheduledMailID"` } func ToDomainScheduledMail(m *ScheduledMail) *domain.ScheduledMail { + topicNames := make([]string, len(m.Topics)) + for i, t := range m.Topics { + topicNames[i] = t.TopicName + } return &domain.ScheduledMail{ ID: m.ID, MailingListName: m.MailingListName, RawMarkdown: m.RawMarkdown, ScheduledAt: m.ScheduledAt, SentAt: m.SentAt, + TopicNames: topicNames, } } diff --git a/db/repository.list.go b/db/repository.list.go index 682d8e8..1664ce7 100644 --- a/db/repository.list.go +++ b/db/repository.list.go @@ -34,6 +34,20 @@ func (r *MailingListRepository) CreateList(ctx context.Context, name string) (*d return nil, fmt.Errorf("create mailing list: %w", result.Error) } + defaultTopic := &Topic{ + Name: "default", + DisplayName: "Default", + MailingListName: name, + DefaultEnabled: true, + } + if err := r.db.WithContext(ctx).Create(defaultTopic).Error; err != nil { + r.logger.ErrorContext(ctx, "failed to create default topic for list", + slog.String("name", name), + slog.Any("error", err), + ) + return nil, fmt.Errorf("create default topic for list %q: %w", name, err) + } + r.logger.InfoContext(ctx, "created mailing list", slog.String("name", name)) return ToDomainList(list), nil } diff --git a/db/repository.newsletter.go b/db/repository.newsletter.go index 271b205..e526cda 100644 --- a/db/repository.newsletter.go +++ b/db/repository.newsletter.go @@ -8,7 +8,7 @@ import ( "github.com/5000K/5000mails/domain" ) -func (r *MailingListRepository) CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*domain.SentNewsletter, error) { +func (r *MailingListRepository) CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string, topicNames []string) (*domain.SentNewsletter, error) { recipients := make([]User, len(recipientIDs)) for i, id := range recipientIDs { recipients[i] = User{} @@ -20,12 +20,21 @@ func (r *MailingListRepository) CreateSentNewsletter(ctx context.Context, subjec mailingLists[i] = MailingList{Name: name} } + var topics []Topic + if len(topicNames) > 0 { + result := r.db.WithContext(ctx).Where("name IN ?", topicNames).Find(&topics) + if result.Error != nil { + return nil, fmt.Errorf("looking up topics for sent newsletter: %w", result.Error) + } + } + record := &SentNewsletter{ Subject: subject, SenderName: senderName, RawMarkdown: rawMarkdown, Recipients: recipients, MailingLists: mailingLists, + Topics: topics, } result := r.db.WithContext(ctx).Create(record) @@ -37,7 +46,7 @@ func (r *MailingListRepository) CreateSentNewsletter(ctx context.Context, subjec return nil, fmt.Errorf("create sent newsletter: %w", result.Error) } - if err := r.db.WithContext(ctx).Preload("Recipients").Preload("MailingLists").First(record, record.ID).Error; err != nil { + if err := r.db.WithContext(ctx).Preload("Recipients").Preload("MailingLists").Preload("Topics").First(record, record.ID).Error; err != nil { return nil, fmt.Errorf("loading sent newsletter associations: %w", err) } @@ -46,7 +55,7 @@ func (r *MailingListRepository) CreateSentNewsletter(ctx context.Context, subjec func (r *MailingListRepository) GetAllSentNewsletters(ctx context.Context) ([]domain.SentNewsletter, error) { var records []SentNewsletter - result := r.db.WithContext(ctx).Preload("MailingLists").Find(&records) + result := r.db.WithContext(ctx).Preload("MailingLists").Preload("Topics").Find(&records) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to get sent newsletters", slog.Any("error", result.Error)) return nil, fmt.Errorf("get all sent newsletters: %w", result.Error) @@ -56,7 +65,7 @@ func (r *MailingListRepository) GetAllSentNewsletters(ctx context.Context) ([]do func (r *MailingListRepository) GetSentNewsletterByID(ctx context.Context, id uint, withRecipients bool) (*domain.SentNewsletter, error) { var record SentNewsletter - q := r.db.WithContext(ctx).Preload("MailingLists") + q := r.db.WithContext(ctx).Preload("MailingLists").Preload("Topics") if withRecipients { q = q.Preload("Recipients") } @@ -74,7 +83,7 @@ func (r *MailingListRepository) GetSentNewsletterByID(ctx context.Context, id ui func (r *MailingListRepository) DeleteSentNewsletter(ctx context.Context, id uint) error { record := &SentNewsletter{} record.ID = id - if err := r.db.WithContext(ctx).Select("Recipients", "MailingLists").Delete(record).Error; err != nil { + if err := r.db.WithContext(ctx).Select("Recipients", "MailingLists", "Topics").Delete(record).Error; err != nil { r.logger.ErrorContext(ctx, "failed to delete sent newsletter", slog.Uint64("id", uint64(id)), slog.Any("error", err), diff --git a/db/repository.scheduled.go b/db/repository.scheduled.go index 23a53dd..046069e 100644 --- a/db/repository.scheduled.go +++ b/db/repository.scheduled.go @@ -8,11 +8,16 @@ import ( "github.com/5000K/5000mails/domain" ) -func (r *MailingListRepository) CreateScheduledMail(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) { +func (r *MailingListRepository) CreateScheduledMail(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64, topicNames []string) (*domain.ScheduledMail, error) { + topics := make([]ScheduledMailTopic, len(topicNames)) + for i, name := range topicNames { + topics[i] = ScheduledMailTopic{TopicName: name} + } m := &ScheduledMail{ MailingListName: mailingListName, RawMarkdown: rawMarkdown, ScheduledAt: scheduledAt, + Topics: topics, } result := r.db.WithContext(ctx).Create(m) if result.Error != nil { @@ -23,7 +28,7 @@ func (r *MailingListRepository) CreateScheduledMail(ctx context.Context, mailing func (r *MailingListRepository) GetAllScheduledMails(ctx context.Context) ([]domain.ScheduledMail, error) { var mails []ScheduledMail - result := r.db.WithContext(ctx).Order("scheduled_at asc").Find(&mails) + result := r.db.WithContext(ctx).Preload("Topics").Order("scheduled_at asc").Find(&mails) if result.Error != nil { return nil, fmt.Errorf("listing scheduled mails: %w", result.Error) } @@ -32,7 +37,7 @@ func (r *MailingListRepository) GetAllScheduledMails(ctx context.Context) ([]dom func (r *MailingListRepository) GetScheduledMailByID(ctx context.Context, id uint) (*domain.ScheduledMail, error) { var m ScheduledMail - result := r.db.WithContext(ctx).First(&m, id) + result := r.db.WithContext(ctx).Preload("Topics").First(&m, id) if result.Error != nil { return nil, fmt.Errorf("getting scheduled mail %d: %w", id, result.Error) } @@ -42,6 +47,7 @@ func (r *MailingListRepository) GetScheduledMailByID(ctx context.Context, id uin func (r *MailingListRepository) GetPendingScheduledMails(ctx context.Context, now int64) ([]domain.ScheduledMail, error) { var mails []ScheduledMail result := r.db.WithContext(ctx). + Preload("Topics"). Where("scheduled_at <= ? AND sent_at IS NULL", now). Order("scheduled_at asc"). Find(&mails) diff --git a/db/repository.topic.go b/db/repository.topic.go new file mode 100644 index 0000000..e96eb43 --- /dev/null +++ b/db/repository.topic.go @@ -0,0 +1,184 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + + "github.com/5000K/5000mails/domain" +) + +func (r *MailingListRepository) CreateTopic(ctx context.Context, mailingListName, name, displayName string, defaultEnabled bool) (*domain.Topic, error) { + t := &Topic{ + Name: name, + DisplayName: displayName, + MailingListName: mailingListName, + DefaultEnabled: defaultEnabled, + } + result := r.db.WithContext(ctx).Create(t) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to create topic", + slog.String("mailing_list_name", mailingListName), + slog.String("name", name), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("create topic %q on list %q: %w", name, mailingListName, result.Error) + } + return ToDomainTopic(t), nil +} + +func (r *MailingListRepository) GetTopicsByList(ctx context.Context, mailingListName string) ([]domain.Topic, error) { + var topics []Topic + result := r.db.WithContext(ctx).Where("mailing_list_name = ?", mailingListName).Find(&topics) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get topics", + slog.String("mailing_list_name", mailingListName), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get topics for list %q: %w", mailingListName, result.Error) + } + return ToDomainTopics(topics), nil +} + +func (r *MailingListRepository) GetTopicByName(ctx context.Context, mailingListName, name string) (*domain.Topic, error) { + var t Topic + result := r.db.WithContext(ctx).Where("mailing_list_name = ? AND name = ?", mailingListName, name).First(&t) + if result.Error != nil { + return nil, fmt.Errorf("get topic %q on list %q: %w", name, mailingListName, result.Error) + } + return ToDomainTopic(&t), nil +} + +func (r *MailingListRepository) UpdateTopic(ctx context.Context, mailingListName, name string, displayName *string, defaultEnabled *bool) (*domain.Topic, error) { + updates := map[string]any{} + if displayName != nil { + updates["display_name"] = *displayName + } + if defaultEnabled != nil { + updates["default_enabled"] = *defaultEnabled + } + if len(updates) == 0 { + return r.GetTopicByName(ctx, mailingListName, name) + } + + result := r.db.WithContext(ctx). + Model(&Topic{}). + Where("mailing_list_name = ? AND name = ?", mailingListName, name). + Updates(updates) + if result.Error != nil { + return nil, fmt.Errorf("update topic %q on list %q: %w", name, mailingListName, result.Error) + } + if result.RowsAffected == 0 { + return nil, fmt.Errorf("update topic %q on list %q: not found", name, mailingListName) + } + return r.GetTopicByName(ctx, mailingListName, name) +} + +func (r *MailingListRepository) DeleteTopic(ctx context.Context, mailingListName, name string) error { + result := r.db.WithContext(ctx). + Where("mailing_list_name = ? AND name = ?", mailingListName, name). + Delete(&Topic{}) + if result.Error != nil { + return fmt.Errorf("delete topic %q on list %q: %w", name, mailingListName, result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("delete topic %q on list %q: not found", name, mailingListName) + } + return nil +} + +func (r *MailingListRepository) GetDefaultEnabledTopics(ctx context.Context, mailingListName string) ([]domain.Topic, error) { + var topics []Topic + result := r.db.WithContext(ctx). + Where("mailing_list_name = ? AND default_enabled = ?", mailingListName, true). + Find(&topics) + if result.Error != nil { + return nil, fmt.Errorf("get default-enabled topics for list %q: %w", mailingListName, result.Error) + } + return ToDomainTopics(topics), nil +} + +func (r *MailingListRepository) SubscribeUserToTopics(ctx context.Context, userID uint, topicIDs []uint) error { + user := &User{} + user.ID = userID + topics := make([]Topic, len(topicIDs)) + for i, id := range topicIDs { + topics[i].ID = id + } + if err := r.db.WithContext(ctx).Model(user).Association("Topics").Append(topics); err != nil { + return fmt.Errorf("subscribing user %d to topics: %w", userID, err) + } + return nil +} + +func (r *MailingListRepository) UnsubscribeUserFromTopics(ctx context.Context, userID uint, topicIDs []uint) error { + user := &User{} + user.ID = userID + topics := make([]Topic, len(topicIDs)) + for i, id := range topicIDs { + topics[i].ID = id + } + if err := r.db.WithContext(ctx).Model(user).Association("Topics").Delete(topics); err != nil { + return fmt.Errorf("unsubscribing user %d from topics: %w", userID, err) + } + return nil +} + +func (r *MailingListRepository) SetUserTopics(ctx context.Context, userID uint, topicIDs []uint) error { + user := &User{} + user.ID = userID + topics := make([]Topic, len(topicIDs)) + for i, id := range topicIDs { + topics[i].ID = id + } + if err := r.db.WithContext(ctx).Model(user).Association("Topics").Replace(topics); err != nil { + return fmt.Errorf("setting topics for user %d: %w", userID, err) + } + return nil +} + +func (r *MailingListRepository) GetUserTopics(ctx context.Context, userID uint) ([]domain.Topic, error) { + var topics []Topic + user := &User{} + user.ID = userID + if err := r.db.WithContext(ctx).Model(user).Association("Topics").Find(&topics); err != nil { + return nil, fmt.Errorf("get topics for user %d: %w", userID, err) + } + return ToDomainTopics(topics), nil +} + +func (r *MailingListRepository) GetConfirmedUsersSubscribedToTopics(ctx context.Context, mailingListName string, topicNames []string) ([]domain.User, error) { + var users []User + result := r.db.WithContext(ctx). + Distinct(). + Joins("JOIN user_topic_subscriptions ON user_topic_subscriptions.user_id = users.id"). + Joins("JOIN topics ON topics.id = user_topic_subscriptions.topic_id AND topics.deleted_at IS NULL"). + Where("users.mailing_list_name = ? AND users.confirmed_at IS NOT NULL AND users.deleted_at IS NULL", mailingListName). + Where("topics.mailing_list_name = ? AND topics.name IN ?", mailingListName, topicNames). + Find(&users) + if result.Error != nil { + return nil, fmt.Errorf("get users subscribed to topics on list %q: %w", mailingListName, result.Error) + } + return ToDomainUsers(users), nil +} + +func (r *MailingListRepository) SubscribeAllUsersToTopic(ctx context.Context, mailingListName string, topicID uint) error { + var users []User + result := r.db.WithContext(ctx).Where("mailing_list_name = ?", mailingListName).Find(&users) + if result.Error != nil { + return fmt.Errorf("listing users for mass topic subscribe on list %q: %w", mailingListName, result.Error) + } + topic := &Topic{} + topic.ID = topicID + for _, u := range users { + if err := r.db.WithContext(ctx).Model(&u).Association("Topics").Append(topic); err != nil { + r.logger.ErrorContext(ctx, "failed to subscribe user to topic", + slog.Uint64("user_id", uint64(u.ID)), + slog.Uint64("topic_id", uint64(topicID)), + slog.Any("error", err), + ) + return fmt.Errorf("subscribing user %d to topic %d: %w", u.ID, topicID, err) + } + } + return nil +} diff --git a/domain/model.go b/domain/model.go index 059d845..0f11bc5 100644 --- a/domain/model.go +++ b/domain/model.go @@ -2,12 +2,10 @@ package domain import "time" -// MailingList represents a named list that users can subscribe to. type MailingList struct { Name string } -// User represents a subscriber on a mailing list. type User struct { ID uint Name string @@ -17,7 +15,6 @@ type User struct { UnsubscribeToken string } -// IsConfirmed returns true if the user has completed double opt-in. func (u *User) IsConfirmed() bool { return u.ConfirmedAt != nil } @@ -27,20 +24,25 @@ type MailMetadata struct { SenderName string } -// Confirmation holds a pending double opt-in token for a user. type Confirmation struct { ID uint UserID uint Token string } -// UserCounts holds subscriber totals for a mailing list. type UserCounts struct { Total int Confirmed int } -// SentNewsletter is an archived record of a dispatched newsletter. +type Topic struct { + ID uint + Name string + DisplayName string + MailingListName string + DefaultEnabled bool +} + type SentNewsletter struct { ID uint Subject string @@ -49,14 +51,14 @@ type SentNewsletter struct { SentAt time.Time Recipients []User MailingLists []MailingList + Topics []Topic } -// ScheduledMail is a pending newsletter queued for future delivery. -// ScheduledAt and SentAt are unix timestamps (UTC). type ScheduledMail struct { ID uint MailingListName string RawMarkdown string ScheduledAt int64 SentAt *int64 + TopicNames []string } diff --git a/domain/ports.go b/domain/ports.go index 42a142b..b3dd833 100644 --- a/domain/ports.go +++ b/domain/ports.go @@ -25,8 +25,23 @@ type ConfirmationRepository interface { DeleteConfirmation(ctx context.Context, id uint) error } +type TopicRepository interface { + CreateTopic(ctx context.Context, mailingListName, name, displayName string, defaultEnabled bool) (*Topic, error) + GetTopicsByList(ctx context.Context, mailingListName string) ([]Topic, error) + GetTopicByName(ctx context.Context, mailingListName, name string) (*Topic, error) + UpdateTopic(ctx context.Context, mailingListName, name string, displayName *string, defaultEnabled *bool) (*Topic, error) + DeleteTopic(ctx context.Context, mailingListName, name string) error + GetDefaultEnabledTopics(ctx context.Context, mailingListName string) ([]Topic, error) + SubscribeUserToTopics(ctx context.Context, userID uint, topicIDs []uint) error + UnsubscribeUserFromTopics(ctx context.Context, userID uint, topicIDs []uint) error + SetUserTopics(ctx context.Context, userID uint, topicIDs []uint) error + GetUserTopics(ctx context.Context, userID uint) ([]Topic, error) + GetConfirmedUsersSubscribedToTopics(ctx context.Context, mailingListName string, topicNames []string) ([]User, error) + SubscribeAllUsersToTopic(ctx context.Context, mailingListName string, topicID uint) error +} + type SentNewsletterRepository interface { - CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*SentNewsletter, error) + CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string, topicNames []string) (*SentNewsletter, error) GetAllSentNewsletters(ctx context.Context) ([]SentNewsletter, error) GetSentNewsletterByID(ctx context.Context, id uint, withRecipients bool) (*SentNewsletter, error) DeleteSentNewsletter(ctx context.Context, id uint) error @@ -34,6 +49,7 @@ type SentNewsletterRepository interface { type Renderer interface { Render(raw *string, data map[string]any) (metadata MailMetadata, body string, err error) + RenderHTML(html string, data map[string]any) (string, error) } type Sender interface { @@ -41,7 +57,7 @@ type Sender interface { } type ScheduledMailRepository interface { - CreateScheduledMail(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*ScheduledMail, error) + CreateScheduledMail(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64, topicNames []string) (*ScheduledMail, error) GetAllScheduledMails(ctx context.Context) ([]ScheduledMail, error) GetScheduledMailByID(ctx context.Context, id uint) (*ScheduledMail, error) GetPendingScheduledMails(ctx context.Context, now int64) ([]ScheduledMail, error) diff --git a/main.go b/main.go index b0e07f0..718c728 100644 --- a/main.go +++ b/main.go @@ -65,13 +65,14 @@ func main() { os.Exit(1) } - subscriptionSvc := service.NewSubscriptionService(repo, repo, repo, rndr, sender, string(confirmRaw), cfg.BaseURL) + subscriptionSvc := service.NewSubscriptionService(repo, repo, repo, repo, rndr, sender, string(confirmRaw), cfg.BaseURL) listSvc := service.NewListService(repo, repo) - mailSvc := service.NewMailService(repo, repo, repo, rndr, sender, cfg.BaseURL) + mailSvc := service.NewMailService(repo, repo, repo, repo, rndr, sender, cfg.BaseURL) + topicSvc := service.NewTopicService(repo, repo) schedulingSvc := service.NewSchedulingService(repo, mailSvc, 30*time.Second, logger) schedulingSvc.Start() - publicHandler := api.NewPublicHandler(subscriptionSvc, mailSvc, api.RedirectPages{ + publicHandler := api.NewPublicHandler(subscriptionSvc, mailSvc, topicSvc, repo, rndr, api.RedirectPages{ SubscribeSuccess: cfg.Redirects.SubscribeSuccess, SubscribeError: cfg.Redirects.SubscribeError, ConfirmSuccess: cfg.Redirects.ConfirmSuccess, @@ -92,7 +93,7 @@ func main() { logger.Warn("private API authentication disabled - no public key configured") } - privateHandler := api.NewPrivateHandler(listSvc, mailSvc, mailSvc, schedulingSvc, publicKey, logger) + privateHandler := api.NewPrivateHandler(listSvc, mailSvc, mailSvc, schedulingSvc, topicSvc, publicKey, logger) publicServer := &http.Server{Addr: cfg.PublicAddr, Handler: publicHandler.Routes()} privateServer := &http.Server{Addr: cfg.PrivateAddr, Handler: privateHandler.Routes()} diff --git a/renderer/goldmark.go b/renderer/goldmark.go index 3cbb362..30196e3 100644 --- a/renderer/goldmark.go +++ b/renderer/goldmark.go @@ -70,6 +70,24 @@ func (r *GoldmarkRenderer) Render(raw *string, data map[string]any) (domain.Mail return metadata, finalBuf.String(), nil } +func (r *GoldmarkRenderer) RenderHTML(html string, data map[string]any) (string, error) { + templated, err := applyTemplate("html-content", html, data) + if err != nil { + return "", fmt.Errorf("templating html content: %w", err) + } + + layoutData := mergeData(data, map[string]any{ + "html": templated, + "metadata": domain.MailMetadata{}, + }) + + var buf bytes.Buffer + if err := r.tmpl.Execute(&buf, layoutData); err != nil { + return "", fmt.Errorf("executing layout template for html: %w", err) + } + return buf.String(), nil +} + func applyTemplate(name, text string, data map[string]any) (string, error) { t, err := template.New(name).Parse(text) if err != nil { diff --git a/service/fakes_test.go b/service/fakes_test.go index cc7d231..9df6494 100644 --- a/service/fakes_test.go +++ b/service/fakes_test.go @@ -8,7 +8,6 @@ import ( "github.com/5000K/5000mails/domain" ) -// fakeListRepo is an in-memory MailingListRepository. type fakeListRepo struct { lists map[string]*domain.MailingList @@ -83,7 +82,6 @@ func (r *fakeListRepo) DeleteList(_ context.Context, name string) error { return nil } -// fakeUserRepo is an in-memory UserRepository. type fakeUserRepo struct { users map[uint]*domain.User nextID uint @@ -179,7 +177,6 @@ func (r *fakeUserRepo) RemoveUser(_ context.Context, userID uint) error { return nil } -// fakeConfirmationRepo is an in-memory ConfirmationRepository. type fakeConfirmationRepo struct { confirmations map[uint]*domain.Confirmation nextID uint @@ -233,7 +230,187 @@ func (r *fakeConfirmationRepo) DeleteConfirmation(_ context.Context, id uint) er return nil } -// fakeNewsletterRepo is an in-memory SentNewsletterRepository. +type fakeTopicRepo struct { + topics map[uint]*domain.Topic + userTopics map[uint]map[uint]bool + nextID uint + + createErr error + getByListErr error + getByNameErr error + updateErr error + deleteErr error + getDefaultErr error + subscribeUserErr error + unsubscribeUserErr error + setUserTopicsErr error + getUserTopicsErr error + getConfirmedUsersErr error + subscribeAllErr error +} + +func newFakeTopicRepo(seed ...*domain.Topic) *fakeTopicRepo { + r := &fakeTopicRepo{ + topics: make(map[uint]*domain.Topic), + userTopics: make(map[uint]map[uint]bool), + nextID: 1, + } + for _, t := range seed { + if t.ID == 0 { + t.ID = r.nextID + r.nextID++ + } + r.topics[t.ID] = t + if t.ID >= r.nextID { + r.nextID = t.ID + 1 + } + } + return r +} + +func (r *fakeTopicRepo) CreateTopic(_ context.Context, mailingListName, name, displayName string, defaultEnabled bool) (*domain.Topic, error) { + if r.createErr != nil { + return nil, r.createErr + } + t := &domain.Topic{ID: r.nextID, Name: name, DisplayName: displayName, MailingListName: mailingListName, DefaultEnabled: defaultEnabled} + r.nextID++ + r.topics[t.ID] = t + return t, nil +} + +func (r *fakeTopicRepo) GetTopicsByList(_ context.Context, mailingListName string) ([]domain.Topic, error) { + if r.getByListErr != nil { + return nil, r.getByListErr + } + var out []domain.Topic + for _, t := range r.topics { + if t.MailingListName == mailingListName { + out = append(out, *t) + } + } + return out, nil +} + +func (r *fakeTopicRepo) GetTopicByName(_ context.Context, mailingListName, name string) (*domain.Topic, error) { + if r.getByNameErr != nil { + return nil, r.getByNameErr + } + for _, t := range r.topics { + if t.MailingListName == mailingListName && t.Name == name { + return t, nil + } + } + return nil, fmt.Errorf("topic %q on list %q not found", name, mailingListName) +} + +func (r *fakeTopicRepo) UpdateTopic(_ context.Context, mailingListName, name string, displayName *string, defaultEnabled *bool) (*domain.Topic, error) { + if r.updateErr != nil { + return nil, r.updateErr + } + for _, t := range r.topics { + if t.MailingListName == mailingListName && t.Name == name { + if displayName != nil { + t.DisplayName = *displayName + } + if defaultEnabled != nil { + t.DefaultEnabled = *defaultEnabled + } + return t, nil + } + } + return nil, fmt.Errorf("topic %q on list %q not found", name, mailingListName) +} + +func (r *fakeTopicRepo) DeleteTopic(_ context.Context, mailingListName, name string) error { + if r.deleteErr != nil { + return r.deleteErr + } + for id, t := range r.topics { + if t.MailingListName == mailingListName && t.Name == name { + delete(r.topics, id) + return nil + } + } + return fmt.Errorf("topic %q on list %q not found", name, mailingListName) +} + +func (r *fakeTopicRepo) GetDefaultEnabledTopics(_ context.Context, mailingListName string) ([]domain.Topic, error) { + if r.getDefaultErr != nil { + return nil, r.getDefaultErr + } + var out []domain.Topic + for _, t := range r.topics { + if t.MailingListName == mailingListName && t.DefaultEnabled { + out = append(out, *t) + } + } + return out, nil +} + +func (r *fakeTopicRepo) SubscribeUserToTopics(_ context.Context, userID uint, topicIDs []uint) error { + if r.subscribeUserErr != nil { + return r.subscribeUserErr + } + if r.userTopics[userID] == nil { + r.userTopics[userID] = make(map[uint]bool) + } + for _, id := range topicIDs { + r.userTopics[userID][id] = true + } + return nil +} + +func (r *fakeTopicRepo) UnsubscribeUserFromTopics(_ context.Context, userID uint, topicIDs []uint) error { + if r.unsubscribeUserErr != nil { + return r.unsubscribeUserErr + } + if r.userTopics[userID] == nil { + return nil + } + for _, id := range topicIDs { + delete(r.userTopics[userID], id) + } + return nil +} + +func (r *fakeTopicRepo) SetUserTopics(_ context.Context, userID uint, topicIDs []uint) error { + if r.setUserTopicsErr != nil { + return r.setUserTopicsErr + } + r.userTopics[userID] = make(map[uint]bool) + for _, id := range topicIDs { + r.userTopics[userID][id] = true + } + return nil +} + +func (r *fakeTopicRepo) GetUserTopics(_ context.Context, userID uint) ([]domain.Topic, error) { + if r.getUserTopicsErr != nil { + return nil, r.getUserTopicsErr + } + var out []domain.Topic + for id := range r.userTopics[userID] { + if t, ok := r.topics[id]; ok { + out = append(out, *t) + } + } + return out, nil +} + +func (r *fakeTopicRepo) GetConfirmedUsersSubscribedToTopics(_ context.Context, _ string, _ []string) ([]domain.User, error) { + if r.getConfirmedUsersErr != nil { + return nil, r.getConfirmedUsersErr + } + return nil, nil +} + +func (r *fakeTopicRepo) SubscribeAllUsersToTopic(_ context.Context, _ string, _ uint) error { + if r.subscribeAllErr != nil { + return r.subscribeAllErr + } + return nil +} + type fakeNewsletterRepo struct { newsletters map[uint]*domain.SentNewsletter nextID uint @@ -255,7 +432,7 @@ func newFakeNewsletterRepo(seed ...*domain.SentNewsletter) *fakeNewsletterRepo { return r } -func (r *fakeNewsletterRepo) CreateSentNewsletter(_ context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*domain.SentNewsletter, error) { +func (r *fakeNewsletterRepo) CreateSentNewsletter(_ context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string, topicNames []string) (*domain.SentNewsletter, error) { if r.createErr != nil { return nil, r.createErr } @@ -267,6 +444,10 @@ func (r *fakeNewsletterRepo) CreateSentNewsletter(_ context.Context, subject, se for i, name := range listNames { lists[i] = domain.MailingList{Name: name} } + topics := make([]domain.Topic, len(topicNames)) + for i, name := range topicNames { + topics[i] = domain.Topic{Name: name} + } n := &domain.SentNewsletter{ ID: r.nextID, Subject: subject, @@ -275,6 +456,7 @@ func (r *fakeNewsletterRepo) CreateSentNewsletter(_ context.Context, subject, se SentAt: time.Now(), Recipients: recipients, MailingLists: lists, + Topics: topics, } r.nextID++ r.newsletters[n.ID] = n @@ -314,7 +496,6 @@ func (r *fakeNewsletterRepo) DeleteSentNewsletter(_ context.Context, id uint) er return nil } -// fakeSender records SendMail calls. type fakeSender struct { calls []sendCall err error @@ -334,15 +515,28 @@ func (s *fakeSender) SendMail(_ context.Context, metadata domain.MailMetadata, b return nil } -// fakeRenderer returns configurable metadata / body. type fakeRenderer struct { metadata domain.MailMetadata body string err error lastData map[string]any + + htmlBody string + htmlErr error } func (r *fakeRenderer) Render(_ *string, data map[string]any) (domain.MailMetadata, string, error) { r.lastData = data return r.metadata, r.body, r.err } + +func (r *fakeRenderer) RenderHTML(_ string, data map[string]any) (string, error) { + r.lastData = data + if r.htmlErr != nil { + return "", r.htmlErr + } + if r.htmlBody != "" { + return r.htmlBody, nil + } + return r.body, nil +} diff --git a/service/mail.go b/service/mail.go index dc22629..b0d7c11 100644 --- a/service/mail.go +++ b/service/mail.go @@ -7,22 +7,21 @@ import ( "github.com/5000K/5000mails/domain" ) -// MailService renders markdown content and dispatches it to mailing list -// recipients or arbitrary test addresses. type MailService struct { lists domain.MailingListRepository users domain.UserRepository + topics domain.TopicRepository newsletters domain.SentNewsletterRepository renderer domain.Renderer sender domain.Sender baseURL string } -// NewMailService creates a new MailService. -func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, newsletters domain.SentNewsletterRepository, renderer domain.Renderer, sender domain.Sender, baseURL string) *MailService { +func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, topics domain.TopicRepository, newsletters domain.SentNewsletterRepository, renderer domain.Renderer, sender domain.Sender, baseURL string) *MailService { return &MailService{ lists: lists, users: users, + topics: topics, newsletters: newsletters, renderer: renderer, sender: sender, @@ -30,18 +29,20 @@ func NewMailService(lists domain.MailingListRepository, users domain.UserReposit } } -// SendToList renders raw and sends the resulting mail to every confirmed -// subscriber of the mailing list identified by listName. -// data is passed through to the renderer as template variables. -func (s *MailService) SendToList(ctx context.Context, listName string, raw string, data map[string]any) error { +func (s *MailService) SendToList(ctx context.Context, listName string, raw string, topicNames []string, data map[string]any) error { list, err := s.lists.GetListByName(ctx, listName) if err != nil { return fmt.Errorf("looking up list %q: %w", listName, err) } - recipients, err := s.users.GetConfirmedUsers(ctx, list.Name) + var recipients []domain.User + if len(topicNames) > 0 { + recipients, err = s.topics.GetConfirmedUsersSubscribedToTopics(ctx, list.Name, topicNames) + } else { + recipients, err = s.users.GetConfirmedUsers(ctx, list.Name) + } if err != nil { - return fmt.Errorf("getting confirmed users for list %q: %w", listName, err) + return fmt.Errorf("getting recipients for list %q: %w", listName, err) } if len(recipients) == 0 { @@ -52,12 +53,13 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin recipientIDs := make([]uint, 0, len(recipients)) for i, recipient := range recipients { - recipientData := make(map[string]any, len(data)+2) + recipientData := make(map[string]any, len(data)+3) for k, v := range data { recipientData[k] = v } recipientData["Recipient"] = recipient recipientData["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken + recipientData["preferencesURL"] = s.baseURL + "/preferences/" + listName + "/" + recipient.UnsubscribeToken metadata, body, err := s.renderer.Render(&raw, recipientData) if err != nil { @@ -73,24 +75,21 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin recipientIDs = append(recipientIDs, recipient.ID) } - if _, err := s.newsletters.CreateSentNewsletter(ctx, firstMetadata.Subject, firstMetadata.SenderName, raw, recipientIDs, []string{listName}); err != nil { + if _, err := s.newsletters.CreateSentNewsletter(ctx, firstMetadata.Subject, firstMetadata.SenderName, raw, recipientIDs, []string{listName}, topicNames); err != nil { return fmt.Errorf("archiving sent newsletter: %w", err) } return nil } -// SendTestMail renders raw and sends the resulting mail to the given user. -// The user is passed in directly and is not looked up from the database, -// making this suitable for previewing a newsletter before a real dispatch. -// data is passed through to the renderer as template variables. func (s *MailService) SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error { - recipientData := make(map[string]any, len(data)+2) + recipientData := make(map[string]any, len(data)+3) for k, v := range data { recipientData[k] = v } recipientData["Recipient"] = recipient recipientData["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken + recipientData["preferencesURL"] = s.baseURL + "/preferences/" + recipient.MailingListName + "/" + recipient.UnsubscribeToken metadata, body, err := s.renderer.Render(&raw, recipientData) if err != nil { @@ -104,7 +103,6 @@ func (s *MailService) SendTestMail(ctx context.Context, recipient domain.User, r return nil } -// AllNewsletters returns all archived sent newsletters. func (s *MailService) AllNewsletters(ctx context.Context) ([]domain.SentNewsletter, error) { newsletters, err := s.newsletters.GetAllSentNewsletters(ctx) if err != nil { @@ -113,7 +111,6 @@ func (s *MailService) AllNewsletters(ctx context.Context) ([]domain.SentNewslett return newsletters, nil } -// GetNewsletter returns a single archived newsletter by ID including recipients and mailing lists. func (s *MailService) GetNewsletter(ctx context.Context, id uint) (*domain.SentNewsletter, error) { newsletter, err := s.newsletters.GetSentNewsletterByID(ctx, id, true) if err != nil { @@ -122,7 +119,6 @@ func (s *MailService) GetNewsletter(ctx context.Context, id uint) (*domain.SentN return newsletter, nil } -// DeleteNewsletter removes an archived newsletter by ID. func (s *MailService) DeleteNewsletter(ctx context.Context, id uint) error { if err := s.newsletters.DeleteSentNewsletter(ctx, id); err != nil { return fmt.Errorf("deleting sent newsletter %d: %w", id, err) @@ -132,9 +128,6 @@ func (s *MailService) DeleteNewsletter(ctx context.Context, id uint) error { var placeholderUser = domain.User{Name: "Subscriber", Email: "you@example.com"} -// RenderNewsletter renders a sent newsletter for a given unsubscribe token. -// If the token is empty or unknown the render proceeds with a placeholder user, -// so the response is identical in both cases and the token is not enumerable. func (s *MailService) RenderNewsletter(ctx context.Context, id uint, unsubscribeToken string) (string, error) { newsletter, err := s.newsletters.GetSentNewsletterByID(ctx, id, false) if err != nil { @@ -148,9 +141,15 @@ func (s *MailService) RenderNewsletter(ctx context.Context, id uint, unsubscribe } } + listName := recipient.MailingListName + if listName == "" && len(newsletter.MailingLists) > 0 { + listName = newsletter.MailingLists[0].Name + } + data := map[string]any{ "Recipient": recipient, "unsubscribeURL": s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken, + "preferencesURL": s.baseURL + "/preferences/" + listName + "/" + recipient.UnsubscribeToken, } _, body, err := s.renderer.Render(&newsletter.RawMarkdown, data) diff --git a/service/mail_test.go b/service/mail_test.go index 1e0cc3c..b357734 100644 --- a/service/mail_test.go +++ b/service/mail_test.go @@ -23,12 +23,13 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(), + newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "body"}, sender, "https://example.com", ) - if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { + if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } if len(sender.calls) != 0 { @@ -42,9 +43,9 @@ func TestMailService_SendToList(t *testing.T) { confirmedUser(2, "weekly", "bob@example.com"), ) sender := &fakeSender{} - svc := NewMailService(newFakeListRepo(list), users, newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "rendered"}, sender, "https://example.com") + svc := NewMailService(newFakeListRepo(list), users, newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "rendered"}, sender, "https://example.com") - if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { + if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } if len(sender.calls) != 2 { @@ -68,9 +69,9 @@ func TestMailService_SendToList(t *testing.T) { t.Run("injects Recipient into render data per recipient", func(t *testing.T) { user := confirmedUser(1, "weekly", "alice@example.com") renderer := &fakeRenderer{metadata: metadata, body: "body"} - svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeTopicRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") - if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { + if err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } got, ok := renderer.lastData["Recipient"] @@ -86,9 +87,9 @@ func TestMailService_SendToList(t *testing.T) { user := confirmedUser(1, "weekly", "alice@example.com") user.UnsubscribeToken = "unsub-tok" renderer := &fakeRenderer{metadata: metadata, body: "body"} - svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeTopicRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") - if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { + if err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } wantURL := "https://example.com/unsubscribe/unsub-tok" @@ -100,8 +101,8 @@ func TestMailService_SendToList(t *testing.T) { t.Run("wraps GetListByName error", func(t *testing.T) { listRepo := newFakeListRepo() listRepo.getByNameErr = errors.New("list missing") - svc := NewMailService(listRepo, newFakeUserRepo(), newFakeNewsletterRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") - err := svc.SendToList(context.Background(), "ghost", "raw", nil) + svc := NewMailService(listRepo, newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") + err := svc.SendToList(context.Background(), "ghost", "raw", nil, nil) if !errors.Is(err, listRepo.getByNameErr) { t.Errorf("expected wrapped error, got: %v", err) } @@ -110,8 +111,8 @@ func TestMailService_SendToList(t *testing.T) { t.Run("wraps GetConfirmedUsers error", func(t *testing.T) { userRepo := newFakeUserRepo() userRepo.getConfirmedErr = errors.New("db down") - svc := NewMailService(newFakeListRepo(list), userRepo, newFakeNewsletterRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") - err := svc.SendToList(context.Background(), "weekly", "raw", nil) + svc := NewMailService(newFakeListRepo(list), userRepo, newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") + err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil) if !errors.Is(err, userRepo.getConfirmedErr) { t.Errorf("expected wrapped error, got: %v", err) } @@ -122,12 +123,13 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), + newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com", ) - err := svc.SendToList(context.Background(), "weekly", "raw", nil) + err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil) if !errors.Is(err, renderErr) { t.Errorf("expected wrapped render error, got: %v", err) } @@ -138,12 +140,13 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), + newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, "https://example.com", ) - err := svc.SendToList(context.Background(), "weekly", "raw", nil) + err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil) if !errors.Is(err, sendErr) { t.Errorf("expected wrapped send error, got: %v", err) } @@ -155,9 +158,9 @@ func TestMailService_SendToList(t *testing.T) { confirmedUser(2, "weekly", "bob@example.com"), ) newsletterRepo := newFakeNewsletterRepo() - svc := NewMailService(newFakeListRepo(list), users, newsletterRepo, &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(list), users, newFakeTopicRepo(), newsletterRepo, &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{}, "https://example.com") - if err := svc.SendToList(context.Background(), "weekly", "# Raw", nil); err != nil { + if err := svc.SendToList(context.Background(), "weekly", "# Raw", nil, nil); err != nil { t.Fatalf("unexpected error: %v", err) } if len(newsletterRepo.newsletters) != 1 { @@ -186,12 +189,13 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), + newFakeTopicRepo(), newsletterRepo, &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{}, "https://example.com", ) - err := svc.SendToList(context.Background(), "weekly", "raw", nil) + err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil) if !errors.Is(err, archiveErr) { t.Errorf("expected wrapped archive error, got: %v", err) } @@ -204,7 +208,7 @@ func TestMailService_SendTestMail(t *testing.T) { t.Run("renders and sends to given recipient", func(t *testing.T) { sender := &fakeSender{} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "preview"}, sender, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "preview"}, sender, "https://example.com") if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -223,7 +227,7 @@ func TestMailService_SendTestMail(t *testing.T) { t.Run("injects Recipient into render data", func(t *testing.T) { renderer := &fakeRenderer{metadata: metadata, body: "body"} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -240,7 +244,7 @@ func TestMailService_SendTestMail(t *testing.T) { t.Run("injects unsubscribeURL into render data", func(t *testing.T) { recipientWithToken := domain.User{ID: 1, Email: "dev@example.com", Name: "Dev", UnsubscribeToken: "my-unsub-tok"} renderer := &fakeRenderer{metadata: metadata, body: "body"} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendTestMail(context.Background(), recipientWithToken, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -253,7 +257,7 @@ func TestMailService_SendTestMail(t *testing.T) { t.Run("wraps renderer error", func(t *testing.T) { renderErr := errors.New("bad template") - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com") err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil) if !errors.Is(err, renderErr) { t.Errorf("expected wrapped render error, got: %v", err) @@ -265,6 +269,7 @@ func TestMailService_SendTestMail(t *testing.T) { svc := NewMailService( newFakeListRepo(), newFakeUserRepo(), + newFakeTopicRepo(), newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, @@ -283,7 +288,7 @@ func TestMailService_RenderNewsletter(t *testing.T) { t.Run("renders with matched user when token is valid", func(t *testing.T) { renderer := &fakeRenderer{metadata: domain.MailMetadata{}, body: "rendered"} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(user), newFakeNewsletterRepo(stored), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(user), newFakeTopicRepo(), newFakeNewsletterRepo(stored), renderer, &fakeSender{}, "https://example.com") body, err := svc.RenderNewsletter(context.Background(), 1, "tok-abc") if err != nil { @@ -300,7 +305,7 @@ func TestMailService_RenderNewsletter(t *testing.T) { t.Run("renders with placeholder when token is empty", func(t *testing.T) { renderer := &fakeRenderer{metadata: domain.MailMetadata{}, body: "rendered"} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(user), newFakeNewsletterRepo(stored), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(user), newFakeTopicRepo(), newFakeNewsletterRepo(stored), renderer, &fakeSender{}, "https://example.com") if _, err := svc.RenderNewsletter(context.Background(), 1, ""); err != nil { t.Fatalf("unexpected error: %v", err) @@ -313,7 +318,7 @@ func TestMailService_RenderNewsletter(t *testing.T) { t.Run("renders with placeholder when token is unknown", func(t *testing.T) { renderer := &fakeRenderer{metadata: domain.MailMetadata{}, body: "rendered"} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(stored), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(stored), renderer, &fakeSender{}, "https://example.com") if _, err := svc.RenderNewsletter(context.Background(), 1, "unknown-token"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -326,7 +331,7 @@ func TestMailService_RenderNewsletter(t *testing.T) { t.Run("wraps newsletter not found error", func(t *testing.T) { newsletterRepo := newFakeNewsletterRepo() - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newsletterRepo, &fakeRenderer{}, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newsletterRepo, &fakeRenderer{}, &fakeSender{}, "https://example.com") _, err := svc.RenderNewsletter(context.Background(), 99, "") if err == nil { @@ -336,7 +341,7 @@ func TestMailService_RenderNewsletter(t *testing.T) { t.Run("wraps renderer error", func(t *testing.T) { renderErr := errors.New("bad template") - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(stored), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeTopicRepo(), newFakeNewsletterRepo(stored), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com") _, err := svc.RenderNewsletter(context.Background(), 1, "") if !errors.Is(err, renderErr) { diff --git a/service/schedule.go b/service/schedule.go index 6ff8c8a..15952ef 100644 --- a/service/schedule.go +++ b/service/schedule.go @@ -10,14 +10,10 @@ import ( "github.com/5000K/5000mails/domain" ) -// ListMailSender can send a rendered markdown newsletter to a mailing list. type ListMailSender 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 } -// SchedulingService persists and dispatches scheduled mails. -// It polls for due mails at a fixed interval and delegates sending to a -// ListMailSender so it carries no rendering or SMTP logic itself. type SchedulingService struct { repo domain.ScheduledMailRepository mailer ListMailSender @@ -27,7 +23,6 @@ type SchedulingService struct { stop chan struct{} } -// NewSchedulingService creates a SchedulingService that polls every interval. func NewSchedulingService(repo domain.ScheduledMailRepository, mailer ListMailSender, interval time.Duration, logger *slog.Logger) *SchedulingService { return &SchedulingService{ repo: repo, @@ -38,13 +33,10 @@ func NewSchedulingService(repo domain.ScheduledMailRepository, mailer ListMailSe } } -// Start launches the background polling loop. -// Call Stop to shut it down. func (s *SchedulingService) Start() { go s.loop() } -// Stop signals the polling loop to exit. func (s *SchedulingService) Stop() { close(s.stop) } @@ -76,7 +68,7 @@ func (s *SchedulingService) dispatchDue() { } for _, m := range pending { - if err := s.mailer.SendToList(ctx, m.MailingListName, m.RawMarkdown, nil); err != nil { + if err := s.mailer.SendToList(ctx, m.MailingListName, m.RawMarkdown, m.TopicNames, nil); err != nil { s.logger.ErrorContext(ctx, "sending scheduled mail", slog.Uint64("id", uint64(m.ID)), slog.String("list", m.MailingListName), @@ -94,16 +86,14 @@ func (s *SchedulingService) dispatchDue() { } } -// Schedule creates a new scheduled mail entry. -func (s *SchedulingService) Schedule(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) { - m, err := s.repo.CreateScheduledMail(ctx, mailingListName, rawMarkdown, scheduledAt) +func (s *SchedulingService) Schedule(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64, topicNames []string) (*domain.ScheduledMail, error) { + m, err := s.repo.CreateScheduledMail(ctx, mailingListName, rawMarkdown, scheduledAt, topicNames) if err != nil { return nil, fmt.Errorf("scheduling mail for list %q: %w", mailingListName, err) } return m, nil } -// List returns all scheduled mails (including already-sent ones). func (s *SchedulingService) List(ctx context.Context) ([]domain.ScheduledMail, error) { mails, err := s.repo.GetAllScheduledMails(ctx) if err != nil { @@ -112,7 +102,6 @@ func (s *SchedulingService) List(ctx context.Context) ([]domain.ScheduledMail, e return mails, nil } -// Get returns a single scheduled mail by ID. func (s *SchedulingService) Get(ctx context.Context, id uint) (*domain.ScheduledMail, error) { m, err := s.repo.GetScheduledMailByID(ctx, id) if err != nil { @@ -121,7 +110,6 @@ func (s *SchedulingService) Get(ctx context.Context, id uint) (*domain.Scheduled return m, nil } -// Delete removes a scheduled mail. func (s *SchedulingService) Delete(ctx context.Context, id uint) error { if err := s.repo.DeleteScheduledMail(ctx, id); err != nil { return fmt.Errorf("deleting scheduled mail %d: %w", id, err) @@ -129,7 +117,6 @@ func (s *SchedulingService) Delete(ctx context.Context, id uint) error { return nil } -// Reschedule changes the target delivery time of a scheduled mail. func (s *SchedulingService) Reschedule(ctx context.Context, id uint, scheduledAt int64) (*domain.ScheduledMail, error) { m, err := s.repo.UpdateScheduledMailTime(ctx, id, scheduledAt) if err != nil { @@ -138,7 +125,6 @@ func (s *SchedulingService) Reschedule(ctx context.Context, id uint, scheduledAt return m, nil } -// ReplaceContent replaces the markdown body of a scheduled mail. func (s *SchedulingService) ReplaceContent(ctx context.Context, id uint, rawMarkdown string) (*domain.ScheduledMail, error) { m, err := s.repo.UpdateScheduledMailContent(ctx, id, rawMarkdown) if err != nil { diff --git a/service/schedule_test.go b/service/schedule_test.go index 99f64fa..cbebbc9 100644 --- a/service/schedule_test.go +++ b/service/schedule_test.go @@ -38,13 +38,13 @@ func newFakeScheduledMailRepo(seed ...*domain.ScheduledMail) *fakeScheduledMailR return r } -func (r *fakeScheduledMailRepo) CreateScheduledMail(_ context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) { +func (r *fakeScheduledMailRepo) CreateScheduledMail(_ context.Context, mailingListName, rawMarkdown string, scheduledAt int64, topicNames []string) (*domain.ScheduledMail, error) { r.mu.Lock() defer r.mu.Unlock() if r.createErr != nil { return nil, r.createErr } - m := &domain.ScheduledMail{ID: r.nextID, MailingListName: mailingListName, RawMarkdown: rawMarkdown, ScheduledAt: scheduledAt} + m := &domain.ScheduledMail{ID: r.nextID, MailingListName: mailingListName, RawMarkdown: rawMarkdown, ScheduledAt: scheduledAt, TopicNames: topicNames} r.nextID++ r.mails[m.ID] = m return m, nil @@ -154,17 +154,18 @@ type fakeListMailSender struct { } type listSendCall struct { - listName string - raw string + listName string + raw string + topicNames []string } -func (s *fakeListMailSender) SendToList(_ context.Context, listName string, raw string, _ map[string]any) error { +func (s *fakeListMailSender) SendToList(_ context.Context, listName string, raw string, topicNames []string, _ map[string]any) error { s.mu.Lock() defer s.mu.Unlock() if s.err != nil { return s.err } - s.calls = append(s.calls, listSendCall{listName: listName, raw: raw}) + s.calls = append(s.calls, listSendCall{listName: listName, raw: raw, topicNames: topicNames}) return nil } @@ -180,7 +181,7 @@ func TestSchedulingService_Schedule(t *testing.T) { repo := newFakeScheduledMailRepo() svc := newTestSchedulingService(repo, &fakeListMailSender{}) - m, err := svc.Schedule(context.Background(), "newsletter", "# Hello", 1_000_000) + m, err := svc.Schedule(context.Background(), "newsletter", "# Hello", 1_000_000, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -200,7 +201,7 @@ func TestSchedulingService_Schedule_RepoError(t *testing.T) { repo.createErr = fmt.Errorf("db error") svc := newTestSchedulingService(repo, &fakeListMailSender{}) - _, err := svc.Schedule(context.Background(), "newsletter", "# Hello", 1_000_000) + _, err := svc.Schedule(context.Background(), "newsletter", "# Hello", 1_000_000, nil) if err == nil { t.Fatal("expected error, got nil") } diff --git a/service/subscription.go b/service/subscription.go index 057d39b..c45dd0b 100644 --- a/service/subscription.go +++ b/service/subscription.go @@ -14,17 +14,18 @@ type SubscriptionService struct { lists domain.MailingListRepository users domain.UserRepository confirmations domain.ConfirmationRepository + topics domain.TopicRepository renderer domain.Renderer sender domain.Sender - confirmMail string // raw markdown template for the confirmation mail + confirmMail string baseURL string } -// NewSubscriptionService creates a new SubscriptionService. func NewSubscriptionService( lists domain.MailingListRepository, users domain.UserRepository, confirmations domain.ConfirmationRepository, + topics domain.TopicRepository, renderer domain.Renderer, sender domain.Sender, confirmMail string, @@ -34,6 +35,7 @@ func NewSubscriptionService( lists: lists, users: users, confirmations: confirmations, + topics: topics, renderer: renderer, sender: sender, confirmMail: confirmMail, @@ -41,10 +43,7 @@ func NewSubscriptionService( } } -// Subscribe adds a user to the mailing list with the given name and sends a -// confirmation mail to the user's address. -// Returns an error if the mailing list does not exist. -func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, email string) (*domain.User, error) { +func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, email string, topicNames []string) (*domain.User, error) { list, err := s.lists.GetListByName(ctx, listName) if err != nil { return nil, fmt.Errorf("mailing list %q not found: %w", listName, err) @@ -60,6 +59,10 @@ func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, return nil, fmt.Errorf("adding user to list %q: %w", listName, err) } + if err := s.subscribeToTopics(ctx, user.ID, list.Name, topicNames); err != nil { + return nil, fmt.Errorf("subscribing user to topics: %w", err) + } + token, err := generateToken() if err != nil { return nil, fmt.Errorf("generating confirmation token: %w", err) @@ -70,9 +73,10 @@ func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, } metadata, body, err := s.renderer.Render(&s.confirmMail, map[string]any{ - "token": token, - "confirmURL": s.baseURL + "/confirm/" + token, - "Recipient": *user, + "token": token, + "confirmURL": s.baseURL + "/confirm/" + token, + "preferencesURL": s.baseURL + "/preferences/" + listName + "/" + user.UnsubscribeToken, + "Recipient": *user, }) if err != nil { return nil, fmt.Errorf("rendering confirmation mail: %w", err) @@ -85,6 +89,42 @@ func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, return user, nil } +func (s *SubscriptionService) subscribeToTopics(ctx context.Context, userID uint, listName string, topicNames []string) error { + var topics []domain.Topic + var err error + + if len(topicNames) > 0 { + allTopics, err := s.topics.GetTopicsByList(ctx, listName) + if err != nil { + return fmt.Errorf("getting topics for list %q: %w", listName, err) + } + nameSet := make(map[string]bool, len(topicNames)) + for _, n := range topicNames { + nameSet[n] = true + } + for _, t := range allTopics { + if nameSet[t.Name] { + topics = append(topics, t) + } + } + } else { + topics, err = s.topics.GetDefaultEnabledTopics(ctx, listName) + if err != nil { + return fmt.Errorf("getting default topics for list %q: %w", listName, err) + } + } + + if len(topics) == 0 { + return nil + } + + topicIDs := make([]uint, len(topics)) + for i, t := range topics { + topicIDs[i] = t.ID + } + return s.topics.SubscribeUserToTopics(ctx, userID, topicIDs) +} + // Confirm completes the double opt-in for the confirmation identified by token. func (s *SubscriptionService) Confirm(ctx context.Context, token string) error { confirmation, err := s.confirmations.GetConfirmationByToken(ctx, token) diff --git a/service/subscription_test.go b/service/subscription_test.go index 5b06600..b2b214b 100644 --- a/service/subscription_test.go +++ b/service/subscription_test.go @@ -12,10 +12,11 @@ func newSubscriptionSvc( lists *fakeListRepo, users *fakeUserRepo, confs *fakeConfirmationRepo, + topics *fakeTopicRepo, renderer *fakeRenderer, sender *fakeSender, ) *SubscriptionService { - return NewSubscriptionService(lists, users, confs, renderer, sender, "# Confirm your subscription\nToken: {{.token}}", "https://example.com") + return NewSubscriptionService(lists, users, confs, topics, renderer, sender, "# Confirm your subscription\nToken: {{.token}}", "https://example.com") } func TestSubscriptionService_Subscribe(t *testing.T) { @@ -26,9 +27,9 @@ func TestSubscriptionService_Subscribe(t *testing.T) { users := newFakeUserRepo() confs := newFakeConfirmationRepo() sender := &fakeSender{} - svc := newSubscriptionSvc(newFakeListRepo(list), users, confs, &fakeRenderer{metadata: metadata, body: "click here"}, sender) + svc := newSubscriptionSvc(newFakeListRepo(list), users, confs, newFakeTopicRepo(), &fakeRenderer{metadata: metadata, body: "click here"}, sender) - user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -48,9 +49,9 @@ func TestSubscriptionService_Subscribe(t *testing.T) { t.Run("passes Recipient in render data", func(t *testing.T) { renderer := &fakeRenderer{metadata: metadata, body: "click here"} - svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), renderer, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), newFakeTopicRepo(), renderer, &fakeSender{}) - user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -65,9 +66,9 @@ func TestSubscriptionService_Subscribe(t *testing.T) { t.Run("injects confirmURL into render data", func(t *testing.T) { renderer := &fakeRenderer{metadata: metadata, body: "click here"} - svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), renderer, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), newFakeTopicRepo(), renderer, &fakeSender{}) - if _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com"); err != nil { + if _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil); err != nil { t.Fatalf("unexpected error: %v", err) } token, _ := renderer.lastData["token"].(string) @@ -80,8 +81,8 @@ func TestSubscriptionService_Subscribe(t *testing.T) { t.Run("returns error when GetListByName fails", func(t *testing.T) { repo := newFakeListRepo() repo.getByNameErr = errors.New("list missing") - svc := newSubscriptionSvc(repo, newFakeUserRepo(), newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) - _, err := svc.Subscribe(context.Background(), "ghost", "Bob", "bob@example.com") + svc := newSubscriptionSvc(repo, newFakeUserRepo(), newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "ghost", "Bob", "bob@example.com", nil) if !errors.Is(err, repo.getByNameErr) { t.Errorf("expected wrapped list error, got: %v", err) } @@ -90,8 +91,8 @@ func TestSubscriptionService_Subscribe(t *testing.T) { t.Run("returns error when AddUser fails", func(t *testing.T) { users := newFakeUserRepo() users.addErr = errors.New("duplicate email") - svc := newSubscriptionSvc(newFakeListRepo(list), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) - _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + svc := newSubscriptionSvc(newFakeListRepo(list), users, newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) if !errors.Is(err, users.addErr) { t.Errorf("expected wrapped add error, got: %v", err) } @@ -100,8 +101,8 @@ func TestSubscriptionService_Subscribe(t *testing.T) { t.Run("returns error when CreateConfirmation fails", func(t *testing.T) { confs := newFakeConfirmationRepo() confs.createErr = errors.New("db full") - svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), confs, &fakeRenderer{}, &fakeSender{}) - _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), confs, newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) if !errors.Is(err, confs.createErr) { t.Errorf("expected wrapped confirmation error, got: %v", err) } @@ -109,8 +110,8 @@ func TestSubscriptionService_Subscribe(t *testing.T) { t.Run("returns error when Render fails", func(t *testing.T) { renderErr := errors.New("bad template") - svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}) - _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + svc := newSubscriptionSvc(newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}) + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) if !errors.Is(err, renderErr) { t.Errorf("expected wrapped render error, got: %v", err) } @@ -123,10 +124,11 @@ func TestSubscriptionService_Subscribe(t *testing.T) { newFakeListRepo(list), newFakeUserRepo(), newFakeConfirmationRepo(), + newFakeTopicRepo(), &fakeRenderer{metadata: metadata, body: "ok"}, &fakeSender{err: sendErr}, ) - _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com") + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) if !errors.Is(err, sendErr) { t.Errorf("expected wrapped send error, got: %v", err) } @@ -137,7 +139,7 @@ func TestSubscriptionService_Confirm(t *testing.T) { t.Run("confirms user and deletes confirmation", func(t *testing.T) { users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListName: "weekly"}) confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "abc123"}) - svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), users, confs, newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) if err := svc.Confirm(context.Background(), "abc123"); err != nil { t.Fatalf("unexpected error: %v", err) @@ -153,7 +155,7 @@ func TestSubscriptionService_Confirm(t *testing.T) { t.Run("returns error for unknown token", func(t *testing.T) { confs := newFakeConfirmationRepo() confs.getErr = errors.New("token not found") - svc := newSubscriptionSvc(newFakeListRepo(), newFakeUserRepo(), confs, &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), newFakeUserRepo(), confs, newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) err := svc.Confirm(context.Background(), "bad-token") if !errors.Is(err, confs.getErr) { t.Errorf("expected wrapped token error, got: %v", err) @@ -164,7 +166,7 @@ func TestSubscriptionService_Confirm(t *testing.T) { users := newFakeUserRepo() users.confirmErr = errors.New("confirm failed") confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 99, Token: "tok"}) - svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), users, confs, newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) err := svc.Confirm(context.Background(), "tok") if !errors.Is(err, users.confirmErr) { t.Errorf("expected wrapped confirm error, got: %v", err) @@ -175,7 +177,7 @@ func TestSubscriptionService_Confirm(t *testing.T) { users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListName: "weekly"}) confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "tok"}) confs.deleteErr = errors.New("delete failed") - svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), users, confs, newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) err := svc.Confirm(context.Background(), "tok") if !errors.Is(err, confs.deleteErr) { t.Errorf("expected wrapped delete error, got: %v", err) @@ -188,7 +190,7 @@ func TestSubscriptionService_Unsubscribe(t *testing.T) { t.Run("removes user by unsubscribe token", func(t *testing.T) { users := newFakeUserRepo(u) - svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) if err := svc.Unsubscribe(context.Background(), "tok-alice"); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -200,7 +202,7 @@ func TestSubscriptionService_Unsubscribe(t *testing.T) { t.Run("returns error when token not found", func(t *testing.T) { users := newFakeUserRepo() users.getByUnsubscribeTokenErr = errors.New("not found") - svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) err := svc.Unsubscribe(context.Background(), "bad-token") if !errors.Is(err, users.getByUnsubscribeTokenErr) { t.Errorf("expected wrapped error, got: %v", err) @@ -210,7 +212,7 @@ func TestSubscriptionService_Unsubscribe(t *testing.T) { t.Run("returns error when RemoveUser fails", func(t *testing.T) { users := newFakeUserRepo(u) users.removeErr = errors.New("delete failed") - svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), &fakeRenderer{}, &fakeSender{}) + svc := newSubscriptionSvc(newFakeListRepo(), users, newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) err := svc.Unsubscribe(context.Background(), "tok-alice") if !errors.Is(err, users.removeErr) { t.Errorf("expected wrapped error, got: %v", err) diff --git a/service/topic.go b/service/topic.go new file mode 100644 index 0000000..3d2c7e7 --- /dev/null +++ b/service/topic.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "fmt" + + "github.com/5000K/5000mails/domain" +) + +type TopicService struct { + topics domain.TopicRepository + lists domain.MailingListRepository +} + +func NewTopicService(topics domain.TopicRepository, lists domain.MailingListRepository) *TopicService { + return &TopicService{topics: topics, lists: lists} +} + +func (s *TopicService) Create(ctx context.Context, listName, name, displayName string, defaultEnabled, subscribeExisting bool) (*domain.Topic, error) { + if _, err := s.lists.GetListByName(ctx, listName); err != nil { + return nil, fmt.Errorf("list %q not found: %w", listName, err) + } + + topic, err := s.topics.CreateTopic(ctx, listName, name, displayName, defaultEnabled) + if err != nil { + return nil, fmt.Errorf("creating topic %q on list %q: %w", name, listName, err) + } + + if subscribeExisting { + if err := s.topics.SubscribeAllUsersToTopic(ctx, listName, topic.ID); err != nil { + return nil, fmt.Errorf("subscribing existing users to topic %q: %w", name, err) + } + } + + return topic, nil +} + +func (s *TopicService) List(ctx context.Context, listName string) ([]domain.Topic, error) { + topics, err := s.topics.GetTopicsByList(ctx, listName) + if err != nil { + return nil, fmt.Errorf("listing topics for list %q: %w", listName, err) + } + return topics, nil +} + +func (s *TopicService) Get(ctx context.Context, listName, name string) (*domain.Topic, error) { + topic, err := s.topics.GetTopicByName(ctx, listName, name) + if err != nil { + return nil, fmt.Errorf("getting topic %q on list %q: %w", name, listName, err) + } + return topic, nil +} + +func (s *TopicService) Update(ctx context.Context, listName, name string, displayName *string, defaultEnabled *bool) (*domain.Topic, error) { + topic, err := s.topics.UpdateTopic(ctx, listName, name, displayName, defaultEnabled) + if err != nil { + return nil, fmt.Errorf("updating topic %q on list %q: %w", name, listName, err) + } + return topic, nil +} + +func (s *TopicService) Delete(ctx context.Context, listName, name string) error { + if err := s.topics.DeleteTopic(ctx, listName, name); err != nil { + return fmt.Errorf("deleting topic %q on list %q: %w", name, listName, err) + } + return nil +} + +func (s *TopicService) GetUserTopics(ctx context.Context, _ string, userID uint) ([]domain.Topic, error) { + topics, err := s.topics.GetUserTopics(ctx, userID) + if err != nil { + return nil, fmt.Errorf("getting topics for user %d: %w", userID, err) + } + return topics, nil +} + +func (s *TopicService) SetUserTopics(ctx context.Context, _ string, userID uint, topicIDs []uint) error { + if err := s.topics.SetUserTopics(ctx, userID, topicIDs); err != nil { + return fmt.Errorf("setting topics for user %d: %w", userID, err) + } + return nil +} diff --git a/smtp/sender.go b/smtp/sender.go index baeb7d5..878b234 100644 --- a/smtp/sender.go +++ b/smtp/sender.go @@ -1,9 +1,12 @@ package smtp import ( + "bytes" "context" "fmt" + stdhtml "html" "log/slog" + "strings" gomail "github.com/wneessen/go-mail" @@ -11,6 +14,51 @@ import ( "github.com/5000K/5000mails/domain" ) +var blockElements = []string{ + "address", "article", "aside", "blockquote", "br", "dd", "details", + "dialog", "div", "dl", "dt", "fieldset", "figcaption", "figure", + "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", + "hgroup", "hr", "li", "main", "nav", "ol", "p", "pre", "section", + "summary", "table", "td", "th", "tr", "ul", +} + +func htmlToPlainText(src []byte) []byte { + var buf bytes.Buffer + i := 0 + for i < len(src) { + if src[i] != '<' { + buf.WriteByte(src[i]) + i++ + continue + } + end := bytes.IndexByte(src[i:], '>') + if end == -1 { + buf.Write(src[i:]) + break + } + inner := src[i+1 : i+end] + if len(inner) > 0 && inner[0] == '/' { + inner = inner[1:] + } + tagName := strings.ToLower(string(inner)) + if sp := strings.IndexByte(tagName, ' '); sp != -1 { + tagName = tagName[:sp] + } + for _, bt := range blockElements { + if tagName == bt { + buf.WriteByte('\n') + break + } + } + i += end + 1 + } + text := stdhtml.UnescapeString(buf.String()) + for strings.Contains(text, "\n\n\n") { + text = strings.ReplaceAll(text, "\n\n\n", "\n\n") + } + return []byte(strings.TrimSpace(text)) +} + type Sender struct { client *gomail.Client senderEmail string @@ -61,6 +109,7 @@ func (s *Sender) SendMail(ctx context.Context, metadata domain.MailMetadata, bod msg.Subject(metadata.Subject) msg.SetBodyString(gomail.TypeTextHTML, body) + msg.AddAlternativeString(gomail.TypeTextPlain, string(htmlToPlainText([]byte(body)))) if err := s.client.DialAndSendWithContext(ctx, msg); err != nil { s.logger.ErrorContext(ctx, "failed to send mail", diff --git a/smtp/sender_test.go b/smtp/sender_test.go new file mode 100644 index 0000000..3c33f3c --- /dev/null +++ b/smtp/sender_test.go @@ -0,0 +1,53 @@ +package smtp + +import ( + "testing" +) + +func TestHtmlToPlainText(t *testing.T) { + tests := []struct { + name string + src string + want string + }{ + { + name: "strips tags and keeps text", + src: "

Hello, world!

", + want: "Hello, world!", + }, + { + name: "inserts newline at block boundaries", + src: "

First

Second

", + want: "First\n\nSecond", + }, + { + name: "unescapes html entities", + src: "

a & b <3

", + want: "a & b <3", + }, + { + name: "collapses excessive blank lines", + src: "

Title



Body

", + want: "Title\n\nBody", + }, + { + name: "handles unclosed tag at end", + src: "

Truncated