Skip to content

Commit 34c796f

Browse files
authored
feat(loo-3789): transactional command (#7)
1 parent cb1e950 commit 34c796f

11 files changed

Lines changed: 874 additions & 10 deletions

Taskfile.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ version: "3"
55
tasks:
66
default:
77
dotenv:
8+
- .env
89
- .env.{{.ENV}}
910
cmds:
1011
- go run ./... {{.CLI_ARGS}}
12+
silent: true
1113

1214
deps:
1315
cmds:
@@ -21,7 +23,7 @@ tasks:
2123

2224
test:
2325
cmds:
24-
- go test ./...
26+
- go test ./... -cover
2527

2628
vuln:
2729
cmds:

cmd/pagination.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package cmd
2+
3+
import (
4+
"github.com/loops-so/cli/internal/api"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func addPaginationFlags(cmd *cobra.Command) {
9+
cmd.Flags().String("per-page", "", "Results per page (10-50, default 20)")
10+
cmd.Flags().String("cursor", "", "Pagination cursor for a specific page")
11+
}
12+
13+
func paginationParams(cmd *cobra.Command) api.PaginationParams {
14+
perPage, _ := cmd.Flags().GetString("per-page")
15+
cursor, _ := cmd.Flags().GetString("cursor")
16+
return api.PaginationParams{
17+
PerPage: perPage,
18+
Cursor: cursor,
19+
}
20+
}

cmd/transactional.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package cmd
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"text/tabwriter"
12+
13+
"github.com/loops-so/cli/internal/api"
14+
"github.com/loops-so/cli/internal/config"
15+
"github.com/spf13/cobra"
16+
)
17+
18+
func attachmentFromPath(path string) (api.Attachment, error) {
19+
info, err := os.Stat(path)
20+
if err != nil {
21+
return api.Attachment{}, fmt.Errorf("attachment %q: %w", path, err)
22+
}
23+
if info.IsDir() {
24+
return api.Attachment{}, fmt.Errorf("attachment %q: is a directory", path)
25+
}
26+
27+
data, err := os.ReadFile(path)
28+
if err != nil {
29+
return api.Attachment{}, fmt.Errorf("attachment %q: %w", path, err)
30+
}
31+
32+
sniff := data
33+
if len(sniff) > 512 {
34+
sniff = sniff[:512]
35+
}
36+
contentType := http.DetectContentType(sniff)
37+
38+
return api.Attachment{
39+
Filename: filepath.Base(path),
40+
ContentType: contentType,
41+
Data: base64.StdEncoding.EncodeToString(data),
42+
}, nil
43+
}
44+
45+
var transactionalCmd = &cobra.Command{
46+
Use: "transactional",
47+
Short: "Manage transactional emails",
48+
}
49+
50+
var transactionalListCmd = &cobra.Command{
51+
Use: "list",
52+
Short: "List published transactional emails",
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
cfg, err := config.Load()
55+
if err != nil {
56+
return err
57+
}
58+
59+
params := paginationParams(cmd)
60+
client := api.NewClient(cfg.EndpointURL, cfg.APIKey)
61+
62+
var emails []api.TransactionalEmail
63+
if params.Cursor != "" {
64+
emails, _, err = client.ListTransactional(params)
65+
} else {
66+
emails, err = api.Paginate(func(cursor string) ([]api.TransactionalEmail, *api.Pagination, error) {
67+
return client.ListTransactional(api.PaginationParams{
68+
PerPage: params.PerPage,
69+
Cursor: cursor,
70+
})
71+
})
72+
}
73+
if err != nil {
74+
return err
75+
}
76+
77+
if len(emails) == 0 {
78+
fmt.Println("No transactional emails found.")
79+
return nil
80+
}
81+
82+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
83+
fmt.Fprintln(w, "ID\tNAME\tLAST UPDATED\tVARIABLES")
84+
for _, e := range emails {
85+
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.ID, e.Name, e.LastUpdated, strings.Join(e.DataVariables, ", "))
86+
}
87+
w.Flush()
88+
89+
return nil
90+
},
91+
}
92+
93+
var transactionalSendCmd = &cobra.Command{
94+
Use: "send",
95+
Short: "Send a transactional email",
96+
RunE: func(cmd *cobra.Command, args []string) error {
97+
cfg, err := config.Load()
98+
if err != nil {
99+
return err
100+
}
101+
102+
email, _ := cmd.Flags().GetString("email")
103+
id, _ := cmd.Flags().GetString("id")
104+
dataRaw, _ := cmd.Flags().GetString("data")
105+
106+
req := api.SendTransactionalRequest{
107+
Email: email,
108+
TransactionalID: id,
109+
}
110+
111+
if cmd.Flags().Changed("add-to-audience") {
112+
v, _ := cmd.Flags().GetBool("add-to-audience")
113+
req.AddToAudience = &v
114+
}
115+
116+
if dataRaw != "" {
117+
if err := json.Unmarshal([]byte(dataRaw), &req.DataVariables); err != nil {
118+
return fmt.Errorf("--data must be a valid JSON object: %w", err)
119+
}
120+
}
121+
122+
paths, _ := cmd.Flags().GetStringArray("attachment")
123+
for _, path := range paths {
124+
a, err := attachmentFromPath(path)
125+
if err != nil {
126+
return err
127+
}
128+
req.Attachments = append(req.Attachments, a)
129+
}
130+
131+
client := api.NewClient(cfg.EndpointURL, cfg.APIKey)
132+
if err := client.SendTransactional(req); err != nil {
133+
return err
134+
}
135+
136+
fmt.Println("Sent.")
137+
return nil
138+
},
139+
}
140+
141+
func init() {
142+
addPaginationFlags(transactionalListCmd)
143+
transactionalCmd.AddCommand(transactionalListCmd)
144+
145+
transactionalSendCmd.Flags().String("email", "", "Recipient email address")
146+
transactionalSendCmd.Flags().String("id", "", "Transactional email ID")
147+
transactionalSendCmd.Flags().BoolP("add-to-audience", "a", false, "Create a contact if one doesn't exist")
148+
transactionalSendCmd.Flags().String("data", "", "Data variables as a JSON object")
149+
transactionalSendCmd.Flags().StringArrayP("attachment", "A", nil, "Path to a file to attach (repeatable)")
150+
transactionalSendCmd.MarkFlagRequired("email")
151+
transactionalSendCmd.MarkFlagRequired("id")
152+
transactionalCmd.AddCommand(transactionalSendCmd)
153+
154+
rootCmd.AddCommand(transactionalCmd)
155+
}

cmd/transactional_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package cmd
2+
3+
import (
4+
"encoding/base64"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestAttachmentFromPath(t *testing.T) {
12+
t.Run("valid file", func(t *testing.T) {
13+
f, err := os.CreateTemp(t.TempDir(), "test-*.txt")
14+
if err != nil {
15+
t.Fatalf("failed to create temp file: %v", err)
16+
}
17+
content := []byte("hello attachment")
18+
f.Write(content)
19+
f.Close()
20+
21+
a, err := attachmentFromPath(f.Name())
22+
if err != nil {
23+
t.Fatalf("unexpected error: %v", err)
24+
}
25+
if a.Filename != filepath.Base(f.Name()) {
26+
t.Errorf("Filename = %q, want %q", a.Filename, filepath.Base(f.Name()))
27+
}
28+
if !strings.HasPrefix(a.ContentType, "text/plain") {
29+
t.Errorf("ContentType = %q, want text/plain prefix", a.ContentType)
30+
}
31+
if a.Data != base64.StdEncoding.EncodeToString(content) {
32+
t.Errorf("Data = %q, want base64 of content", a.Data)
33+
}
34+
})
35+
36+
t.Run("file not found", func(t *testing.T) {
37+
_, err := attachmentFromPath("/nonexistent/file.pdf")
38+
if err == nil {
39+
t.Fatal("expected error, got nil")
40+
}
41+
if !strings.Contains(err.Error(), "/nonexistent/file.pdf") {
42+
t.Errorf("error %q should mention the path", err.Error())
43+
}
44+
})
45+
46+
t.Run("directory", func(t *testing.T) {
47+
_, err := attachmentFromPath(t.TempDir())
48+
if err == nil {
49+
t.Fatal("expected error, got nil")
50+
}
51+
if !strings.Contains(err.Error(), "is a directory") {
52+
t.Errorf("error %q should mention 'is a directory'", err.Error())
53+
}
54+
})
55+
}

internal/api/api_key.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type APIKeyResponse struct {
1111
}
1212

1313
func (c *Client) GetAPIKey() (*APIKeyResponse, error) {
14-
req, err := c.newRequest(http.MethodGet, "/api-key")
14+
req, err := c.newRequest(http.MethodGet, "/api-key", nil)
1515
if err != nil {
1616
return nil, err
1717
}

internal/api/client.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"encoding/json"
55
"fmt"
6+
"io"
67
"math/rand/v2"
78
"net/http"
89
"time"
@@ -44,10 +45,16 @@ func NewClient(baseURL, apiKey string) *Client {
4445

4546
func errorFromResponse(resp *http.Response) *APIError {
4647
var body struct {
47-
Error string `json:"error"`
48+
Error string `json:"error"`
49+
Message string `json:"message"`
4850
}
49-
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Error != "" {
50-
return &APIError{StatusCode: resp.StatusCode, Message: body.Error}
51+
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil {
52+
if body.Error != "" {
53+
return &APIError{StatusCode: resp.StatusCode, Message: body.Error}
54+
}
55+
if body.Message != "" {
56+
return &APIError{StatusCode: resp.StatusCode, Message: body.Message}
57+
}
5158
}
5259
return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("unexpected status: %d", resp.StatusCode)}
5360
}
@@ -83,12 +90,15 @@ func (c *Client) do(req *http.Request) (*http.Response, error) {
8390
return resp, nil
8491
}
8592

86-
func (c *Client) newRequest(method, path string) (*http.Request, error) {
93+
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
8794
url := fmt.Sprintf("%s%s", c.baseURL, path)
88-
req, err := http.NewRequest(method, url, nil)
95+
req, err := http.NewRequest(method, url, body)
8996
if err != nil {
9097
return nil, err
9198
}
9299
req.Header.Set("Authorization", "Bearer "+c.apiKey)
100+
if body != nil {
101+
req.Header.Set("Content-Type", "application/json")
102+
}
93103
return req, nil
94104
}

0 commit comments

Comments
 (0)