From dff058ff0c25e5e2ee7d72ee1279a7b9a8237bfe Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Tue, 14 Apr 2026 15:13:57 +0200 Subject: [PATCH 1/3] implement newsletter history --- api/private.go | 110 +++++++++++++++++-- api/private_test.go | 207 +++++++++++++++++++++++++++++++++++- cli/cli_test.go | 16 ++- db/connect.go | 2 +- db/model.go | 33 ++++++ db/repository.newsletter.go | 81 ++++++++++++++ domain/model.go | 11 ++ domain/ports.go | 7 ++ main.go | 4 +- service/fakes_test.go | 81 ++++++++++++++ service/mail.go | 63 ++++++++--- service/mail_test.go | 70 ++++++++++-- 12 files changed, 652 insertions(+), 33 deletions(-) create mode 100644 db/repository.newsletter.go diff --git a/api/private.go b/api/private.go index 294c903..0de9d3f 100644 --- a/api/private.go +++ b/api/private.go @@ -34,19 +34,27 @@ type MailDispatcher interface { SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error } +// NewsletterArchive is the private API's view of the newsletter archive. +type NewsletterArchive interface { + AllNewsletters(ctx context.Context) ([]domain.SentNewsletter, error) + GetNewsletter(ctx context.Context, id uint) (*domain.SentNewsletter, error) + DeleteNewsletter(ctx context.Context, id uint) error +} + // PrivateHandler serves the private admin API. // When publicKey is non-nil, every request must carry a valid Ed25519 signature. type PrivateHandler struct { - lists ListManager - mail MailDispatcher - publicKey ed25519.PublicKey - logger *slog.Logger + lists ListManager + mail MailDispatcher + newsletters NewsletterArchive + publicKey ed25519.PublicKey + logger *slog.Logger } // NewPrivateHandler creates a new PrivateHandler. // Pass a nil publicKey to disable request authentication. -func NewPrivateHandler(lists ListManager, mail MailDispatcher, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler { - return &PrivateHandler{lists: lists, mail: mail, publicKey: publicKey, logger: logger} +func NewPrivateHandler(lists ListManager, mail MailDispatcher, newsletters NewsletterArchive, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler { + return &PrivateHandler{lists: lists, mail: mail, newsletters: newsletters, publicKey: publicKey, logger: logger} } // Routes returns the mux for all private API endpoints. @@ -60,6 +68,9 @@ 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 /mail/test", h.auth(h.handleSendTestMail)) + mux.Handle("GET /newsletters", h.auth(h.handleAllNewsletters)) + mux.Handle("GET /newsletters/{id}", h.auth(h.handleGetNewsletter)) + mux.Handle("DELETE /newsletters/{id}", h.auth(h.handleDeleteNewsletter)) return mux } @@ -98,6 +109,24 @@ type testMailRequest struct { Data map[string]any `json:"data"` } +type newsletterSummaryResponse struct { + ID uint `json:"id"` + Subject string `json:"subject"` + SenderName string `json:"senderName"` + SentAt string `json:"sentAt"` + MailingLists []string `json:"mailingLists"` +} + +type newsletterDetailResponse struct { + ID uint `json:"id"` + Subject string `json:"subject"` + SenderName string `json:"senderName"` + RawMarkdown string `json:"rawMarkdown"` + SentAt string `json:"sentAt"` + Recipients []userResponse `json:"recipients"` + MailingLists []string `json:"mailingLists"` +} + // --- handlers --- func (h *PrivateHandler) handleAllLists(w http.ResponseWriter, r *http.Request) { @@ -224,6 +253,75 @@ func (h *PrivateHandler) handleSendToList(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, map[string]string{"message": "mail dispatched"}) } +func (h *PrivateHandler) handleAllNewsletters(w http.ResponseWriter, r *http.Request) { + newsletters, err := h.newsletters.AllNewsletters(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "get all newsletters failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load newsletters") + return + } + resp := make([]newsletterSummaryResponse, len(newsletters)) + for i, n := range newsletters { + lists := make([]string, len(n.MailingLists)) + for j, l := range n.MailingLists { + lists[j] = l.Name + } + resp[i] = newsletterSummaryResponse{ + ID: n.ID, + Subject: n.Subject, + SenderName: n.SenderName, + SentAt: n.SentAt.Format(time.RFC3339), + MailingLists: lists, + } + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *PrivateHandler) handleGetNewsletter(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid newsletter id") + return + } + n, err := h.newsletters.GetNewsletter(r.Context(), uint(id)) + if err != nil { + h.logger.ErrorContext(r.Context(), "get newsletter failed", slog.Uint64("id", id), slog.Any("error", err)) + writeError(w, http.StatusNotFound, "newsletter not found") + return + } + lists := make([]string, len(n.MailingLists)) + for i, l := range n.MailingLists { + lists[i] = l.Name + } + recipients := make([]userResponse, len(n.Recipients)) + for i, u := range n.Recipients { + recipients[i] = userResponse{ID: u.ID, Name: u.Name, Email: u.Email, Confirmed: u.IsConfirmed()} + } + writeJSON(w, http.StatusOK, newsletterDetailResponse{ + ID: n.ID, + Subject: n.Subject, + SenderName: n.SenderName, + RawMarkdown: n.RawMarkdown, + SentAt: n.SentAt.Format(time.RFC3339), + Recipients: recipients, + MailingLists: lists, + }) +} + +func (h *PrivateHandler) handleDeleteNewsletter(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid newsletter id") + return + } + if err := h.newsletters.DeleteNewsletter(r.Context(), uint(id)); err != nil { + h.logger.ErrorContext(r.Context(), "delete newsletter failed", slog.Uint64("id", id), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to delete newsletter") + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *PrivateHandler) handleSendTestMail(w http.ResponseWriter, r *http.Request) { var body testMailRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { diff --git a/api/private_test.go b/api/private_test.go index ff71160..bac1aa1 100644 --- a/api/private_test.go +++ b/api/private_test.go @@ -147,10 +147,63 @@ func (f *fakeMailDispatcher) SendTestMail(_ context.Context, recipient domain.Us return f.sendTestMailErr } +type fakeNewsletterArchive struct { + newsletters map[uint]*domain.SentNewsletter + nextID uint + + getAllErr error + getErr error + deleteErr error +} + +func newFakeNewsletterArchive(seed ...*domain.SentNewsletter) *fakeNewsletterArchive { + f := &fakeNewsletterArchive{newsletters: make(map[uint]*domain.SentNewsletter), nextID: 1} + for _, n := range seed { + f.newsletters[n.ID] = n + if n.ID >= f.nextID { + f.nextID = n.ID + 1 + } + } + return f +} + +func (f *fakeNewsletterArchive) AllNewsletters(_ context.Context) ([]domain.SentNewsletter, error) { + if f.getAllErr != nil { + return nil, f.getAllErr + } + out := make([]domain.SentNewsletter, 0, len(f.newsletters)) + for _, n := range f.newsletters { + out = append(out, *n) + } + return out, nil +} + +func (f *fakeNewsletterArchive) GetNewsletter(_ context.Context, id uint) (*domain.SentNewsletter, error) { + if f.getErr != nil { + return nil, f.getErr + } + n, ok := f.newsletters[id] + if !ok { + return nil, fmt.Errorf("newsletter %d not found", id) + } + return n, nil +} + +func (f *fakeNewsletterArchive) DeleteNewsletter(_ context.Context, id uint) error { + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.newsletters[id]; !ok { + return fmt.Errorf("newsletter %d not found", id) + } + delete(f.newsletters, id) + return nil +} + // --- helpers --- func newPrivateTestHandler(lists *fakeListManager, mail *fakeMailDispatcher, pub ed25519.PublicKey) *PrivateHandler { - return NewPrivateHandler(lists, mail, pub, slog.Default()) + return NewPrivateHandler(lists, mail, newFakeNewsletterArchive(), pub, slog.Default()) } func privateRequest(t *testing.T, h *PrivateHandler, method, target string, body any) *httptest.ResponseRecorder { @@ -493,6 +546,156 @@ func TestPrivateHandler_SendTestMail(t *testing.T) { }) } +// --- newsletter tests --- + +func TestPrivateHandler_AllNewsletters(t *testing.T) { + now := time.Now() + n := &domain.SentNewsletter{ + ID: 1, + Subject: "Weekly Digest", + SenderName: "Bot", + SentAt: now, + MailingLists: []domain.MailingList{{Name: "weekly"}}, + } + + t.Run("returns all newsletters", func(t *testing.T) { + nl := newFakeNewsletterArchive(n) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, 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) + } + var resp []newsletterSummaryResponse + decodeJSON(t, w, &resp) + if len(resp) != 1 { + t.Fatalf("expected 1 newsletter, got %d", len(resp)) + } + if resp[0].Subject != "Weekly Digest" { + t.Errorf("expected subject %q, got %q", "Weekly Digest", resp[0].Subject) + } + if len(resp[0].MailingLists) != 1 || resp[0].MailingLists[0] != "weekly" { + t.Errorf("unexpected mailing lists: %v", resp[0].MailingLists) + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + nl := newFakeNewsletterArchive() + nl.getAllErr = errors.New("db failure") + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, nil, slog.Default()) + w := privateRequest(t, h, http.MethodGet, "/newsletters", nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_GetNewsletter(t *testing.T) { + now := time.Now() + confirmed := now + n := &domain.SentNewsletter{ + ID: 42, + Subject: "Issue #1", + SenderName: "Editor", + RawMarkdown: "# Hello", + SentAt: now, + Recipients: []domain.User{{ID: 1, Name: "Alice", Email: "alice@example.com", ConfirmedAt: &confirmed}}, + MailingLists: []domain.MailingList{{Name: "monthly"}}, + } + + t.Run("returns newsletter detail", func(t *testing.T) { + nl := newFakeNewsletterArchive(n) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, nil, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/newsletters/42", nil) + req.SetPathValue("id", "42") + w := httptest.NewRecorder() + h.handleGetNewsletter(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp newsletterDetailResponse + decodeJSON(t, w, &resp) + if resp.Subject != "Issue #1" { + t.Errorf("expected subject %q, got %q", "Issue #1", resp.Subject) + } + if resp.RawMarkdown != "# Hello" { + t.Errorf("expected raw %q, got %q", "# Hello", resp.RawMarkdown) + } + if len(resp.Recipients) != 1 || resp.Recipients[0].Email != "alice@example.com" { + t.Errorf("unexpected recipients: %+v", resp.Recipients) + } + if len(resp.MailingLists) != 1 || resp.MailingLists[0] != "monthly" { + t.Errorf("unexpected mailing lists: %v", resp.MailingLists) + } + }) + + t.Run("returns 404 when not found", func(t *testing.T) { + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), nil, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/newsletters/99", nil) + req.SetPathValue("id", "99") + w := httptest.NewRecorder() + h.handleGetNewsletter(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } + }) + + t.Run("returns 400 on invalid id", func(t *testing.T) { + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), nil, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/newsletters/abc", nil) + req.SetPathValue("id", "abc") + w := httptest.NewRecorder() + h.handleGetNewsletter(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + +func TestPrivateHandler_DeleteNewsletter(t *testing.T) { + now := time.Now() + n := &domain.SentNewsletter{ID: 7, Subject: "Old", SentAt: now} + + t.Run("returns 204 on success", func(t *testing.T) { + nl := newFakeNewsletterArchive(n) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, nil, slog.Default()) + req := httptest.NewRequest(http.MethodDelete, "/newsletters/7", nil) + req.SetPathValue("id", "7") + w := httptest.NewRecorder() + h.handleDeleteNewsletter(w, req) + if w.Code != http.StatusNoContent { + t.Errorf("expected 204, got %d", w.Code) + } + if _, exists := nl.newsletters[7]; exists { + t.Error("newsletter should have been deleted") + } + }) + + 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, nil, slog.Default()) + req := httptest.NewRequest(http.MethodDelete, "/newsletters/7", nil) + req.SetPathValue("id", "7") + w := httptest.NewRecorder() + h.handleDeleteNewsletter(w, req) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) + + t.Run("returns 400 on invalid id", func(t *testing.T) { + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), nil, slog.Default()) + req := httptest.NewRequest(http.MethodDelete, "/newsletters/bad", nil) + req.SetPathValue("id", "bad") + w := httptest.NewRecorder() + h.handleDeleteNewsletter(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) +} + // --- authentication tests --- func TestPrivateHandler_Auth(t *testing.T) { @@ -550,7 +753,7 @@ func TestPrivateClient_Integration(t *testing.T) { } mail := &fakeMailDispatcher{} - srv := httptest.NewServer(NewPrivateHandler(m, mail, pub, slog.Default()).Routes()) + srv := httptest.NewServer(NewPrivateHandler(m, mail, newFakeNewsletterArchive(), pub, slog.Default()).Routes()) defer srv.Close() client := NewPrivateClient(srv.URL, priv) diff --git a/cli/cli_test.go b/cli/cli_test.go index 2048b52..c66c8a1 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -118,9 +118,23 @@ func (f *fakeMailDispatcher) SendTestMail(_ context.Context, r domain.User, raw // --- helpers --- +type fakeNewsletterArchive struct{} + +func (f *fakeNewsletterArchive) AllNewsletters(_ context.Context) ([]domain.SentNewsletter, error) { + return nil, nil +} + +func (f *fakeNewsletterArchive) GetNewsletter(_ context.Context, _ uint) (*domain.SentNewsletter, error) { + return nil, fmt.Errorf("not found") +} + +func (f *fakeNewsletterArchive) DeleteNewsletter(_ context.Context, _ uint) error { + return nil +} + func startTestServer(t *testing.T, lm *fakeListManager, md *fakeMailDispatcher, pub ed25519.PublicKey) *httptest.Server { t.Helper() - h := api.NewPrivateHandler(lm, md, pub, slog.Default()) + h := api.NewPrivateHandler(lm, md, &fakeNewsletterArchive{}, pub, slog.Default()) return httptest.NewServer(h.Routes()) } diff --git a/db/connect.go b/db/connect.go index 1a88c81..ce5340f 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{}); err != nil { + if err := database.AutoMigrate(&MailingList{}, &User{}, &Confirmation{}, &SentNewsletter{}); err != nil { return fmt.Errorf("auto-migrating database: %w", err) } return nil diff --git a/db/model.go b/db/model.go index ade313a..07bb1ba 100644 --- a/db/model.go +++ b/db/model.go @@ -76,3 +76,36 @@ func ToGORMConfirmation(c *domain.Confirmation) *Confirmation { Token: c.Token, } } + +type SentNewsletter struct { + gorm.Model + Subject string + SenderName string + RawMarkdown string + Recipients []User `gorm:"many2many:sent_newsletter_recipients;"` + MailingLists []MailingList `gorm:"many2many:sent_newsletter_mailing_lists;"` +} + +func ToDomainSentNewsletter(n *SentNewsletter) *domain.SentNewsletter { + lists := make([]domain.MailingList, len(n.MailingLists)) + for i, l := range n.MailingLists { + lists[i] = *ToDomainList(&l) + } + return &domain.SentNewsletter{ + ID: n.ID, + Subject: n.Subject, + SenderName: n.SenderName, + RawMarkdown: n.RawMarkdown, + SentAt: n.CreatedAt, + Recipients: ToDomainUsers(n.Recipients), + MailingLists: lists, + } +} + +func ToDomainSentNewsletters(newsletters []SentNewsletter) []domain.SentNewsletter { + result := make([]domain.SentNewsletter, len(newsletters)) + for i := range newsletters { + result[i] = *ToDomainSentNewsletter(&newsletters[i]) + } + return result +} diff --git a/db/repository.newsletter.go b/db/repository.newsletter.go new file mode 100644 index 0000000..54396b7 --- /dev/null +++ b/db/repository.newsletter.go @@ -0,0 +1,81 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + + "github.com/5000K/5000mails/domain" +) + +func (r *MailingListRepository) CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*domain.SentNewsletter, error) { + recipients := make([]User, len(recipientIDs)) + for i, id := range recipientIDs { + recipients[i] = User{} + recipients[i].ID = id + } + + mailingLists := make([]MailingList, len(listNames)) + for i, name := range listNames { + mailingLists[i] = MailingList{Name: name} + } + + record := &SentNewsletter{ + Subject: subject, + SenderName: senderName, + RawMarkdown: rawMarkdown, + Recipients: recipients, + MailingLists: mailingLists, + } + + result := r.db.WithContext(ctx).Create(record) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to create sent newsletter", + slog.String("subject", subject), + slog.Any("error", result.Error), + ) + 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 { + return nil, fmt.Errorf("loading sent newsletter associations: %w", err) + } + + return ToDomainSentNewsletter(record), nil +} + +func (r *MailingListRepository) GetAllSentNewsletters(ctx context.Context) ([]domain.SentNewsletter, error) { + var records []SentNewsletter + result := r.db.WithContext(ctx).Preload("MailingLists").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) + } + return ToDomainSentNewsletters(records), nil +} + +func (r *MailingListRepository) GetSentNewsletterByID(ctx context.Context, id uint) (*domain.SentNewsletter, error) { + var record SentNewsletter + result := r.db.WithContext(ctx).Preload("Recipients").Preload("MailingLists").First(&record, id) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get sent newsletter", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("get sent newsletter %d: %w", id, result.Error) + } + return ToDomainSentNewsletter(&record), nil +} + +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 { + r.logger.ErrorContext(ctx, "failed to delete sent newsletter", + slog.Uint64("id", uint64(id)), + slog.Any("error", err), + ) + return fmt.Errorf("delete sent newsletter %d: %w", id, err) + } + return nil +} diff --git a/domain/model.go b/domain/model.go index e720fbd..facccf6 100644 --- a/domain/model.go +++ b/domain/model.go @@ -39,3 +39,14 @@ type UserCounts struct { Total int Confirmed int } + +// SentNewsletter is an archived record of a dispatched newsletter. +type SentNewsletter struct { + ID uint + Subject string + SenderName string + RawMarkdown string + SentAt time.Time + Recipients []User + MailingLists []MailingList +} diff --git a/domain/ports.go b/domain/ports.go index 83f243a..8971b66 100644 --- a/domain/ports.go +++ b/domain/ports.go @@ -25,6 +25,13 @@ type ConfirmationRepository interface { DeleteConfirmation(ctx context.Context, id uint) error } +type SentNewsletterRepository interface { + CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*SentNewsletter, error) + GetAllSentNewsletters(ctx context.Context) ([]SentNewsletter, error) + GetSentNewsletterByID(ctx context.Context, id uint) (*SentNewsletter, error) + DeleteSentNewsletter(ctx context.Context, id uint) error +} + type Renderer interface { Render(raw *string, data map[string]any) (metadata MailMetadata, body string, err error) } diff --git a/main.go b/main.go index f3ff21d..b7dc73c 100644 --- a/main.go +++ b/main.go @@ -67,7 +67,7 @@ func main() { subscriptionSvc := service.NewSubscriptionService(repo, repo, repo, rndr, sender, string(confirmRaw), cfg.BaseURL) listSvc := service.NewListService(repo, repo) - mailSvc := service.NewMailService(repo, repo, rndr, sender, cfg.BaseURL) + mailSvc := service.NewMailService(repo, repo, repo, rndr, sender, cfg.BaseURL) publicHandler := api.NewPublicHandler(subscriptionSvc, api.RedirectPages{ SubscribeSuccess: cfg.Redirects.SubscribeSuccess, @@ -90,7 +90,7 @@ func main() { logger.Warn("private API authentication disabled - no public key configured") } - privateHandler := api.NewPrivateHandler(listSvc, mailSvc, publicKey, logger) + privateHandler := api.NewPrivateHandler(listSvc, mailSvc, mailSvc, publicKey, logger) publicServer := &http.Server{Addr: cfg.PublicAddr, Handler: publicHandler.Routes()} privateServer := &http.Server{Addr: cfg.PrivateAddr, Handler: privateHandler.Routes()} diff --git a/service/fakes_test.go b/service/fakes_test.go index 0f19a5d..804f531 100644 --- a/service/fakes_test.go +++ b/service/fakes_test.go @@ -233,6 +233,87 @@ func (r *fakeConfirmationRepo) DeleteConfirmation(_ context.Context, id uint) er return nil } +// fakeNewsletterRepo is an in-memory SentNewsletterRepository. +type fakeNewsletterRepo struct { + newsletters map[uint]*domain.SentNewsletter + nextID uint + + createErr error + getAllErr error + getErr error + deleteErr error +} + +func newFakeNewsletterRepo(seed ...*domain.SentNewsletter) *fakeNewsletterRepo { + r := &fakeNewsletterRepo{newsletters: make(map[uint]*domain.SentNewsletter), nextID: 1} + for _, n := range seed { + r.newsletters[n.ID] = n + if n.ID >= r.nextID { + r.nextID = n.ID + 1 + } + } + return r +} + +func (r *fakeNewsletterRepo) CreateSentNewsletter(_ context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*domain.SentNewsletter, error) { + if r.createErr != nil { + return nil, r.createErr + } + recipients := make([]domain.User, len(recipientIDs)) + for i, id := range recipientIDs { + recipients[i] = domain.User{ID: id} + } + lists := make([]domain.MailingList, len(listNames)) + for i, name := range listNames { + lists[i] = domain.MailingList{Name: name} + } + n := &domain.SentNewsletter{ + ID: r.nextID, + Subject: subject, + SenderName: senderName, + RawMarkdown: rawMarkdown, + SentAt: time.Now(), + Recipients: recipients, + MailingLists: lists, + } + r.nextID++ + r.newsletters[n.ID] = n + return n, nil +} + +func (r *fakeNewsletterRepo) GetAllSentNewsletters(_ context.Context) ([]domain.SentNewsletter, error) { + if r.getAllErr != nil { + return nil, r.getAllErr + } + out := make([]domain.SentNewsletter, 0, len(r.newsletters)) + for _, n := range r.newsletters { + out = append(out, *n) + } + return out, nil +} + +func (r *fakeNewsletterRepo) GetSentNewsletterByID(_ context.Context, id uint) (*domain.SentNewsletter, error) { + if r.getErr != nil { + return nil, r.getErr + } + n, ok := r.newsletters[id] + if !ok { + return nil, fmt.Errorf("newsletter %d not found", id) + } + return n, nil +} + +func (r *fakeNewsletterRepo) DeleteSentNewsletter(_ context.Context, id uint) error { + if r.deleteErr != nil { + return r.deleteErr + } + if _, ok := r.newsletters[id]; !ok { + return fmt.Errorf("newsletter %d not found", id) + } + delete(r.newsletters, id) + return nil +} + // fakeSender records SendMail calls. type fakeSender struct { calls []sendCall diff --git a/service/mail.go b/service/mail.go index 9dfd85c..0c49a3f 100644 --- a/service/mail.go +++ b/service/mail.go @@ -10,21 +10,23 @@ import ( // MailService renders markdown content and dispatches it to mailing list // recipients or arbitrary test addresses. type MailService struct { - lists domain.MailingListRepository - users domain.UserRepository - renderer domain.Renderer - sender domain.Sender - baseURL string + lists domain.MailingListRepository + users domain.UserRepository + newsletters domain.SentNewsletterRepository + renderer domain.Renderer + sender domain.Sender + baseURL string } // NewMailService creates a new MailService. -func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, renderer domain.Renderer, sender domain.Sender, baseURL string) *MailService { +func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, newsletters domain.SentNewsletterRepository, renderer domain.Renderer, sender domain.Sender, baseURL string) *MailService { return &MailService{ - lists: lists, - users: users, - renderer: renderer, - sender: sender, - baseURL: baseURL, + lists: lists, + users: users, + newsletters: newsletters, + renderer: renderer, + sender: sender, + baseURL: baseURL, } } @@ -46,7 +48,10 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin return nil } - for _, recipient := range recipients { + var firstMetadata domain.MailMetadata + recipientIDs := make([]uint, 0, len(recipients)) + + for i, recipient := range recipients { recipientData := make(map[string]any, len(data)+2) for k, v := range data { recipientData[k] = v @@ -58,10 +63,18 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin if err != nil { return fmt.Errorf("rendering mail for %q: %w", recipient.Email, err) } + if i == 0 { + firstMetadata = metadata + } if err := s.sender.SendMail(ctx, metadata, body, recipient); err != nil { return fmt.Errorf("sending mail to %q: %w", recipient.Email, err) } + recipientIDs = append(recipientIDs, recipient.ID) + } + + if _, err := s.newsletters.CreateSentNewsletter(ctx, firstMetadata.Subject, firstMetadata.SenderName, raw, recipientIDs, []string{listName}); err != nil { + return fmt.Errorf("archiving sent newsletter: %w", err) } return nil @@ -90,3 +103,29 @@ 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 { + return nil, fmt.Errorf("listing sent newsletters: %w", err) + } + 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) + if err != nil { + return nil, fmt.Errorf("getting sent newsletter %d: %w", id, err) + } + 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) + } + return nil +} diff --git a/service/mail_test.go b/service/mail_test.go index bb3909f..baec664 100644 --- a/service/mail_test.go +++ b/service/mail_test.go @@ -23,6 +23,7 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(), + newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "body"}, sender, "https://example.com", @@ -41,7 +42,7 @@ func TestMailService_SendToList(t *testing.T) { confirmedUser(2, "weekly", "bob@example.com"), ) sender := &fakeSender{} - svc := NewMailService(newFakeListRepo(list), users, &fakeRenderer{metadata: metadata, body: "rendered"}, sender, "https://example.com") + svc := NewMailService(newFakeListRepo(list), users, newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "rendered"}, sender, "https://example.com") if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -67,7 +68,7 @@ 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), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -85,7 +86,7 @@ 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), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -99,7 +100,7 @@ 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(), &fakeRenderer{}, &fakeSender{}, "https://example.com") + svc := NewMailService(listRepo, newFakeUserRepo(), newFakeNewsletterRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") err := svc.SendToList(context.Background(), "ghost", "raw", nil) if !errors.Is(err, listRepo.getByNameErr) { t.Errorf("expected wrapped error, got: %v", err) @@ -109,7 +110,7 @@ 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, &fakeRenderer{}, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(list), userRepo, newFakeNewsletterRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") err := svc.SendToList(context.Background(), "weekly", "raw", nil) if !errors.Is(err, userRepo.getConfirmedErr) { t.Errorf("expected wrapped error, got: %v", err) @@ -121,6 +122,7 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), + newFakeNewsletterRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com", @@ -136,6 +138,7 @@ func TestMailService_SendToList(t *testing.T) { svc := NewMailService( newFakeListRepo(list), newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), + newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, "https://example.com", @@ -145,6 +148,54 @@ func TestMailService_SendToList(t *testing.T) { t.Errorf("expected wrapped send error, got: %v", err) } }) + + t.Run("archives newsletter after sending", func(t *testing.T) { + users := newFakeUserRepo( + confirmedUser(1, "weekly", "alice@example.com"), + confirmedUser(2, "weekly", "bob@example.com"), + ) + newsletterRepo := newFakeNewsletterRepo() + svc := NewMailService(newFakeListRepo(list), users, newsletterRepo, &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{}, "https://example.com") + + if err := svc.SendToList(context.Background(), "weekly", "# Raw", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(newsletterRepo.newsletters) != 1 { + t.Fatalf("expected 1 archived newsletter, got %d", len(newsletterRepo.newsletters)) + } + for _, n := range newsletterRepo.newsletters { + if n.Subject != metadata.Subject { + t.Errorf("expected subject %q, got %q", metadata.Subject, n.Subject) + } + if n.RawMarkdown != "# Raw" { + t.Errorf("expected raw %q, got %q", "# Raw", n.RawMarkdown) + } + if len(n.Recipients) != 2 { + t.Errorf("expected 2 recipients, got %d", len(n.Recipients)) + } + if len(n.MailingLists) != 1 || n.MailingLists[0].Name != "weekly" { + t.Errorf("unexpected mailing lists: %+v", n.MailingLists) + } + } + }) + + t.Run("wraps newsletter archive error", func(t *testing.T) { + archiveErr := errors.New("archive failed") + newsletterRepo := newFakeNewsletterRepo() + newsletterRepo.createErr = archiveErr + svc := NewMailService( + newFakeListRepo(list), + newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), + newsletterRepo, + &fakeRenderer{metadata: metadata, body: "body"}, + &fakeSender{}, + "https://example.com", + ) + err := svc.SendToList(context.Background(), "weekly", "raw", nil) + if !errors.Is(err, archiveErr) { + t.Errorf("expected wrapped archive error, got: %v", err) + } + }) } func TestMailService_SendTestMail(t *testing.T) { @@ -153,7 +204,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(), &fakeRenderer{metadata: metadata, body: "preview"}, sender, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), 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) @@ -172,7 +223,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(), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -189,7 +240,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(), renderer, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newFakeNewsletterRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendTestMail(context.Background(), recipientWithToken, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -202,7 +253,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(), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com") + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), 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) @@ -214,6 +265,7 @@ func TestMailService_SendTestMail(t *testing.T) { svc := NewMailService( newFakeListRepo(), newFakeUserRepo(), + newFakeNewsletterRepo(), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, "https://example.com", From b2b2d28d844551b5c4801ea9b125a4b0949cded5 Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Tue, 14 Apr 2026 15:18:00 +0200 Subject: [PATCH 2/3] add newsletter preview functionality and related tests --- api/public.go | 35 ++++++++++++- api/public_test.go | 99 ++++++++++++++++++++++++++++++++++++- db/repository.newsletter.go | 8 ++- domain/ports.go | 2 +- main.go | 2 +- service/fakes_test.go | 2 +- service/mail.go | 32 +++++++++++- service/mail_test.go | 68 +++++++++++++++++++++++++ 8 files changed, 238 insertions(+), 10 deletions(-) diff --git a/api/public.go b/api/public.go index 3ae7f60..a5175e2 100644 --- a/api/public.go +++ b/api/public.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "strings" "github.com/5000K/5000mails/domain" @@ -17,6 +18,10 @@ type Subscriber interface { Unsubscribe(ctx context.Context, unsubscribeToken string) error } +type NewsletterPreviewer interface { + RenderNewsletter(ctx context.Context, id uint, unsubscribeToken string) (string, error) +} + type RedirectPages struct { SubscribeSuccess string SubscribeError string @@ -28,12 +33,13 @@ type RedirectPages struct { type PublicHandler struct { subscriptions Subscriber + newsletters NewsletterPreviewer redirects RedirectPages logger *slog.Logger } -func NewPublicHandler(subscriptions Subscriber, redirects RedirectPages, logger *slog.Logger) *PublicHandler { - return &PublicHandler{subscriptions: subscriptions, redirects: redirects, logger: logger} +func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, redirects RedirectPages, logger *slog.Logger) *PublicHandler { + return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, redirects: redirects, logger: logger} } func (h *PublicHandler) Routes() *http.ServeMux { @@ -41,6 +47,7 @@ func (h *PublicHandler) Routes() *http.ServeMux { mux.HandleFunc("POST /{listName}/subscribe", h.handleSubscribe) mux.HandleFunc("GET /confirm/{token}", h.handleConfirm) mux.HandleFunc("GET /unsubscribe/{token}", h.handleUnsubscribe) + mux.HandleFunc("GET /mail/{id}", h.handleNewsletterPreview) return mux } @@ -96,6 +103,30 @@ func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request redirectOrJSON(w, r, h.redirects.UnsubscribeSuccess, http.StatusOK, map[string]string{"message": "you have been unsubscribed"}) } +func (h *PublicHandler) handleNewsletterPreview(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid newsletter id") + return + } + + token := r.URL.Query().Get("token") + body, err := h.newsletters.RenderNewsletter(r.Context(), uint(id), token) + if err != nil { + h.logger.ErrorContext(r.Context(), "newsletter preview failed", + slog.Uint64("id", id), + slog.Any("error", err), + ) + writeError(w, http.StatusNotFound, "newsletter not found") + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, body) +} + 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 648e01c..5c31489 100644 --- a/api/public_test.go +++ b/api/public_test.go @@ -45,12 +45,26 @@ func (f *fakeSubscriber) Unsubscribe(_ context.Context, token string) error { return f.unsubscribeErr } +type fakeNewsletterPreviewer struct { + body string + err error + + lastID uint + lastToken string +} + +func (f *fakeNewsletterPreviewer) RenderNewsletter(_ context.Context, id uint, token string) (string, error) { + f.lastID = id + f.lastToken = token + return f.body, f.err +} + func newTestHandler(sub *fakeSubscriber) *PublicHandler { - return NewPublicHandler(sub, RedirectPages{}, slog.Default()) + return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, RedirectPages{}, slog.Default()) } func newTestHandlerWithRedirects(sub *fakeSubscriber, redirects RedirectPages) *PublicHandler { - return NewPublicHandler(sub, redirects, slog.Default()) + return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, redirects, slog.Default()) } func TestHandleSubscribe(t *testing.T) { @@ -379,3 +393,84 @@ 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()) + req := httptest.NewRequest(http.MethodGet, "/mail/42", nil) + req.SetPathValue("id", "42") + w := httptest.NewRecorder() + h.handleNewsletterPreview(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Errorf("expected text/html content-type, got %q", ct) + } + if w.Body.String() != "Hello" { + t.Errorf("unexpected body: %s", w.Body) + } + if previewer.lastID != 42 { + t.Errorf("expected id 42, got %d", previewer.lastID) + } + if previewer.lastToken != "" { + t.Errorf("expected empty token, got %q", previewer.lastToken) + } + }) + + t.Run("passes token to previewer when provided", func(t *testing.T) { + previewer := &fakeNewsletterPreviewer{body: ""} + h := NewPublicHandler(&fakeSubscriber{}, previewer, RedirectPages{}, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/mail/7?token=abc123", nil) + req.SetPathValue("id", "7") + w := httptest.NewRecorder() + h.handleNewsletterPreview(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if previewer.lastToken != "abc123" { + t.Errorf("expected token %q, got %q", "abc123", previewer.lastToken) + } + }) + + t.Run("returns 400 on non-numeric id", func(t *testing.T) { + h := NewPublicHandler(&fakeSubscriber{}, &fakeNewsletterPreviewer{}, RedirectPages{}, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/mail/abc", nil) + req.SetPathValue("id", "abc") + w := httptest.NewRecorder() + h.handleNewsletterPreview(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + }) + + 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()) + + for _, token := range []string{"", "some-token"} { + req := httptest.NewRequest(http.MethodGet, "/mail/99?token="+token, nil) + req.SetPathValue("id", "99") + w := httptest.NewRecorder() + h.handleNewsletterPreview(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("token=%q: expected 404, got %d", token, w.Code) + } + } + }) + + t.Run("preview is routed via Routes()", func(t *testing.T) { + previewer := &fakeNewsletterPreviewer{body: "

hi

"} + h := NewPublicHandler(&fakeSubscriber{}, previewer, RedirectPages{}, slog.Default()) + req := httptest.NewRequest(http.MethodGet, "/mail/1", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + }) +} diff --git a/db/repository.newsletter.go b/db/repository.newsletter.go index 54396b7..271b205 100644 --- a/db/repository.newsletter.go +++ b/db/repository.newsletter.go @@ -54,9 +54,13 @@ func (r *MailingListRepository) GetAllSentNewsletters(ctx context.Context) ([]do return ToDomainSentNewsletters(records), nil } -func (r *MailingListRepository) GetSentNewsletterByID(ctx context.Context, id uint) (*domain.SentNewsletter, error) { +func (r *MailingListRepository) GetSentNewsletterByID(ctx context.Context, id uint, withRecipients bool) (*domain.SentNewsletter, error) { var record SentNewsletter - result := r.db.WithContext(ctx).Preload("Recipients").Preload("MailingLists").First(&record, id) + q := r.db.WithContext(ctx).Preload("MailingLists") + if withRecipients { + q = q.Preload("Recipients") + } + result := q.First(&record, id) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to get sent newsletter", slog.Uint64("id", uint64(id)), diff --git a/domain/ports.go b/domain/ports.go index 8971b66..5d7d9e9 100644 --- a/domain/ports.go +++ b/domain/ports.go @@ -28,7 +28,7 @@ type ConfirmationRepository interface { type SentNewsletterRepository interface { CreateSentNewsletter(ctx context.Context, subject, senderName, rawMarkdown string, recipientIDs []uint, listNames []string) (*SentNewsletter, error) GetAllSentNewsletters(ctx context.Context) ([]SentNewsletter, error) - GetSentNewsletterByID(ctx context.Context, id uint) (*SentNewsletter, error) + GetSentNewsletterByID(ctx context.Context, id uint, withRecipients bool) (*SentNewsletter, error) DeleteSentNewsletter(ctx context.Context, id uint) error } diff --git a/main.go b/main.go index b7dc73c..99d4ca4 100644 --- a/main.go +++ b/main.go @@ -69,7 +69,7 @@ func main() { listSvc := service.NewListService(repo, repo) mailSvc := service.NewMailService(repo, repo, repo, rndr, sender, cfg.BaseURL) - publicHandler := api.NewPublicHandler(subscriptionSvc, api.RedirectPages{ + publicHandler := api.NewPublicHandler(subscriptionSvc, mailSvc, api.RedirectPages{ SubscribeSuccess: cfg.Redirects.SubscribeSuccess, SubscribeError: cfg.Redirects.SubscribeError, ConfirmSuccess: cfg.Redirects.ConfirmSuccess, diff --git a/service/fakes_test.go b/service/fakes_test.go index 804f531..cc7d231 100644 --- a/service/fakes_test.go +++ b/service/fakes_test.go @@ -292,7 +292,7 @@ func (r *fakeNewsletterRepo) GetAllSentNewsletters(_ context.Context) ([]domain. return out, nil } -func (r *fakeNewsletterRepo) GetSentNewsletterByID(_ context.Context, id uint) (*domain.SentNewsletter, error) { +func (r *fakeNewsletterRepo) GetSentNewsletterByID(_ context.Context, id uint, _ bool) (*domain.SentNewsletter, error) { if r.getErr != nil { return nil, r.getErr } diff --git a/service/mail.go b/service/mail.go index 0c49a3f..dc22629 100644 --- a/service/mail.go +++ b/service/mail.go @@ -115,7 +115,7 @@ func (s *MailService) AllNewsletters(ctx context.Context) ([]domain.SentNewslett // 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) + newsletter, err := s.newsletters.GetSentNewsletterByID(ctx, id, true) if err != nil { return nil, fmt.Errorf("getting sent newsletter %d: %w", id, err) } @@ -129,3 +129,33 @@ func (s *MailService) DeleteNewsletter(ctx context.Context, id uint) error { } return nil } + +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 { + return "", fmt.Errorf("loading newsletter %d: %w", id, err) + } + + recipient := placeholderUser + if unsubscribeToken != "" { + if u, err := s.users.GetUserByUnsubscribeToken(ctx, unsubscribeToken); err == nil { + recipient = *u + } + } + + data := map[string]any{ + "Recipient": recipient, + "unsubscribeURL": s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken, + } + + _, body, err := s.renderer.Render(&newsletter.RawMarkdown, data) + if err != nil { + return "", fmt.Errorf("rendering newsletter %d: %w", id, err) + } + return body, nil +} diff --git a/service/mail_test.go b/service/mail_test.go index baec664..1e0cc3c 100644 --- a/service/mail_test.go +++ b/service/mail_test.go @@ -276,3 +276,71 @@ func TestMailService_SendTestMail(t *testing.T) { } }) } + +func TestMailService_RenderNewsletter(t *testing.T) { + stored := &domain.SentNewsletter{ID: 1, RawMarkdown: "# Hello"} + user := &domain.User{ID: 1, Name: "Alice", Email: "alice@example.com", UnsubscribeToken: "tok-abc"} + + 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") + + body, err := svc.RenderNewsletter(context.Background(), 1, "tok-abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if body != "rendered" { + t.Errorf("expected body %q, got %q", "rendered", body) + } + got, _ := renderer.lastData["Recipient"].(domain.User) + if got.Email != user.Email { + t.Errorf("expected recipient email %q, got %q", user.Email, got.Email) + } + }) + + 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") + + if _, err := svc.RenderNewsletter(context.Background(), 1, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, _ := renderer.lastData["Recipient"].(domain.User) + if got.Email != placeholderUser.Email { + t.Errorf("expected placeholder email %q, got %q", placeholderUser.Email, got.Email) + } + }) + + 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") + + if _, err := svc.RenderNewsletter(context.Background(), 1, "unknown-token"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, _ := renderer.lastData["Recipient"].(domain.User) + if got.Email != placeholderUser.Email { + t.Errorf("expected placeholder email %q, got %q", placeholderUser.Email, got.Email) + } + }) + + t.Run("wraps newsletter not found error", func(t *testing.T) { + newsletterRepo := newFakeNewsletterRepo() + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), newsletterRepo, &fakeRenderer{}, &fakeSender{}, "https://example.com") + + _, err := svc.RenderNewsletter(context.Background(), 99, "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + 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") + + _, err := svc.RenderNewsletter(context.Background(), 1, "") + if !errors.Is(err, renderErr) { + t.Errorf("expected wrapped render error, got: %v", err) + } + }) +} From e49bdb91492f63baefe919f27b2bbf84cd03be53 Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Tue, 14 Apr 2026 15:45:53 +0200 Subject: [PATCH 3/3] add scheduling --- api/private.go | 168 ++++++++++++++++- api/private_client.go | 115 ++++++++++++ api/private_test.go | 39 +++- cli/cli.go | 220 ++++++++++++++++++++-- cli/cli_test.go | 21 ++- db/connect.go | 2 +- db/model.go | 26 +++ db/repository.scheduled.go | 100 ++++++++++ domain/model.go | 10 + domain/ports.go | 11 ++ go.mod | 6 +- go.sum | 10 + main.go | 5 +- service/schedule.go | 148 +++++++++++++++ service/schedule_test.go | 369 +++++++++++++++++++++++++++++++++++++ 15 files changed, 1219 insertions(+), 31 deletions(-) create mode 100644 db/repository.scheduled.go create mode 100644 service/schedule.go create mode 100644 service/schedule_test.go diff --git a/api/private.go b/api/private.go index 0de9d3f..fcd054d 100644 --- a/api/private.go +++ b/api/private.go @@ -41,20 +41,31 @@ type NewsletterArchive interface { DeleteNewsletter(ctx context.Context, id uint) error } +// ScheduleManager is the private API's view of the scheduling service. +type ScheduleManager interface { + Schedule(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) + List(ctx context.Context) ([]domain.ScheduledMail, error) + Get(ctx context.Context, id uint) (*domain.ScheduledMail, error) + Delete(ctx context.Context, id uint) error + Reschedule(ctx context.Context, id uint, scheduledAt int64) (*domain.ScheduledMail, error) + ReplaceContent(ctx context.Context, id uint, rawMarkdown string) (*domain.ScheduledMail, error) +} + // PrivateHandler serves the private admin API. // When publicKey is non-nil, every request must carry a valid Ed25519 signature. type PrivateHandler struct { lists ListManager mail MailDispatcher newsletters NewsletterArchive + scheduler ScheduleManager publicKey ed25519.PublicKey logger *slog.Logger } // NewPrivateHandler creates a new PrivateHandler. // Pass a nil publicKey to disable request authentication. -func NewPrivateHandler(lists ListManager, mail MailDispatcher, newsletters NewsletterArchive, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler { - return &PrivateHandler{lists: lists, mail: mail, newsletters: newsletters, publicKey: publicKey, logger: logger} +func NewPrivateHandler(lists ListManager, mail MailDispatcher, newsletters NewsletterArchive, scheduler ScheduleManager, publicKey ed25519.PublicKey, logger *slog.Logger) *PrivateHandler { + return &PrivateHandler{lists: lists, mail: mail, newsletters: newsletters, scheduler: scheduler, publicKey: publicKey, logger: logger} } // Routes returns the mux for all private API endpoints. @@ -67,10 +78,16 @@ func (h *PrivateHandler) Routes() *http.ServeMux { mux.Handle("DELETE /lists/{name}", h.auth(h.handleDeleteList)) mux.Handle("GET /lists/{name}/users", h.auth(h.handleListUsers)) mux.Handle("POST /lists/{name}/send", h.auth(h.handleSendToList)) + mux.Handle("POST /lists/{name}/schedule", h.auth(h.handleScheduleMail)) mux.Handle("POST /mail/test", h.auth(h.handleSendTestMail)) mux.Handle("GET /newsletters", h.auth(h.handleAllNewsletters)) mux.Handle("GET /newsletters/{id}", h.auth(h.handleGetNewsletter)) mux.Handle("DELETE /newsletters/{id}", h.auth(h.handleDeleteNewsletter)) + mux.Handle("GET /scheduled", h.auth(h.handleAllScheduled)) + mux.Handle("GET /scheduled/{id}", h.auth(h.handleGetScheduled)) + mux.Handle("DELETE /scheduled/{id}", h.auth(h.handleDeleteScheduled)) + mux.Handle("PUT /scheduled/{id}/schedule", h.auth(h.handleRescheduleMail)) + mux.Handle("PUT /scheduled/{id}/content", h.auth(h.handleReplaceScheduledContent)) return mux } @@ -127,6 +144,26 @@ type newsletterDetailResponse struct { MailingLists []string `json:"mailingLists"` } +type scheduleRequest struct { + Raw string `json:"raw"` + ScheduledAt int64 `json:"scheduledAt"` +} + +type scheduledMailResponse struct { + ID uint `json:"id"` + MailingListName string `json:"mailingListName"` + ScheduledAt int64 `json:"scheduledAt"` + SentAt *int64 `json:"sentAt"` +} + +type rescheduleRequest struct { + ScheduledAt int64 `json:"scheduledAt"` +} + +type replaceContentRequest struct { + Raw string `json:"raw"` +} + // --- handlers --- func (h *PrivateHandler) handleAllLists(w http.ResponseWriter, r *http.Request) { @@ -343,6 +380,133 @@ func (h *PrivateHandler) handleSendTestMail(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, map[string]string{"message": "test mail sent"}) } +func (h *PrivateHandler) handleScheduleMail(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + + var body scheduleRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Raw == "" || body.ScheduledAt == 0 { + writeError(w, http.StatusBadRequest, "raw and scheduledAt are required") + return + } + + m, err := h.scheduler.Schedule(r.Context(), name, body.Raw, body.ScheduledAt) + if err != nil { + h.logger.ErrorContext(r.Context(), "schedule mail failed", slog.String("list", name), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to schedule mail") + return + } + + writeJSON(w, http.StatusCreated, scheduledMailResponse{ + ID: m.ID, + MailingListName: m.MailingListName, + ScheduledAt: m.ScheduledAt, + SentAt: m.SentAt, + }) +} + +func (h *PrivateHandler) handleAllScheduled(w http.ResponseWriter, r *http.Request) { + mails, err := h.scheduler.List(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "list scheduled mails failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load scheduled mails") + return + } + resp := make([]scheduledMailResponse, len(mails)) + for i, m := range mails { + resp[i] = scheduledMailResponse{ + ID: m.ID, + MailingListName: m.MailingListName, + ScheduledAt: m.ScheduledAt, + SentAt: m.SentAt, + } + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *PrivateHandler) handleGetScheduled(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + m, err := h.scheduler.Get(r.Context(), uint(id)) + if err != nil { + h.logger.ErrorContext(r.Context(), "get scheduled mail failed", slog.Uint64("id", id), slog.Any("error", err)) + writeError(w, http.StatusNotFound, "scheduled mail not found") + return + } + writeJSON(w, http.StatusOK, scheduledMailResponse{ + ID: m.ID, + MailingListName: m.MailingListName, + ScheduledAt: m.ScheduledAt, + SentAt: m.SentAt, + }) +} + +func (h *PrivateHandler) handleDeleteScheduled(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.scheduler.Delete(r.Context(), uint(id)); err != nil { + h.logger.ErrorContext(r.Context(), "delete scheduled mail failed", slog.Uint64("id", id), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to delete scheduled mail") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *PrivateHandler) handleRescheduleMail(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var body rescheduleRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ScheduledAt == 0 { + writeError(w, http.StatusBadRequest, "scheduledAt is required") + return + } + m, err := h.scheduler.Reschedule(r.Context(), uint(id), body.ScheduledAt) + if err != nil { + h.logger.ErrorContext(r.Context(), "reschedule mail failed", slog.Uint64("id", id), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to reschedule mail") + return + } + writeJSON(w, http.StatusOK, scheduledMailResponse{ + ID: m.ID, + MailingListName: m.MailingListName, + ScheduledAt: m.ScheduledAt, + SentAt: m.SentAt, + }) +} + +func (h *PrivateHandler) handleReplaceScheduledContent(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + var body replaceContentRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Raw == "" { + writeError(w, http.StatusBadRequest, "raw is required") + return + } + m, err := h.scheduler.ReplaceContent(r.Context(), uint(id), body.Raw) + if err != nil { + h.logger.ErrorContext(r.Context(), "replace scheduled content failed", slog.Uint64("id", id), slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to replace content") + return + } + writeJSON(w, http.StatusOK, scheduledMailResponse{ + ID: m.ID, + MailingListName: m.MailingListName, + ScheduledAt: m.ScheduledAt, + SentAt: m.SentAt, + }) +} + // --- auth middleware --- const signatureWindow = 5 * time.Minute diff --git a/api/private_client.go b/api/private_client.go index 13bcaae..94b49c8 100644 --- a/api/private_client.go +++ b/api/private_client.go @@ -203,6 +203,121 @@ func (c *PrivateClient) SendTestMail(ctx context.Context, recipient RecipientInp return nil } +// 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"` +} + +// 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} + 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) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusCreated); err != nil { + return nil, fmt.Errorf("schedule mail: %w", err) + } + var out ScheduledMailItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("schedule mail: decode response: %w", err) + } + return &out, nil +} + +// GetAllScheduled returns all scheduled mails. +func (c *PrivateClient) GetAllScheduled(ctx context.Context) ([]ScheduledMailItem, error) { + resp, err := c.do(ctx, http.MethodGet, "/scheduled", nil) + if err != nil { + return nil, fmt.Errorf("get all scheduled: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("get all scheduled: %w", err) + } + var out []ScheduledMailItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("get all scheduled: decode response: %w", err) + } + return out, nil +} + +// GetScheduled returns a single scheduled mail by ID. +func (c *PrivateClient) GetScheduled(ctx context.Context, id uint) (*ScheduledMailItem, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/scheduled/%d", id), nil) + if err != nil { + return nil, fmt.Errorf("get scheduled: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("get scheduled: %w", err) + } + var out ScheduledMailItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("get scheduled: decode response: %w", err) + } + return &out, nil +} + +// DeleteScheduled deletes a scheduled mail by ID. +func (c *PrivateClient) DeleteScheduled(ctx context.Context, id uint) error { + resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/scheduled/%d", id), nil) + if err != nil { + return fmt.Errorf("delete scheduled: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusNoContent); err != nil { + return fmt.Errorf("delete scheduled: %w", err) + } + return nil +} + +// RescheduleMail changes the delivery time of a scheduled mail. +// scheduledAt is a unix timestamp (UTC). +func (c *PrivateClient) RescheduleMail(ctx context.Context, id uint, scheduledAt int64) (*ScheduledMailItem, error) { + resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/scheduled/%d/schedule", id), map[string]any{"scheduledAt": scheduledAt}) + if err != nil { + return nil, fmt.Errorf("reschedule mail: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("reschedule mail: %w", err) + } + var out ScheduledMailItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("reschedule mail: decode response: %w", err) + } + return &out, nil +} + +// ReplaceScheduledContent replaces the markdown body of a scheduled mail. +func (c *PrivateClient) ReplaceScheduledContent(ctx context.Context, id uint, raw string) (*ScheduledMailItem, error) { + resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/scheduled/%d/content", id), map[string]any{"raw": raw}) + if err != nil { + return nil, fmt.Errorf("replace scheduled content: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("replace scheduled content: %w", err) + } + var out ScheduledMailItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("replace scheduled content: decode response: %w", err) + } + return &out, nil +} + // do builds and executes a signed HTTP request. func (c *PrivateClient) do(ctx context.Context, method, path string, payload any) (*http.Response, error) { var bodyBytes []byte diff --git a/api/private_test.go b/api/private_test.go index bac1aa1..b53dd2d 100644 --- a/api/private_test.go +++ b/api/private_test.go @@ -202,8 +202,27 @@ func (f *fakeNewsletterArchive) DeleteNewsletter(_ context.Context, id uint) err // --- helpers --- +type fakeScheduleManager struct{} + +func (f *fakeScheduleManager) Schedule(_ context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: 1, MailingListName: mailingListName, RawMarkdown: rawMarkdown, ScheduledAt: scheduledAt}, nil +} +func (f *fakeScheduleManager) List(_ context.Context) ([]domain.ScheduledMail, error) { + return nil, nil +} +func (f *fakeScheduleManager) Get(_ context.Context, id uint) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: id}, nil +} +func (f *fakeScheduleManager) Delete(_ context.Context, _ uint) error { return nil } +func (f *fakeScheduleManager) Reschedule(_ context.Context, id uint, scheduledAt int64) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: id, ScheduledAt: scheduledAt}, nil +} +func (f *fakeScheduleManager) ReplaceContent(_ context.Context, id uint, rawMarkdown string) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: id, RawMarkdown: rawMarkdown}, nil +} + func newPrivateTestHandler(lists *fakeListManager, mail *fakeMailDispatcher, pub ed25519.PublicKey) *PrivateHandler { - return NewPrivateHandler(lists, mail, newFakeNewsletterArchive(), pub, slog.Default()) + return NewPrivateHandler(lists, mail, newFakeNewsletterArchive(), &fakeScheduleManager{}, pub, slog.Default()) } func privateRequest(t *testing.T, h *PrivateHandler, method, target string, body any) *httptest.ResponseRecorder { @@ -560,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, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, 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) @@ -581,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, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) w := privateRequest(t, h, http.MethodGet, "/newsletters", nil) if w.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d", w.Code) @@ -604,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, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/newsletters/42", nil) req.SetPathValue("id", "42") w := httptest.NewRecorder() @@ -630,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(), nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/newsletters/99", nil) req.SetPathValue("id", "99") w := httptest.NewRecorder() @@ -641,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(), nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/newsletters/abc", nil) req.SetPathValue("id", "abc") w := httptest.NewRecorder() @@ -658,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, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) req := httptest.NewRequest(http.MethodDelete, "/newsletters/7", nil) req.SetPathValue("id", "7") w := httptest.NewRecorder() @@ -674,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, nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, nl, &fakeScheduleManager{}, nil, slog.Default()) req := httptest.NewRequest(http.MethodDelete, "/newsletters/7", nil) req.SetPathValue("id", "7") w := httptest.NewRecorder() @@ -685,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(), nil, slog.Default()) + h := NewPrivateHandler(newFakeListManager(), &fakeMailDispatcher{}, newFakeNewsletterArchive(), &fakeScheduleManager{}, nil, slog.Default()) req := httptest.NewRequest(http.MethodDelete, "/newsletters/bad", nil) req.SetPathValue("id", "bad") w := httptest.NewRecorder() @@ -753,7 +772,7 @@ func TestPrivateClient_Integration(t *testing.T) { } mail := &fakeMailDispatcher{} - srv := httptest.NewServer(NewPrivateHandler(m, mail, newFakeNewsletterArchive(), pub, slog.Default()).Routes()) + srv := httptest.NewServer(NewPrivateHandler(m, mail, newFakeNewsletterArchive(), &fakeScheduleManager{}, pub, slog.Default()).Routes()) defer srv.Close() client := NewPrivateClient(srv.URL, priv) diff --git a/cli/cli.go b/cli/cli.go index 91f299e..38ce2af 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -7,7 +7,9 @@ import ( "fmt" "io" "os" + "strconv" "strings" + "time" "github.com/5000K/5000mails/api" ) @@ -19,20 +21,32 @@ Global flags: --private-key-path PATH Path to Ed25519 private key file for authentication Commands: - list all List all mailing lists - list create --name NAME Create a mailing list - list get --name NAME Get list details and stats - list rename --name NAME --new-name NEWNAME Rename a mailing list - list delete --name NAME Delete a mailing list - list users --name NAME List subscribers - - send list --list NAME --raw-path PATH Send mail to all confirmed subscribers - send test --name NAME --email EMAIL Send a test mail - --raw-path PATH - - keys generate [--out-dir DIR] Generate an Ed25519 key pair - -Options for send commands: + list all List all mailing lists + list create --name NAME Create a mailing list + list get --name NAME Get list details and stats + list rename --name NAME --new-name NEWNAME Rename a mailing list + list delete --name NAME Delete a mailing list + list users --name NAME List subscribers + + send list --list NAME --raw-path PATH Send mail immediately + [--at ISO8601] [--timezone TZ] Schedule instead of sending immediately + send test --email EMAIL --raw-path PATH Send a test mail + [--name NAME] + + schedule list List all scheduled mails + schedule get --id ID Get a scheduled mail + schedule delete --id ID Delete a scheduled mail + schedule reschedule --id ID --at ISO8601 Reschedule a mail + [--timezone TZ] + schedule content --id ID --raw-path PATH Replace content of a scheduled mail + + keys generate [--out-dir DIR] Generate an Ed25519 key pair + +Time flags: + --at ISO8601 ISO 8601 datetime, assumed UTC (e.g. 2026-04-15T10:00:00) + --timezone TZ IANA timezone name overriding the UTC default (e.g. Europe/Berlin) + +Options for send/schedule commands: --data KEY=VALUE Template variable (repeatable) ` @@ -63,6 +77,8 @@ func Run(args []string, stdout, stderr io.Writer) int { return runList(rest, serverURL, keyPath, stdout, stderr) case "send": return runSend(rest, serverURL, keyPath, stdout, stderr) + case "schedule": + return runSchedule(rest, serverURL, keyPath, stdout, stderr) case "keys": return runKeys(rest, stdout, stderr) case "help", "--help", "-h": @@ -251,7 +267,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 [--data KEY=VALUE ...]") + fmt.Fprintln(stderr, "usage: 5kmcli send list --list NAME --raw-path PATH [--at ISO8601] [--timezone TZ] [--data KEY=VALUE ...]") return 1 } raw, err := os.ReadFile(rawPath) @@ -259,6 +275,23 @@ func sendList(args []string, client *api.PrivateClient, stdout, stderr io.Writer fmt.Fprintf(stderr, "error reading raw file: %v\n", err) return 1 } + + atStr := flagValue(args, "--at") + if atStr != "" { + scheduledAt, err := parseTimestamp(atStr, flagValue(args, "--timezone")) + if err != nil { + fmt.Fprintf(stderr, "error parsing --at: %v\n", err) + return 1 + } + m, err := client.ScheduleMail(context.Background(), listName, string(raw), scheduledAt) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, m) + return 0 + } + data := collectData(args) if err := client.SendToList(context.Background(), listName, string(raw), data); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) @@ -355,3 +388,160 @@ func printJSON(w io.Writer, v any) { enc.SetIndent("", " ") enc.Encode(v) } + +// parseTimestamp converts an ISO 8601 datetime string to a unix timestamp. +// If tz is empty the input is assumed to be UTC. +func parseTimestamp(raw, tz string) (int64, error) { + loc := time.UTC + if tz != "" { + var err error + loc, err = time.LoadLocation(tz) + if err != nil { + return 0, fmt.Errorf("unknown timezone %q: %w", tz, err) + } + } + + formats := []string{ + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.ParseInLocation(f, raw, loc); err == nil { + return t.Unix(), nil + } + } + return 0, fmt.Errorf("cannot parse %q as ISO 8601 datetime", raw) +} + +func runSchedule(args []string, serverURL, keyPath string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "usage: 5kmcli schedule [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 scheduleList(client, stdout, stderr) + case "get": + return scheduleGet(flags, client, stdout, stderr) + case "delete": + return scheduleDelete(flags, client, stderr) + case "reschedule": + return scheduleReschedule(flags, client, stdout, stderr) + case "content": + return scheduleContent(flags, client, stdout, stderr) + default: + fmt.Fprintf(stderr, "unknown schedule subcommand: %s\n", sub) + return 1 + } +} + +func scheduleList(client *api.PrivateClient, stdout, stderr io.Writer) int { + mails, err := client.GetAllScheduled(context.Background()) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, mails) + return 0 +} + +func scheduleGet(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + idStr := flagValue(args, "--id") + if idStr == "" { + fmt.Fprintln(stderr, "usage: 5kmcli schedule get --id ID") + return 1 + } + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + fmt.Fprintf(stderr, "invalid id: %v\n", err) + return 1 + } + m, err := client.GetScheduled(context.Background(), uint(id)) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, m) + return 0 +} + +func scheduleDelete(args []string, client *api.PrivateClient, stderr io.Writer) int { + idStr := flagValue(args, "--id") + if idStr == "" { + fmt.Fprintln(stderr, "usage: 5kmcli schedule delete --id ID") + return 1 + } + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + fmt.Fprintf(stderr, "invalid id: %v\n", err) + return 1 + } + if err := client.DeleteScheduled(context.Background(), uint(id)); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 +} + +func scheduleReschedule(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + idStr := flagValue(args, "--id") + atStr := flagValue(args, "--at") + if idStr == "" || atStr == "" { + fmt.Fprintln(stderr, "usage: 5kmcli schedule reschedule --id ID --at ISO8601 [--timezone TZ]") + return 1 + } + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + fmt.Fprintf(stderr, "invalid id: %v\n", err) + return 1 + } + scheduledAt, err := parseTimestamp(atStr, flagValue(args, "--timezone")) + if err != nil { + fmt.Fprintf(stderr, "error parsing --at: %v\n", err) + return 1 + } + m, err := client.RescheduleMail(context.Background(), uint(id), scheduledAt) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, m) + return 0 +} + +func scheduleContent(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { + idStr := flagValue(args, "--id") + rawPath := flagValue(args, "--raw-path") + if idStr == "" || rawPath == "" { + fmt.Fprintln(stderr, "usage: 5kmcli schedule content --id ID --raw-path PATH") + return 1 + } + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + fmt.Fprintf(stderr, "invalid id: %v\n", err) + return 1 + } + raw, err := os.ReadFile(rawPath) + if err != nil { + fmt.Fprintf(stderr, "error reading raw file: %v\n", err) + return 1 + } + m, err := client.ReplaceScheduledContent(context.Background(), uint(id), string(raw)) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, m) + return 0 +} diff --git a/cli/cli_test.go b/cli/cli_test.go index c66c8a1..4d0aefd 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -134,10 +134,29 @@ 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{}, pub, slog.Default()) + h := api.NewPrivateHandler(lm, md, &fakeNewsletterArchive{}, &fakeScheduleManager{}, 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) { + return &domain.ScheduledMail{ID: 1, MailingListName: list, RawMarkdown: raw, ScheduledAt: at}, nil +} +func (f *fakeScheduleManager) List(_ context.Context) ([]domain.ScheduledMail, error) { + return nil, nil +} +func (f *fakeScheduleManager) Get(_ context.Context, id uint) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: id}, nil +} +func (f *fakeScheduleManager) Delete(_ context.Context, _ uint) error { return nil } +func (f *fakeScheduleManager) Reschedule(_ context.Context, id uint, at int64) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: id, ScheduledAt: at}, nil +} +func (f *fakeScheduleManager) ReplaceContent(_ context.Context, id uint, raw string) (*domain.ScheduledMail, error) { + return &domain.ScheduledMail{ID: id, RawMarkdown: raw}, nil +} + func tmpRawFile(t *testing.T, content string) string { t.Helper() p := filepath.Join(t.TempDir(), "mail.md") diff --git a/db/connect.go b/db/connect.go index ce5340f..49349d8 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{}); err != nil { + if err := database.AutoMigrate(&MailingList{}, &User{}, &Confirmation{}, &SentNewsletter{}, &ScheduledMail{}); err != nil { return fmt.Errorf("auto-migrating database: %w", err) } return nil diff --git a/db/model.go b/db/model.go index 07bb1ba..148af5e 100644 --- a/db/model.go +++ b/db/model.go @@ -109,3 +109,29 @@ func ToDomainSentNewsletters(newsletters []SentNewsletter) []domain.SentNewslett } return result } + +type ScheduledMail struct { + gorm.Model + MailingListName string `gorm:"not null;index"` + RawMarkdown string `gorm:"not null"` + ScheduledAt int64 `gorm:"not null;index"` + SentAt *int64 +} + +func ToDomainScheduledMail(m *ScheduledMail) *domain.ScheduledMail { + return &domain.ScheduledMail{ + ID: m.ID, + MailingListName: m.MailingListName, + RawMarkdown: m.RawMarkdown, + ScheduledAt: m.ScheduledAt, + SentAt: m.SentAt, + } +} + +func ToDomainScheduledMails(mails []ScheduledMail) []domain.ScheduledMail { + result := make([]domain.ScheduledMail, len(mails)) + for i := range mails { + result[i] = *ToDomainScheduledMail(&mails[i]) + } + return result +} diff --git a/db/repository.scheduled.go b/db/repository.scheduled.go new file mode 100644 index 0000000..23a53dd --- /dev/null +++ b/db/repository.scheduled.go @@ -0,0 +1,100 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + + "github.com/5000K/5000mails/domain" +) + +func (r *MailingListRepository) CreateScheduledMail(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*domain.ScheduledMail, error) { + m := &ScheduledMail{ + MailingListName: mailingListName, + RawMarkdown: rawMarkdown, + ScheduledAt: scheduledAt, + } + result := r.db.WithContext(ctx).Create(m) + if result.Error != nil { + return nil, fmt.Errorf("creating scheduled mail for list %q: %w", mailingListName, result.Error) + } + return ToDomainScheduledMail(m), nil +} + +func (r *MailingListRepository) GetAllScheduledMails(ctx context.Context) ([]domain.ScheduledMail, error) { + var mails []ScheduledMail + result := r.db.WithContext(ctx).Order("scheduled_at asc").Find(&mails) + if result.Error != nil { + return nil, fmt.Errorf("listing scheduled mails: %w", result.Error) + } + return ToDomainScheduledMails(mails), nil +} + +func (r *MailingListRepository) GetScheduledMailByID(ctx context.Context, id uint) (*domain.ScheduledMail, error) { + var m ScheduledMail + result := r.db.WithContext(ctx).First(&m, id) + if result.Error != nil { + return nil, fmt.Errorf("getting scheduled mail %d: %w", id, result.Error) + } + return ToDomainScheduledMail(&m), nil +} + +func (r *MailingListRepository) GetPendingScheduledMails(ctx context.Context, now int64) ([]domain.ScheduledMail, error) { + var mails []ScheduledMail + result := r.db.WithContext(ctx). + Where("scheduled_at <= ? AND sent_at IS NULL", now). + Order("scheduled_at asc"). + Find(&mails) + if result.Error != nil { + return nil, fmt.Errorf("getting pending scheduled mails: %w", result.Error) + } + return ToDomainScheduledMails(mails), nil +} + +func (r *MailingListRepository) UpdateScheduledMailTime(ctx context.Context, id uint, scheduledAt int64) (*domain.ScheduledMail, error) { + result := r.db.WithContext(ctx).Model(&ScheduledMail{}).Where("id = ?", id).Update("scheduled_at", scheduledAt) + if result.Error != nil { + return nil, fmt.Errorf("rescheduling mail %d: %w", id, result.Error) + } + if result.RowsAffected == 0 { + return nil, fmt.Errorf("rescheduling mail %d: not found", id) + } + return r.GetScheduledMailByID(ctx, id) +} + +func (r *MailingListRepository) UpdateScheduledMailContent(ctx context.Context, id uint, rawMarkdown string) (*domain.ScheduledMail, error) { + result := r.db.WithContext(ctx).Model(&ScheduledMail{}).Where("id = ?", id).Update("raw_markdown", rawMarkdown) + if result.Error != nil { + return nil, fmt.Errorf("updating content of scheduled mail %d: %w", id, result.Error) + } + if result.RowsAffected == 0 { + return nil, fmt.Errorf("updating content of scheduled mail %d: not found", id) + } + return r.GetScheduledMailByID(ctx, id) +} + +func (r *MailingListRepository) MarkScheduledMailSent(ctx context.Context, id uint, sentAt int64) error { + result := r.db.WithContext(ctx).Model(&ScheduledMail{}).Where("id = ?", id).Update("sent_at", sentAt) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to mark scheduled mail as sent", + slog.Uint64("id", uint64(id)), + slog.Any("error", result.Error), + ) + return fmt.Errorf("marking scheduled mail %d as sent: %w", id, result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("marking scheduled mail %d as sent: not found", id) + } + return nil +} + +func (r *MailingListRepository) DeleteScheduledMail(ctx context.Context, id uint) error { + result := r.db.WithContext(ctx).Delete(&ScheduledMail{}, id) + if result.Error != nil { + return fmt.Errorf("deleting scheduled mail %d: %w", id, result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("deleting scheduled mail %d: not found", id) + } + return nil +} diff --git a/domain/model.go b/domain/model.go index facccf6..059d845 100644 --- a/domain/model.go +++ b/domain/model.go @@ -50,3 +50,13 @@ type SentNewsletter struct { Recipients []User MailingLists []MailingList } + +// 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 +} diff --git a/domain/ports.go b/domain/ports.go index 5d7d9e9..42a142b 100644 --- a/domain/ports.go +++ b/domain/ports.go @@ -39,3 +39,14 @@ type Renderer interface { type Sender interface { SendMail(ctx context.Context, metadata MailMetadata, body string, recipient User) error } + +type ScheduledMailRepository interface { + CreateScheduledMail(ctx context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*ScheduledMail, error) + GetAllScheduledMails(ctx context.Context) ([]ScheduledMail, error) + GetScheduledMailByID(ctx context.Context, id uint) (*ScheduledMail, error) + GetPendingScheduledMails(ctx context.Context, now int64) ([]ScheduledMail, error) + UpdateScheduledMailTime(ctx context.Context, id uint, scheduledAt int64) (*ScheduledMail, error) + UpdateScheduledMailContent(ctx context.Context, id uint, rawMarkdown string) (*ScheduledMail, error) + MarkScheduledMailSent(ctx context.Context, id uint, sentAt int64) error + DeleteScheduledMail(ctx context.Context, id uint) error +} diff --git a/go.mod b/go.mod index 4ad4d5a..a45e9ae 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,9 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect + github.com/go-co-op/gocron/v2 v2.20.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -22,8 +24,10 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect - github.com/stretchr/testify v1.10.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/wneessen/go-mail v0.7.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/go.sum b/go.sum index cb96047..357bb61 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,12 @@ github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc= +github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -23,15 +27,21 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= diff --git a/main.go b/main.go index 99d4ca4..b0e07f0 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,8 @@ func main() { subscriptionSvc := service.NewSubscriptionService(repo, repo, repo, rndr, sender, string(confirmRaw), cfg.BaseURL) listSvc := service.NewListService(repo, repo) mailSvc := service.NewMailService(repo, repo, repo, rndr, sender, cfg.BaseURL) + schedulingSvc := service.NewSchedulingService(repo, mailSvc, 30*time.Second, logger) + schedulingSvc.Start() publicHandler := api.NewPublicHandler(subscriptionSvc, mailSvc, api.RedirectPages{ SubscribeSuccess: cfg.Redirects.SubscribeSuccess, @@ -90,7 +92,7 @@ func main() { logger.Warn("private API authentication disabled - no public key configured") } - privateHandler := api.NewPrivateHandler(listSvc, mailSvc, mailSvc, publicKey, logger) + privateHandler := api.NewPrivateHandler(listSvc, mailSvc, mailSvc, schedulingSvc, publicKey, logger) publicServer := &http.Server{Addr: cfg.PublicAddr, Handler: publicHandler.Routes()} privateServer := &http.Server{Addr: cfg.PrivateAddr, Handler: privateHandler.Routes()} @@ -125,4 +127,5 @@ func main() { } logger.Info("servers stopped") + schedulingSvc.Stop() } diff --git a/service/schedule.go b/service/schedule.go new file mode 100644 index 0000000..6ff8c8a --- /dev/null +++ b/service/schedule.go @@ -0,0 +1,148 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "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 +} + +// 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 + interval time.Duration + logger *slog.Logger + mu sync.Mutex + 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, + mailer: mailer, + interval: interval, + logger: logger, + stop: make(chan struct{}), + } +} + +// 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) +} + +func (s *SchedulingService) loop() { + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for { + select { + case <-s.stop: + return + case <-ticker.C: + s.dispatchDue() + } + } +} + +func (s *SchedulingService) dispatchDue() { + s.mu.Lock() + defer s.mu.Unlock() + + ctx := context.Background() + now := time.Now().Unix() + + pending, err := s.repo.GetPendingScheduledMails(ctx, now) + if err != nil { + s.logger.ErrorContext(ctx, "fetching pending scheduled mails", slog.Any("error", err)) + return + } + + for _, m := range pending { + if err := s.mailer.SendToList(ctx, m.MailingListName, m.RawMarkdown, nil); err != nil { + s.logger.ErrorContext(ctx, "sending scheduled mail", + slog.Uint64("id", uint64(m.ID)), + slog.String("list", m.MailingListName), + slog.Any("error", err), + ) + continue + } + sentAt := time.Now().Unix() + if err := s.repo.MarkScheduledMailSent(ctx, m.ID, sentAt); err != nil { + s.logger.ErrorContext(ctx, "marking scheduled mail as sent", + slog.Uint64("id", uint64(m.ID)), + slog.Any("error", err), + ) + } + } +} + +// 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) + 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 { + return nil, fmt.Errorf("listing scheduled mails: %w", err) + } + 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 { + return nil, fmt.Errorf("getting scheduled mail %d: %w", id, err) + } + 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) + } + 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 { + return nil, fmt.Errorf("rescheduling mail %d: %w", id, err) + } + 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 { + return nil, fmt.Errorf("replacing content of scheduled mail %d: %w", id, err) + } + return m, nil +} diff --git a/service/schedule_test.go b/service/schedule_test.go new file mode 100644 index 0000000..99f64fa --- /dev/null +++ b/service/schedule_test.go @@ -0,0 +1,369 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/5000K/5000mails/domain" +) + +// fakeScheduledMailRepo is an in-memory ScheduledMailRepository. +type fakeScheduledMailRepo struct { + mu sync.Mutex + mails map[uint]*domain.ScheduledMail + nextID uint + + createErr error + getAllErr error + getErr error + pendingErr error + updateErr error + markErr error + deleteErr error +} + +func newFakeScheduledMailRepo(seed ...*domain.ScheduledMail) *fakeScheduledMailRepo { + r := &fakeScheduledMailRepo{mails: make(map[uint]*domain.ScheduledMail), nextID: 1} + for _, m := range seed { + r.mails[m.ID] = m + if m.ID >= r.nextID { + r.nextID = m.ID + 1 + } + } + return r +} + +func (r *fakeScheduledMailRepo) CreateScheduledMail(_ context.Context, mailingListName, rawMarkdown string, scheduledAt int64) (*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} + r.nextID++ + r.mails[m.ID] = m + return m, nil +} + +func (r *fakeScheduledMailRepo) GetAllScheduledMails(_ context.Context) ([]domain.ScheduledMail, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getAllErr != nil { + return nil, r.getAllErr + } + out := make([]domain.ScheduledMail, 0, len(r.mails)) + for _, m := range r.mails { + out = append(out, *m) + } + return out, nil +} + +func (r *fakeScheduledMailRepo) GetScheduledMailByID(_ context.Context, id uint) (*domain.ScheduledMail, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.getErr != nil { + return nil, r.getErr + } + m, ok := r.mails[id] + if !ok { + return nil, fmt.Errorf("scheduled mail %d not found", id) + } + return m, nil +} + +func (r *fakeScheduledMailRepo) GetPendingScheduledMails(_ context.Context, now int64) ([]domain.ScheduledMail, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.pendingErr != nil { + return nil, r.pendingErr + } + var out []domain.ScheduledMail + for _, m := range r.mails { + if m.ScheduledAt <= now && m.SentAt == nil { + out = append(out, *m) + } + } + return out, nil +} + +func (r *fakeScheduledMailRepo) UpdateScheduledMailTime(_ context.Context, id uint, scheduledAt int64) (*domain.ScheduledMail, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.updateErr != nil { + return nil, r.updateErr + } + m, ok := r.mails[id] + if !ok { + return nil, fmt.Errorf("scheduled mail %d not found", id) + } + m.ScheduledAt = scheduledAt + return m, nil +} + +func (r *fakeScheduledMailRepo) UpdateScheduledMailContent(_ context.Context, id uint, rawMarkdown string) (*domain.ScheduledMail, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.updateErr != nil { + return nil, r.updateErr + } + m, ok := r.mails[id] + if !ok { + return nil, fmt.Errorf("scheduled mail %d not found", id) + } + m.RawMarkdown = rawMarkdown + return m, nil +} + +func (r *fakeScheduledMailRepo) MarkScheduledMailSent(_ context.Context, id uint, sentAt int64) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.markErr != nil { + return r.markErr + } + m, ok := r.mails[id] + if !ok { + return fmt.Errorf("scheduled mail %d not found", id) + } + m.SentAt = &sentAt + return nil +} + +func (r *fakeScheduledMailRepo) DeleteScheduledMail(_ context.Context, id uint) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.deleteErr != nil { + return r.deleteErr + } + if _, ok := r.mails[id]; !ok { + return fmt.Errorf("scheduled mail %d not found", id) + } + delete(r.mails, id) + return nil +} + +// fakeListMailSender records SendToList calls. +type fakeListMailSender struct { + mu sync.Mutex + calls []listSendCall + err error +} + +type listSendCall struct { + listName string + raw string +} + +func (s *fakeListMailSender) SendToList(_ context.Context, listName string, raw 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}) + return nil +} + +func (s *fakeListMailSender) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.calls) +} + +// --- tests --- + +func TestSchedulingService_Schedule(t *testing.T) { + repo := newFakeScheduledMailRepo() + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + m, err := svc.Schedule(context.Background(), "newsletter", "# Hello", 1_000_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.MailingListName != "newsletter" { + t.Errorf("want list newsletter, got %q", m.MailingListName) + } + if m.ScheduledAt != 1_000_000 { + t.Errorf("want scheduledAt 1000000, got %d", m.ScheduledAt) + } + if m.SentAt != nil { + t.Error("new scheduled mail should not be marked as sent") + } +} + +func TestSchedulingService_Schedule_RepoError(t *testing.T) { + repo := newFakeScheduledMailRepo() + repo.createErr = fmt.Errorf("db error") + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + _, err := svc.Schedule(context.Background(), "newsletter", "# Hello", 1_000_000) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSchedulingService_List(t *testing.T) { + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "a", RawMarkdown: "x", ScheduledAt: 100}, + &domain.ScheduledMail{ID: 2, MailingListName: "b", RawMarkdown: "y", ScheduledAt: 200}, + ) + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + mails, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(mails) != 2 { + t.Errorf("want 2 mails, got %d", len(mails)) + } +} + +func TestSchedulingService_Get(t *testing.T) { + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "x", RawMarkdown: "raw", ScheduledAt: 42}, + ) + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + m, err := svc.Get(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.ID != 1 || m.RawMarkdown != "raw" { + t.Errorf("unexpected mail: %+v", m) + } +} + +func TestSchedulingService_Get_NotFound(t *testing.T) { + svc := newTestSchedulingService(newFakeScheduledMailRepo(), &fakeListMailSender{}) + _, err := svc.Get(context.Background(), 99) + if err == nil { + t.Fatal("expected error for missing id") + } +} + +func TestSchedulingService_Delete(t *testing.T) { + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "x", RawMarkdown: "raw", ScheduledAt: 42}, + ) + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + if err := svc.Delete(context.Background(), 1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + mails, _ := repo.GetAllScheduledMails(context.Background()) + if len(mails) != 0 { + t.Error("mail should have been deleted") + } +} + +func TestSchedulingService_Reschedule(t *testing.T) { + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "x", RawMarkdown: "raw", ScheduledAt: 100}, + ) + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + m, err := svc.Reschedule(context.Background(), 1, 9999) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.ScheduledAt != 9999 { + t.Errorf("want scheduledAt 9999, got %d", m.ScheduledAt) + } +} + +func TestSchedulingService_ReplaceContent(t *testing.T) { + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "x", RawMarkdown: "old", ScheduledAt: 100}, + ) + svc := newTestSchedulingService(repo, &fakeListMailSender{}) + + m, err := svc.ReplaceContent(context.Background(), 1, "# New content") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.RawMarkdown != "# New content" { + t.Errorf("content not replaced, got %q", m.RawMarkdown) + } +} + +func TestSchedulingService_DispatchesDueMails(t *testing.T) { + past := time.Now().Add(-1 * time.Hour).Unix() + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "newsletter", RawMarkdown: "# Hi", ScheduledAt: past}, + ) + mailer := &fakeListMailSender{} + svc := newTestSchedulingService(repo, mailer) + + svc.dispatchDue() + + if mailer.callCount() != 1 { + t.Fatalf("expected 1 send call, got %d", mailer.callCount()) + } + if mailer.calls[0].listName != "newsletter" { + t.Errorf("unexpected list name: %s", mailer.calls[0].listName) + } + + m, _ := repo.GetScheduledMailByID(context.Background(), 1) + if m.SentAt == nil { + t.Error("mail should be marked as sent after dispatch") + } +} + +func TestSchedulingService_DoesNotDispatchFutureMails(t *testing.T) { + future := time.Now().Add(1 * time.Hour).Unix() + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "newsletter", RawMarkdown: "# Hi", ScheduledAt: future}, + ) + mailer := &fakeListMailSender{} + svc := newTestSchedulingService(repo, mailer) + + svc.dispatchDue() + + if mailer.callCount() != 0 { + t.Errorf("expected no send calls for future mail, got %d", mailer.callCount()) + } +} + +func TestSchedulingService_DoesNotRedispatchSentMails(t *testing.T) { + past := time.Now().Add(-1 * time.Hour).Unix() + sentAt := past + 10 + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "newsletter", RawMarkdown: "# Hi", ScheduledAt: past, SentAt: &sentAt}, + ) + mailer := &fakeListMailSender{} + svc := newTestSchedulingService(repo, mailer) + + svc.dispatchDue() + + if mailer.callCount() != 0 { + t.Errorf("expected no send calls for already-sent mail, got %d", mailer.callCount()) + } +} + +func TestSchedulingService_SendErrorDoesNotMarkAsSent(t *testing.T) { + past := time.Now().Add(-1 * time.Hour).Unix() + repo := newFakeScheduledMailRepo( + &domain.ScheduledMail{ID: 1, MailingListName: "newsletter", RawMarkdown: "# Hi", ScheduledAt: past}, + ) + mailer := &fakeListMailSender{err: fmt.Errorf("smtp failure")} + svc := newTestSchedulingService(repo, mailer) + + svc.dispatchDue() + + m, _ := repo.GetScheduledMailByID(context.Background(), 1) + if m.SentAt != nil { + t.Error("failed mail should not be marked as sent") + } +} + +func newTestSchedulingService(repo *fakeScheduledMailRepo, mailer *fakeListMailSender) *SchedulingService { + return NewSchedulingService(repo, mailer, time.Minute, noopLogger()) +} + +func noopLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +}