From 00d477b836d04f93a54f1b09ac172f3d639a7d8a Mon Sep 17 00:00:00 2001 From: Michael Rykov Date: Mon, 23 Jun 2025 16:32:05 +0800 Subject: [PATCH 1/7] [GQL] Added "sendCampaign" with ZipFs --- client/send.go | 167 +++++++++++++++++++++++++++++++++++++ client/send_test.go | 120 +++++++++++++++++++++++++++ cmd/send.go | 21 ++++- go.mod | 1 + go.sum | 2 + server/handler.go | 121 +++++++++++++++++++++++++++ server/handler_test.go | 184 +++++++++++++++++++++++++++++++++++++++++ server/schema.go | 8 +- server/send.go | 33 ++++++++ 9 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 client/send.go create mode 100644 client/send_test.go create mode 100644 server/handler.go create mode 100644 server/handler_test.go diff --git a/client/send.go b/client/send.go new file mode 100644 index 0000000..a7bfd7d --- /dev/null +++ b/client/send.go @@ -0,0 +1,167 @@ +package client + +import ( + "resty.dev/v3" + + "archive/zip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "mime/multipart" + "net/textproto" + "os" + "path/filepath" +) + +const sendGQL = ` + mutation sendCampaign($campaign: String!, $list: String!) { + sendCampaign(campaign: $campaign, list: $list) + } +` + +type client struct { + context context.Context + serverURL string +} + +func New(ctx context.Context, url string) *client { + return &client{ctx, url} +} + +func (c *client) Send(projectPath, campaign, list string) error { + pr, ct := streamZipToMultipart(projectPath, func(mw *multipart.Writer) error { + header := make(textproto.MIMEHeader) + header.Set("Content-Type", "application/json") + part, err := mw.CreatePart(header) + if err != nil { + return err + } + + return json.NewEncoder(part).Encode(map[string]any{ + "operationName": "sendCampaign", + "query": sendGQL, + "variables": map[string]any{ + "campaign": campaign, + "list": list, + }, + }) + }) + + // Capture GraphQL errors + var output gqlErrorResponse + + // Prepare Resty client and request + client := resty.New() + resp, err := client.R(). + SetHeader("Content-Type", ct). + SetResult(&output). + SetBody(pr). + Post(c.serverURL) + + // non‐2xx → treat as error + if err != nil { + return err + } else if e := output.Errors; len(e) > 0 { + return fmt.Errorf("server error: %s", e[0].Message) + } else if resp.IsError() { + return fmt.Errorf("server returned %s: %s", + resp.Status(), + resp.String(), + ) + } + + return nil +} + +// Common GQL error response +type gqlErrorResponse struct { + Errors []struct { + Path []string + Message string + } +} + +// Create a pipe: the ZIP writer writes to pw, and resty reads from pr. +func streamZipToMultipart(dirPath string, callback func(*multipart.Writer) error) (io.Reader, string) { + // Create a pipe: multipart.Writer writes to pw, Resty reads from pr. + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + + go func() { + defer pw.Close() + defer mw.Close() + + // Create a single zip-file part + header := make(textproto.MIMEHeader) + header.Set("Content-Type", "application/zip") // <- here you set it! + part, err := mw.CreatePart(header) + if err != nil { + pw.CloseWithError(err) + return + } + + // Stream the ZIP into that part: + zw := zip.NewWriter(part) + zipErr := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + // Create a zip header based on file info + info, err := d.Info() + if err != nil { + return err + } + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + // Compute the relative path in the zip archive + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + + // Normalize to "/" separators + header.Name = filepath.ToSlash(relPath) + header.Method = zip.Deflate + + // Create the entry and copy file data + w, err := zw.CreateHeader(header) + if err != nil { + return err + } + if _, err := io.Copy(w, f); err != nil { + return err + } + + return nil + }) + + // Close the zip writer (flushes headers) + mwErr := errors.Join(zipErr, zw.Close()) + + // Callback to add more parts + if callback != nil { + mwErr = errors.Join(mwErr, callback(mw)) + } + + // Propagate any error to the reader side + if mwErr != nil { + pw.CloseWithError(mwErr) + } else { + pw.Close() + } + }() + + return pr, mw.FormDataContentType() +} diff --git a/client/send_test.go b/client/send_test.go new file mode 100644 index 0000000..63c73be --- /dev/null +++ b/client/send_test.go @@ -0,0 +1,120 @@ +package client + +import ( + "github.com/rykov/paperboy/server" + + "archive/zip" + "context" + "fmt" + "io" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestSendIntegration(t *testing.T) { + // 1) create temp dir with files + dir := t.TempDir() + for name, content := range expected { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writing %q: %v", path, err) + } + } + + // 2) spin up the server + h := server.MustSchemaHandler(schemaSDL, &testResolver{}) + srv := httptest.NewServer(h) + defer srv.Close() + + // 3) test various client requests + cli := New(context.Background(), srv.URL) + if err := cli.Send(dir, "testCampaign", "testList"); err != nil { + t.Fatalf("client.Send failed: %v", err) + } + + if err := cli.Send(dir, "testCampaign", "testError"); err == nil { + t.Errorf("Expected server to error, got success") + } else if a, e := err.Error(), "server error: testError"; a != e { + t.Errorf("Expected error %q, got %q", e, a) + } +} + +// minimal test‐only schema & resolver: +const schemaSDL = ` + schema { query: Query mutation: Mutation } + type Query {} + type Mutation { + sendCampaign(campaign: String!, list: String!): Boolean! + } +` + +// expected files and their contents +var expected = map[string]string{ + "foo.txt": "hello foo", + "bar.txt": "hello bar", +} + +type testResolver struct{} + +// resolver signature with context so we can pull the zip back out +func (r *testResolver) SendCampaign(ctx context.Context, args struct { + Campaign string + List string +}) (bool, error) { + if l := args.List; l == "testError" { + return false, fmt.Errorf(l) + } + + f, ok := server.RequestZipFile(ctx) + if !ok || f == nil { + return false, fmt.Errorf("zip file not found in context") + } + defer f.Close() + + // figure out its size + info, err := f.Stat() + if err != nil { + return false, fmt.Errorf("stat temp file: %w", err) + } + + // open it as a zip.Reader + zr, err := zip.NewReader(f, info.Size()) + if err != nil { + return false, fmt.Errorf("open zip: %w", err) + } + + // track which files we saw + seen := make(map[string]bool) + + for _, entry := range zr.File { + rc, err := entry.Open() + if err != nil { + return false, fmt.Errorf("open entry %q: %w", entry.Name, err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return false, fmt.Errorf("read entry %q: %w", entry.Name, err) + } + + want, exists := expected[entry.Name] + if !exists { + return false, fmt.Errorf("unexpected file %q in zip", entry.Name) + } + if string(data) != want { + return false, fmt.Errorf("file %q contents = %q; want %q", entry.Name, data, want) + } + seen[entry.Name] = true + } + + // make sure we saw them all + for name := range expected { + if !seen[name] { + return false, fmt.Errorf("expected file %q but not found in zip", name) + } + } + + return true, nil +} diff --git a/cmd/send.go b/cmd/send.go index 06d8ca8..1db4472 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/rykov/paperboy/client" "github.com/rykov/paperboy/config" "github.com/rykov/paperboy/mail" "github.com/spf13/cobra" @@ -12,7 +13,9 @@ import ( ) func sendCmd() *cobra.Command { - return &cobra.Command{ + var serverURL string + + cmd := &cobra.Command{ Use: "send [content] [list]", Short: "Send campaign to recipients", Example: "paperboy send the-announcement in-the-know", @@ -20,15 +23,25 @@ func sendCmd() *cobra.Command { // Uncomment the following line if your bare application // has an action associated with it: RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.LoadConfig() + cfg, err := config.LoadConfig() // Validate project if err != nil { return err } - ctx := withSignalTrap(cmd.Context()) - return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1]) + ctx := cmd.Context() + if u := serverURL; u != "" { + return client.New(ctx, u).Send(".", args[0], args[1]) + } else { + ctx = withSignalTrap(ctx) + return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1]) + } }, } + + // Server to specify remote server + cmd.Flags().StringVar(&serverURL, "server", "", "URL of server") + + return cmd } func withSignalTrap(cmdCtx context.Context) context.Context { diff --git a/go.mod b/go.mod index 177246f..f8cbf79 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/yuin/goldmark v1.7.12 golang.org/x/net v0.41.0 gopkg.in/yaml.v3 v3.0.1 + resty.dev/v3 v3.0.0-beta.3 ) require ( diff --git a/go.sum b/go.sum index 9ae7a5d..46d5e5d 100644 --- a/go.sum +++ b/go.sum @@ -229,3 +229,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E= +resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= diff --git a/server/handler.go b/server/handler.go new file mode 100644 index 0000000..6236879 --- /dev/null +++ b/server/handler.go @@ -0,0 +1,121 @@ +package server + +import ( + "github.com/graph-gophers/graphql-go" + "github.com/graph-gophers/graphql-go/relay" + + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" +) + +const ( + ctxZipFileKey = iota +) + +// MustSchemaHandler wraps the multipart GraphQL handler +func MustSchemaHandler(schema string, resolver any) http.Handler { + s := graphql.MustParseSchema(schema, resolver) + return &handler{Handler: &relay.Handler{s}} +} + +type handler struct { + *relay.Handler +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + h.Handler.ServeHTTP(w, r) + } + + // Parse form to capture request & zip + params, file, err := parseMultipartGQL(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer os.Remove(file.Name()) // clean up + + // Store zip file into context to handle in GraphQL + ctx := context.WithValue(r.Context(), ctxZipFileKey, file) + + // Execute GraphQL query + response := h.Schema.Exec(ctx, params.Query, params.OperationName, params.Variables) + responseJSON, err := json.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Respond with results + w.Header().Set("Content-Type", "application/json") + w.Write(responseJSON) +} + +// Accessor for zipfile from context +func RequestZipFile(ctx context.Context) (*os.File, bool) { + f, ok := ctx.Value(ctxZipFileKey).(*os.File) + return f, ok +} + +// Iterate through form and capture JSON and ZIP files for processing +func parseMultipartGQL(r *http.Request) (params *gqlRequestParams, file *os.File, err error) { + // Get a streaming reader for the parts + mr, err := r.MultipartReader() + if err != nil { + return nil, nil, err + } + + // Iterate through each part + for { + part, err := mr.NextPart() + if errors.Is(err, io.EOF) { + return params, file, nil + } else if err != nil { + return nil, nil, err + } + + // Inspect incoming headers + ct := part.Header.Get("Content-Type") + //fieldName := part.FormName() + // fileName := part.FileName() // empty if this part isn't a file + + switch { + case ct == "application/json": + params = &gqlRequestParams{} + if err := json.NewDecoder(part).Decode(params); err != nil { + return nil, nil, err + } + + case ct == "application/zip": + file, err = os.CreateTemp("", "paperboy-zip") + if err != nil { + return nil, nil, err + } + + _, err1 := io.Copy(file, part) + _, err2 := file.Seek(0, io.SeekStart) + + if err := errors.Join(err1, err2); err != nil { + os.Remove(file.Name()) // clean up + return nil, nil, err + } + + default: + // unknown type—just skip it + if _, err := io.Copy(io.Discard, part); err != nil { + return nil, nil, err + } + } + } +} + +type gqlRequestParams struct { + Query string `json:"query"` + OperationName string `json:"operationName"` + Variables map[string]interface{} `json:"variables"` +} diff --git a/server/handler_test.go b/server/handler_test.go new file mode 100644 index 0000000..456a1b4 --- /dev/null +++ b/server/handler_test.go @@ -0,0 +1,184 @@ +package server + +import ( + "github.com/google/go-cmp/cmp" + + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http/httptest" + "net/textproto" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestParseMultipartGQL(t *testing.T) { + // Prepare JSON and ZIP content + zipData := []byte("PK\x03\x04dummyzipcontent") + jsonReq := map[string]any{ + "variables": map[string]any{"foo": 123}, + "operationName": "TestOp", + "query": "{ hello }", + } + + // Build multipart body via helper + body, contentType, err := buildMultipartBody(jsonReq, zipData) + if err != nil { + t.Fatalf("buildMultipartBody error: %v", err) + } + + // Create HTTP request + req := httptest.NewRequest("POST", "/graphql", body) + req.Header.Set("Content-Type", contentType) + + // Call parseMultipartGQL + params, file, err := parseMultipartGQL(req) + if err != nil { + t.Fatalf("parseMultipartGQL error: %v", err) + } + defer os.Remove(file.Name()) + + // Check params + expected := &gqlRequestParams{ + Query: "{ hello }", + OperationName: "TestOp", + Variables: map[string]interface{}{"foo": float64(123)}, + } + if params.Query != expected.Query { + t.Errorf("expected Query %q, got %q", expected.Query, params.Query) + } + if params.OperationName != expected.OperationName { + t.Errorf("expected OperationName %q, got %q", expected.OperationName, params.OperationName) + } + if !reflect.DeepEqual(params.Variables, expected.Variables) { + t.Errorf("expected Variables %v, got %v", expected.Variables, params.Variables) + } + + // Check file contents + info, err := os.Stat(file.Name()) + if err != nil { + t.Fatalf("stat temp file: %v", err) + } + if info.Size() != int64(len(zipData)) { + t.Errorf("expected file size %d, got %d", len(zipData), info.Size()) + } + if !strings.HasPrefix(filepath.Base(file.Name()), "paperboy-zip") { + t.Errorf("unexpected temp file name: %s", file.Name()) + } +} + +func TestServeHTTP_Multipart_IncludesFile(t *testing.T) { + // Prepare GraphQL JSON and ZIP data + resolver := testResolver{ + zipData: []byte("PK\x03\x04dummyzipcontent"), + gqlVars: map[string]any{"testArg": "123"}, + } + + // Simple GraphQL schema that exposes hasFile + schema := `type Query { checkFile: Boolean! }` + h := MustSchemaHandler(schema, &resolver) + + // Prepare GraphQL JSON and ZIP data + jsonReq := map[string]any{ + "variables": resolver.gqlVars, + "query": "{ checkFile }", + } + + // Build multipart body via helper + body, contentType, err := buildMultipartBody(jsonReq, resolver.zipData) + if err != nil { + t.Fatalf("buildMultipartBody error: %v", err) + } + + // Create HTTP request and recorder + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/graphql", body) + req.Header.Set("Content-Type", contentType) + + // Invoke handler + h.ServeHTTP(rr, req) + + if rr.Code != 200 { + t.Errorf("GQL error body: %q", rr.Body.String()) + t.Fatalf("expected status 200, got %d", rr.Code) + } + + // Verify response + var resp struct { + Data struct{ CheckFile bool } + Errors []map[string]any + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if e := resp.Errors; e != nil { + t.Errorf("expected no checkFile errors: %+v", e) + } + if !resp.Data.CheckFile { + t.Errorf("expected checkFile=true, got false") + } +} + +// testResolver is used for the ServeHTTP integration test. +type testResolver struct { + gqlVars map[string]any + zipData []byte +} + +func (r *testResolver) CheckFile(ctx context.Context) (bool, error) { + if f, ok := RequestZipFile(ctx); !ok { + return false, errors.New("No zip file in context") + } else if raw, err := io.ReadAll(f); err != nil { + return false, err + } else if d := cmp.Diff(r.zipData, raw); d != "" { + return false, fmt.Errorf("Zip file mismatch: %s", d) + } + return true, nil +} + +// buildMultipartBody constructs a multipart body with an application/json part and an application/zip part. +func buildMultipartBody(jsonContent map[string]any, zipContent []byte) (body *bytes.Buffer, contentType string, err error) { + body = &bytes.Buffer{} + w := multipart.NewWriter(body) + + // JSON part + hdr := textproto.MIMEHeader{} + hdr.Set("Content-Type", "application/json") + jsonBytes, err := json.Marshal(jsonContent) + if err != nil { + return nil, "", err + } + jsonPart, err := w.CreatePart(hdr) + if err != nil { + return nil, "", err + } + if _, err := jsonPart.Write(jsonBytes); err != nil { + return nil, "", err + } + + // ZIP part + hdr = textproto.MIMEHeader{} + hdr.Set("Content-Type", "application/zip") + hdr.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, "test.zip")) + zipPart, err := w.CreatePart(hdr) + if err != nil { + return nil, "", err + } + if _, err := zipPart.Write(zipContent); err != nil { + return nil, "", err + } + + // Close writer to finalize the body + if err := w.Close(); err != nil { + return nil, "", err + } + + return body, w.FormDataContentType(), nil +} diff --git a/server/schema.go b/server/schema.go index 9714845..48c9f36 100644 --- a/server/schema.go +++ b/server/schema.go @@ -1,8 +1,6 @@ package server import ( - "github.com/graph-gophers/graphql-go" - "github.com/graph-gophers/graphql-go/relay" "github.com/rs/cors" "github.com/rykov/paperboy/config" @@ -19,8 +17,9 @@ func GraphQLHandler(cfg *config.AConfig) http.Handler { }, }) - schema := graphql.MustParseSchema(schemaText, &Resolver{cfg: cfg}) - return c.Handler(&relay.Handler{Schema: schema}) + // GraphQL handler for exposed API + handler := MustSchemaHandler(schemaText, &Resolver{cfg: cfg}) + return c.Handler(handler) } const schemaText = ` @@ -40,6 +39,7 @@ const schemaText = ` # All mutations type Mutation { sendBeta(content: String!, recipients: [RecipientInput!]!): Int! + sendCampaign(campaign: String!, list: String!): Boolean! } # A single rendered email information diff --git a/server/send.go b/server/send.go index 98ec69d..8a38e03 100644 --- a/server/send.go +++ b/server/send.go @@ -1,9 +1,13 @@ package server import ( + "github.com/rykov/paperboy/config" "github.com/rykov/paperboy/mail" + "github.com/spf13/afero/zipfs" + "archive/zip" "context" + "errors" "fmt" ) @@ -62,3 +66,32 @@ func (r *Resolver) SendBeta(ctx context.Context, args SendOneArgs) (int32, error err = mail.SendCampaign(ctx, r.cfg, campaign) return int32(len(recipients)), err } + +type SendCampaignArgs struct { + Campaign string + List string +} + +// ===== Use ZIP-file attachment to deliver campaign to the recipient list ====== +func (r *Resolver) SendCampaign(ctx context.Context, args SendCampaignArgs) (bool, error) { + file, ok := RequestZipFile(ctx) + if !ok { + return false, errors.New("ZIP: No file") + } + fi, err := file.Stat() + if err != nil { + return false, fmt.Errorf("ZIP: %w", err) + } + zr, err := zip.NewReader(file, fi.Size()) + if err != nil { + return false, fmt.Errorf("ZIP: %w", err) + } + + cfg, err := config.LoadConfigFs(zipfs.New(zr)) + if err != nil { + return false, fmt.Errorf("ZIP Config: %w", err) + } + + err = mail.LoadAndSendCampaign(ctx, cfg, args.Campaign, args.List) + return err == nil, err +} From db2e29ef0c5f79f945ad41356ba7871dfd365d18 Mon Sep 17 00:00:00 2001 From: Michael Rykov Date: Mon, 23 Jun 2025 15:36:28 +0800 Subject: [PATCH 2/7] Added panic, logging, and auth middleware --- client/send_test.go | 12 ++++++++++-- cmd/preview.go | 6 +++--- cmd/server.go | 7 +++---- config/config.go | 15 ++++++++++++++- go.mod | 1 + go.sum | 2 ++ server/handler.go | 28 ++++++++++++++++++++++++++++ 7 files changed, 61 insertions(+), 10 deletions(-) diff --git a/client/send_test.go b/client/send_test.go index 63c73be..4b6f4b0 100644 --- a/client/send_test.go +++ b/client/send_test.go @@ -25,7 +25,7 @@ func TestSendIntegration(t *testing.T) { // 2) spin up the server h := server.MustSchemaHandler(schemaSDL, &testResolver{}) - srv := httptest.NewServer(h) + srv := httptest.NewServer(server.WithMiddleware(h, nil)) defer srv.Close() // 3) test various client requests @@ -39,6 +39,12 @@ func TestSendIntegration(t *testing.T) { } else if a, e := err.Error(), "server error: testError"; a != e { t.Errorf("Expected error %q, got %q", e, a) } + + if err := cli.Send(dir, "testCampaign", "testPanic"); err == nil { + t.Errorf("Expected server to error, got success") + } else if a, e := err.Error(), "server error: panic occurred: testPanic"; a != e { + t.Errorf("Expected error %q, got %q", e, a) + } } // minimal test‐only schema & resolver: @@ -64,7 +70,9 @@ func (r *testResolver) SendCampaign(ctx context.Context, args struct { List string }) (bool, error) { if l := args.List; l == "testError" { - return false, fmt.Errorf(l) + return false, fmt.Errorf("%s", l) + } else if l == "testPanic" { + panic(l) } f, ok := server.RequestZipFile(ctx) diff --git a/cmd/preview.go b/cmd/preview.go index 59cecf1..022a5e2 100644 --- a/cmd/preview.go +++ b/cmd/preview.go @@ -34,7 +34,7 @@ func previewCmd() *cobra.Command { // Wait for server and open preview go func() { if r, _ := <-serverReady; r { - openPreview(args[0], args[1]) + openPreview(cfg, args[0], args[1]) } }() @@ -44,9 +44,9 @@ func previewCmd() *cobra.Command { } } -func openPreview(content, list string) { +func openPreview(cfg *config.AConfig, content, list string) { // Root URL for preview and GraphQL server - previewRoot := fmt.Sprintf("http://localhost:%d", serverLocalPort) + previewRoot := fmt.Sprintf("http://localhost:%d", cfg.ServerPort) previewPath := fmt.Sprintf("/preview/%s/%s", url.PathEscape(content), url.PathEscape(list)) // Open preview URL on various platform diff --git a/cmd/server.go b/cmd/server.go index 3255f79..c51c7be 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -16,7 +16,6 @@ import ( const ( // Local server configuration serverGraphQLPath = "/graphql" - serverLocalPort = 8080 ) func serverCmd() *cobra.Command { @@ -56,9 +55,9 @@ func startAPIServer(cfg *config.AConfig, configFn configFunc) error { // The rest is handled by UI mux.Handle("/", uiHandler()) - // Initialize server - s := &http.Server{Handler: mux} - s.Addr = fmt.Sprintf(":%d", serverLocalPort) + // Initialize server with standard middleware + s := &http.Server{Handler: server.WithMiddleware(mux, cfg)} + s.Addr = fmt.Sprintf(":%d", cfg.ServerPort) // Open port for listening l, err := net.Listen("tcp", s.Addr) diff --git a/config/config.go b/config/config.go index 9946001..5553294 100644 --- a/config/config.go +++ b/config/config.go @@ -56,6 +56,10 @@ type ConfigFile struct { // Delivery SendRate float32 Workers int + + // Client/Server + ServerAuth string + ServerPort uint } type SMTPConfig struct { @@ -117,7 +121,11 @@ func LoadConfigFs(fs afero.Fs) (*AConfig, error) { viperConfig := newViperConfig(cfg.AppFs) if err := viperConfig.ReadInConfig(); err != nil { - return nil, err + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + fmt.Println(err.Error()) + } else { + return nil, err + } } err := viperConfig.Unmarshal(&cfg.ConfigFile) @@ -161,6 +169,11 @@ func newViperConfig(fs afero.Fs) *viper.Viper { v.SetDefault("sendRate", 1) v.SetDefault("workers", 3) + // Server, Client, API + v.BindEnv("serverPort", "PORT") + v.SetDefault("serverPort", 8080) + v.SetDefault("serverAuth", "") + // Prepare for project's config.* v.SetConfigName("config") v.AddConfigPath("/") diff --git a/go.mod b/go.mod index f8cbf79..35779ca 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 + github.com/urfave/negroni/v3 v3.1.1 github.com/yuin/goldmark v1.7.12 golang.org/x/net v0.41.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 46d5e5d..0ae4dcf 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= +github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= +github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/server/handler.go b/server/handler.go index 6236879..340fa1a 100644 --- a/server/handler.go +++ b/server/handler.go @@ -3,8 +3,11 @@ package server import ( "github.com/graph-gophers/graphql-go" "github.com/graph-gophers/graphql-go/relay" + "github.com/rykov/paperboy/config" + "github.com/urfave/negroni/v3" "context" + "crypto/subtle" "encoding/json" "errors" "io" @@ -119,3 +122,28 @@ type gqlRequestParams struct { OperationName string `json:"operationName"` Variables map[string]interface{} `json:"variables"` } + +// WithMiddleware wraps the handler with logging, recovery, etc +func WithMiddleware(h http.Handler, cfg *config.AConfig) http.Handler { + n := negroni.New(negroni.NewRecovery(), negroni.NewLogger()) + + // Add basic authentication + if cfg != nil && cfg.ServerAuth != "" { + expU, expP, _ := strings.Cut(cfg.ServerAuth, ":") + n.UseFunc(func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if u, p, ok := r.BasicAuth(); ok { + okU := subtle.ConstantTimeCompare([]byte(u), []byte(expU)) == 1 + okP := subtle.ConstantTimeCompare([]byte(p), []byte(expP)) == 1 + if okU && okP { + next(rw, r) + return + } + } + s := http.StatusUnauthorized + http.Error(rw, http.StatusText(s), s) + }) + } + + n.UseHandler(h) + return n +} From 18046393a843006596d9410f987891842fb92cb2 Mon Sep 17 00:00:00 2001 From: Michael Rykov Date: Mon, 23 Jun 2025 16:27:09 +0800 Subject: [PATCH 3/7] Added ClientIgnores configuration --- client/send.go | 66 ++++++++++++++++++++++++++++++++++++++++----- client/send_test.go | 15 ++++++++--- cmd/send.go | 11 +++++--- config/config.go | 5 ++-- go.mod | 1 + go.sum | 2 ++ 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/client/send.go b/client/send.go index a7bfd7d..268edf2 100644 --- a/client/send.go +++ b/client/send.go @@ -1,6 +1,7 @@ package client import ( + "github.com/moby/patternmatcher" "resty.dev/v3" "archive/zip" @@ -31,8 +32,8 @@ func New(ctx context.Context, url string) *client { return &client{ctx, url} } -func (c *client) Send(projectPath, campaign, list string) error { - pr, ct := streamZipToMultipart(projectPath, func(mw *multipart.Writer) error { +func (c *client) Send(args SendArgs) error { + pr, ct := streamZipToMultipart(args, func(mw *multipart.Writer) error { header := make(textproto.MIMEHeader) header.Set("Content-Type", "application/json") part, err := mw.CreatePart(header) @@ -44,8 +45,8 @@ func (c *client) Send(projectPath, campaign, list string) error { "operationName": "sendCampaign", "query": sendGQL, "variables": map[string]any{ - "campaign": campaign, - "list": list, + "campaign": args.Campaign, + "list": args.List, }, }) }) @@ -76,6 +77,14 @@ func (c *client) Send(projectPath, campaign, list string) error { return nil } +// Common GQL error response +type SendArgs struct { + ProjectIgnores []string + ProjectPath string + Campaign string + List string +} + // Common GQL error response type gqlErrorResponse struct { Errors []struct { @@ -85,11 +94,14 @@ type gqlErrorResponse struct { } // Create a pipe: the ZIP writer writes to pw, and resty reads from pr. -func streamZipToMultipart(dirPath string, callback func(*multipart.Writer) error) (io.Reader, string) { +func streamZipToMultipart(args SendArgs, callback func(*multipart.Writer) error) (io.Reader, string) { // Create a pipe: multipart.Writer writes to pw, Resty reads from pr. pr, pw := io.Pipe() mw := multipart.NewWriter(pw) + dirPath := args.ProjectPath + ignores := args.ProjectIgnores + go func() { defer pw.Close() defer mw.Close() @@ -105,7 +117,9 @@ func streamZipToMultipart(dirPath string, callback func(*multipart.Writer) error // Stream the ZIP into that part: zw := zip.NewWriter(part) - zipErr := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { + + // Walk through the directory respecting "ClientIgnores" filtering configuration + zipErr := walkWithIgnores(dirPath, ignores, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } @@ -165,3 +179,43 @@ func streamZipToMultipart(dirPath string, callback func(*multipart.Writer) error return pr, mw.FormDataContentType() } + +// walkWithIgnores does a ".dockerignore"-like filtering when walking a directory +func walkWithIgnores(root string, ignores []string, fn fs.WalkDirFunc) error { + matcher, err := patternmatcher.New(ignores) + if err != nil { + return fmt.Errorf("invalid ignore pattern: %w", err) + } + + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + // Propagate internal errors + if err != nil { + return fn(path, d, err) + } + + // Compute a path relative to root for pattern matching + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + rel = path + } + rel = filepath.ToSlash(rel) + + // Skip the root itself from matching + if rel != "." { + // patternmatcher expects slash-separated paths + matched, err := matcher.Matches(rel) + if err != nil { + return fmt.Errorf("matching %q: %w", rel, err) + } + if matched { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + } + + // Not ignored + return fn(path, d, err) + }) +} diff --git a/client/send_test.go b/client/send_test.go index 4b6f4b0..ef65ea3 100644 --- a/client/send_test.go +++ b/client/send_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "maps" "net/http/httptest" "os" "path/filepath" @@ -16,7 +17,9 @@ import ( func TestSendIntegration(t *testing.T) { // 1) create temp dir with files dir := t.TempDir() - for name, content := range expected { + tempFiles := maps.Clone(expected) + tempFiles["baz.skip"] = "skip this plz" + for name, content := range tempFiles { path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("writing %q: %v", path, err) @@ -29,18 +32,22 @@ func TestSendIntegration(t *testing.T) { defer srv.Close() // 3) test various client requests + args := SendArgs{ProjectPath: dir, Campaign: "testCampaign", List: "testList"} + args.ProjectIgnores = []string{"*.skip"} // Test ignoring files cli := New(context.Background(), srv.URL) - if err := cli.Send(dir, "testCampaign", "testList"); err != nil { + if err := cli.Send(args); err != nil { t.Fatalf("client.Send failed: %v", err) } - if err := cli.Send(dir, "testCampaign", "testError"); err == nil { + args.List = "testError" + if err := cli.Send(args); err == nil { t.Errorf("Expected server to error, got success") } else if a, e := err.Error(), "server error: testError"; a != e { t.Errorf("Expected error %q, got %q", e, a) } - if err := cli.Send(dir, "testCampaign", "testPanic"); err == nil { + args.List = "testPanic" + if err := cli.Send(args); err == nil { t.Errorf("Expected server to error, got success") } else if a, e := err.Error(), "server error: panic occurred: testPanic"; a != e { t.Errorf("Expected error %q, got %q", e, a) diff --git a/cmd/send.go b/cmd/send.go index 1db4472..209eb3e 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -29,11 +29,16 @@ func sendCmd() *cobra.Command { } ctx := cmd.Context() - if u := serverURL; u != "" { - return client.New(ctx, u).Send(".", args[0], args[1]) - } else { + if u := serverURL; u == "" { ctx = withSignalTrap(ctx) return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1]) + } else { + return client.New(ctx, u).Send(client.SendArgs{ + ProjectPath: ".", // TODO: configurable + ProjectIgnores: cfg.ClientIgnores, + Campaign: args[0], + List: args[1], + }) } }, } diff --git a/config/config.go b/config/config.go index 5553294..eed085f 100644 --- a/config/config.go +++ b/config/config.go @@ -58,8 +58,9 @@ type ConfigFile struct { Workers int // Client/Server - ServerAuth string - ServerPort uint + ClientIgnores []string + ServerAuth string + ServerPort uint } type SMTPConfig struct { diff --git a/go.mod b/go.mod index 35779ca..fcf633e 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/jtacoma/uritemplates v1.0.0 github.com/microcosm-cc/bluemonday v1.0.27 + github.com/moby/patternmatcher v0.6.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/rs/cors v1.11.1 github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index 0ae4dcf..531e116 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= From 2abfc6bd216df38366dc3386a168028c63304271 Mon Sep 17 00:00:00 2001 From: Michael Rykov Date: Mon, 23 Jun 2025 21:50:43 +0800 Subject: [PATCH 4/7] Fixed TLS when InsecureSkipVerify = false --- config/config.go | 2 +- mail/sender.go | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/config/config.go b/config/config.go index eed085f..74d8fba 100644 --- a/config/config.go +++ b/config/config.go @@ -67,7 +67,7 @@ type SMTPConfig struct { URL string User string Pass string - TLS TLSConfig + TLS *TLSConfig } type TLSConfig struct { diff --git a/mail/sender.go b/mail/sender.go index 25762c7..dc94485 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -183,6 +183,9 @@ func (d *deliverer) configureSender() (sender gomail.SendCloser, err error) { fmt.Println("Retrying SMTP dial on error: ", err) }), ) + if err != nil { + return nil, err + } } // DKIM-signing sender, if configuration is present @@ -240,15 +243,20 @@ func smtpDialer(cfg *config.SMTPConfig) (*gomail.Dialer, error) { // Initialize the dialer d := gomail.NewDialer(surl.Hostname(), port, user, pass) d.SSL = (surl.Scheme == "smtps") - // insecure TLSConfig - tlsMinVersion, err := cfg.TLS.GetMinVersion() - if err != nil { - return nil, err - } - d.TLSConfig = &tls.Config{ - InsecureSkipVerify: cfg.TLS.InsecureSkipVerify, - MinVersion: tlsMinVersion, + + // Custom TLSConfig + if cfg.TLS != nil { + tlsMinVersion, err := cfg.TLS.GetMinVersion() + if err != nil { + return nil, err + } + d.TLSConfig = &tls.Config{ + InsecureSkipVerify: cfg.TLS.InsecureSkipVerify, + MinVersion: tlsMinVersion, + ServerName: d.Host, + } } + return d, nil } From 9eac1e2475bed1f9292a353f22364d5c4796d1c6 Mon Sep 17 00:00:00 2001 From: Guilhem Bonnefille Date: Sun, 7 Sep 2025 21:51:31 +0200 Subject: [PATCH 5/7] feat: add recipient filter --- cmd/send.go | 8 +++++++- go.mod | 1 + go.sum | 2 ++ mail/sender.go | 38 ++++++++++++++++++++++++++++++++++++++ mail/sender_test.go | 26 ++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/cmd/send.go b/cmd/send.go index 209eb3e..4c27251 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -14,6 +14,7 @@ import ( func sendCmd() *cobra.Command { var serverURL string + var recipientsFilter string cmd := &cobra.Command{ Use: "send [content] [list]", @@ -31,7 +32,11 @@ func sendCmd() *cobra.Command { ctx := cmd.Context() if u := serverURL; u == "" { ctx = withSignalTrap(ctx) - return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1]) + if recipientsFilter != "" { + return mail.LoadAndSendCampaignFiltered(ctx, cfg, args[0], args[1], recipientsFilter) + } else { + return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1]) + } } else { return client.New(ctx, u).Send(client.SendArgs{ ProjectPath: ".", // TODO: configurable @@ -45,6 +50,7 @@ func sendCmd() *cobra.Command { // Server to specify remote server cmd.Flags().StringVar(&serverURL, "server", "", "URL of server") + cmd.Flags().StringVar(&recipientsFilter, "filter", "", "Recipients filter") return cmd } diff --git a/go.mod b/go.mod index fcf633e..84ad666 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/andybalholm/cascadia v1.3.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/casbin/govaluate v1.10.0 github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect diff --git a/go.sum b/go.sum index 531e116..8b43777 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bep/inflect v0.0.0-20160408190323-b896c45f5af9 h1:2ZyfRr6MKtNow0D0AbbVlzrS3OI6a+svlOHrtFYGI9Q= github.com/bep/inflect v0.0.0-20160408190323-b896c45f5af9/go.mod h1:/fmCHLLmoBKSfptXUFVJZb7MMt7JCS4vm0vqQmAo3xE= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= diff --git a/mail/sender.go b/mail/sender.go index dc94485..0141fad 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -3,6 +3,7 @@ package mail import ( "crypto/tls" + "github.com/casbin/govaluate" "github.com/cenkalti/backoff/v5" "github.com/go-gomail/gomail" "github.com/rykov/paperboy/config" @@ -27,6 +28,43 @@ func LoadAndSendCampaign(ctx context.Context, cfg *config.AConfig, tmplFile, rec return SendCampaign(ctx, cfg, c) } +func LoadAndSendCampaignFiltered(ctx context.Context, cfg *config.AConfig, tmplFile, recipientFile string, filter string) error { + // Load up template and recipientswith frontmatter + c, err := LoadCampaign(cfg, tmplFile, recipientFile) + if err != nil { + return err + } + + recipients := c.Recipients + filteredRecipients, err := filterRecipients(recipients, filter) + if err != nil { + return err + } + + c.Recipients = filteredRecipients + + return SendCampaign(ctx, cfg, c) +} + +func filterRecipients(recipients []*ctxRecipient, filter string) ([]*ctxRecipient, error) { + expression, err := govaluate.NewEvaluableExpression(filter) + if err != nil { + return nil, err + } + + var filteredRecipients []*ctxRecipient + for _, r := range recipients { + result, err := expression.Evaluate(r.Params) + if err != nil { + return nil, err + } + if result == true { + filteredRecipients = append(filteredRecipients, r) + } + } + return filteredRecipients, nil +} + func SendCampaign(ctx context.Context, cfg *config.AConfig, c *Campaign) error { // Initialize deliverer engine := &deliverer{ diff --git a/mail/sender_test.go b/mail/sender_test.go index deca0ad..dc5b355 100644 --- a/mail/sender_test.go +++ b/mail/sender_test.go @@ -96,3 +96,29 @@ func TestSmtpDialerFailure(t *testing.T) { }) } } + +func TestFilter(t *testing.T) { + recipients := []*ctxRecipient{ + &ctxRecipient{ + Name: "Name1", + Email: "name1@example.com", + Params: map[string]interface{}{ + "class": "1", + }, + }, + &ctxRecipient{ + Name: "Name2", + Email: "name2@example.com", + Params: map[string]interface{}{ + "class": "2", + }, + }, + } + filtered, err := filterRecipients(recipients, "class == '1'") + if err != nil { + t.Errorf("Failed: %s", err) + } + if len(filtered) != 1 { + t.Errorf("Got %d", len(filtered)) + } +} From b545dd38f74abf85c1b3d09f172a25ef07bac586 Mon Sep 17 00:00:00 2001 From: Guilhem Bonnefille Date: Mon, 8 Sep 2025 18:12:47 +0200 Subject: [PATCH 6/7] refacto: better design for filtering ctxRecipients --- mail/campaign.go | 2 +- mail/ctx_recipients.go | 41 +++++++++++++++++++++++++++++++++++++ mail/ctx_recipients_test.go | 29 ++++++++++++++++++++++++++ mail/sender.go | 23 +-------------------- mail/sender_test.go | 26 ----------------------- 5 files changed, 72 insertions(+), 49 deletions(-) create mode 100644 mail/ctx_recipients.go create mode 100644 mail/ctx_recipients_test.go diff --git a/mail/campaign.go b/mail/campaign.go index 8893852..2cb862a 100644 --- a/mail/campaign.go +++ b/mail/campaign.go @@ -38,7 +38,7 @@ type tmplContext struct { } type Campaign struct { - Recipients []*ctxRecipient + Recipients CtxRecipients EmailMeta *ctxCampaign Email parser.Email diff --git a/mail/ctx_recipients.go b/mail/ctx_recipients.go new file mode 100644 index 0000000..94e5f1f --- /dev/null +++ b/mail/ctx_recipients.go @@ -0,0 +1,41 @@ +package mail + +import ( + "github.com/casbin/govaluate" +) + +type CtxRecipients []*ctxRecipient + +// Filter filters the recipients based on the provided filter expression. +// It evaluates the filter expression against each recipient's parameters +// and returns a slice of recipients that match the criteria. +// +// Parameters: +// +// filter: A string representing the filter expression to evaluate. +// The expression should be in a format compatible with +// the govaluate library. +// +// Returns: +// +// A slice of pointers to ctxRecipient that match the filter criteria, +// or an error if the evaluation of the expression fails or if any +// other error occurs during the filtering process. +func (cr CtxRecipients) Filter(filter string) ([]*ctxRecipient, error) { + expression, err := govaluate.NewEvaluableExpression(filter) + if err != nil { + return nil, err + } + + var filteredRecipients []*ctxRecipient + for _, r := range cr { + result, err := expression.Evaluate(r.Params) + if err != nil { + return nil, err + } + if result == true { + filteredRecipients = append(filteredRecipients, r) + } + } + return filteredRecipients, nil +} diff --git a/mail/ctx_recipients_test.go b/mail/ctx_recipients_test.go new file mode 100644 index 0000000..87bb60a --- /dev/null +++ b/mail/ctx_recipients_test.go @@ -0,0 +1,29 @@ +package mail + +import "testing" + +func TestCtxRecipientsFilter(t *testing.T) { + var recipients CtxRecipients = []*ctxRecipient{ + &ctxRecipient{ + Name: "Name1", + Email: "name1@example.com", + Params: map[string]interface{}{ + "class": "1", + }, + }, + &ctxRecipient{ + Name: "Name2", + Email: "name2@example.com", + Params: map[string]interface{}{ + "class": "2", + }, + }, + } + filtered, err := recipients.Filter("class == '1'") + if err != nil { + t.Errorf("Failed: %s", err) + } + if len(filtered) != 1 { + t.Errorf("Got %d", len(filtered)) + } +} diff --git a/mail/sender.go b/mail/sender.go index 0141fad..45c1731 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -3,7 +3,6 @@ package mail import ( "crypto/tls" - "github.com/casbin/govaluate" "github.com/cenkalti/backoff/v5" "github.com/go-gomail/gomail" "github.com/rykov/paperboy/config" @@ -35,8 +34,7 @@ func LoadAndSendCampaignFiltered(ctx context.Context, cfg *config.AConfig, tmplF return err } - recipients := c.Recipients - filteredRecipients, err := filterRecipients(recipients, filter) + filteredRecipients, err := c.Recipients.Filter(filter) if err != nil { return err } @@ -46,25 +44,6 @@ func LoadAndSendCampaignFiltered(ctx context.Context, cfg *config.AConfig, tmplF return SendCampaign(ctx, cfg, c) } -func filterRecipients(recipients []*ctxRecipient, filter string) ([]*ctxRecipient, error) { - expression, err := govaluate.NewEvaluableExpression(filter) - if err != nil { - return nil, err - } - - var filteredRecipients []*ctxRecipient - for _, r := range recipients { - result, err := expression.Evaluate(r.Params) - if err != nil { - return nil, err - } - if result == true { - filteredRecipients = append(filteredRecipients, r) - } - } - return filteredRecipients, nil -} - func SendCampaign(ctx context.Context, cfg *config.AConfig, c *Campaign) error { // Initialize deliverer engine := &deliverer{ diff --git a/mail/sender_test.go b/mail/sender_test.go index dc5b355..deca0ad 100644 --- a/mail/sender_test.go +++ b/mail/sender_test.go @@ -96,29 +96,3 @@ func TestSmtpDialerFailure(t *testing.T) { }) } } - -func TestFilter(t *testing.T) { - recipients := []*ctxRecipient{ - &ctxRecipient{ - Name: "Name1", - Email: "name1@example.com", - Params: map[string]interface{}{ - "class": "1", - }, - }, - &ctxRecipient{ - Name: "Name2", - Email: "name2@example.com", - Params: map[string]interface{}{ - "class": "2", - }, - }, - } - filtered, err := filterRecipients(recipients, "class == '1'") - if err != nil { - t.Errorf("Failed: %s", err) - } - if len(filtered) != 1 { - t.Errorf("Got %d", len(filtered)) - } -} From d9a5b2a94303a6a0133128d817567b879e670985 Mon Sep 17 00:00:00 2001 From: Guilhem Bonnefille Date: Thu, 11 Sep 2025 22:13:28 +0200 Subject: [PATCH 7/7] feat: allow declaration of the filter in the Campaign frontmatter Depend on the use case. One can create a campaign for a subset of the recipients. In this case, having the filter is the campaign is the most logic. Other can plan to send the campaign to all the recipients. But for technical reason, it make sense to send by batches. In this cas, having the filter on the command line make more sense. --- cmd/send.go | 6 +----- mail/context.go | 6 +++++- mail/sender.go | 26 +++++++++++--------------- server/send.go | 3 ++- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/cmd/send.go b/cmd/send.go index 4c27251..a0a5a1f 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -32,11 +32,7 @@ func sendCmd() *cobra.Command { ctx := cmd.Context() if u := serverURL; u == "" { ctx = withSignalTrap(ctx) - if recipientsFilter != "" { - return mail.LoadAndSendCampaignFiltered(ctx, cfg, args[0], args[1], recipientsFilter) - } else { - return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1]) - } + return mail.LoadAndSendCampaign(ctx, cfg, args[0], args[1], recipientsFilter) } else { return client.New(ctx, u).Send(client.SendArgs{ ProjectPath: ".", // TODO: configurable diff --git a/mail/context.go b/mail/context.go index 9b4f40d..a8ccb84 100644 --- a/mail/context.go +++ b/mail/context.go @@ -43,7 +43,9 @@ func newRecipient(data map[string]interface{}) ctxRecipient { // Campaign variable type ctxCampaign struct { - From string + From string + // Filter recipients + Filter string Params map[string]interface{} // Original subject from frontmatter @@ -62,9 +64,11 @@ func newCampaign(cfg *config.AConfig, data map[string]interface{}) ctxCampaign { if c.From, _ = c.Params["from"].(string); c.From == "" { c.From = cfg.From } + c.Filter, _ = c.Params["filter"].(string) delete(c.Params, "subject") delete(c.Params, "from") + delete(c.Params, "filter") return c } diff --git a/mail/sender.go b/mail/sender.go index 45c1731..cb5aaef 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -17,30 +17,26 @@ import ( "time" ) -func LoadAndSendCampaign(ctx context.Context, cfg *config.AConfig, tmplFile, recipientFile string) error { +func LoadAndSendCampaign(ctx context.Context, cfg *config.AConfig, tmplFile, recipientFile string, filter string) error { // Load up template and recipientswith frontmatter c, err := LoadCampaign(cfg, tmplFile, recipientFile) if err != nil { return err } - return SendCampaign(ctx, cfg, c) -} - -func LoadAndSendCampaignFiltered(ctx context.Context, cfg *config.AConfig, tmplFile, recipientFile string, filter string) error { - // Load up template and recipientswith frontmatter - c, err := LoadCampaign(cfg, tmplFile, recipientFile) - if err != nil { - return err + if filter == "" { + // Argument specified: use the possibly declared in Campaign + filter = c.EmailMeta.Filter } - - filteredRecipients, err := c.Recipients.Filter(filter) - if err != nil { - return err + if filter != "" { + // Filter the recipients + filteredRecipients, err := c.Recipients.Filter(filter) + if err != nil { + return err + } + c.Recipients = filteredRecipients } - c.Recipients = filteredRecipients - return SendCampaign(ctx, cfg, c) } diff --git a/server/send.go b/server/send.go index 8a38e03..7017ec2 100644 --- a/server/send.go +++ b/server/send.go @@ -92,6 +92,7 @@ func (r *Resolver) SendCampaign(ctx context.Context, args SendCampaignArgs) (boo return false, fmt.Errorf("ZIP Config: %w", err) } - err = mail.LoadAndSendCampaign(ctx, cfg, args.Campaign, args.List) + // Filter is possibly defined in the Campaign + err = mail.LoadAndSendCampaign(ctx, cfg, args.Campaign, args.List, "") return err == nil, err }