Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ version: "3"
tasks:
default:
dotenv:
- .env
- .env.{{.ENV}}
cmds:
- go run ./... {{.CLI_ARGS}}
silent: true

deps:
cmds:
Expand All @@ -21,7 +23,7 @@ tasks:

test:
cmds:
- go test ./...
- go test ./... -cover

vuln:
cmds:
Expand Down
20 changes: 20 additions & 0 deletions cmd/pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package cmd

import (
"github.com/loops-so/cli/internal/api"
"github.com/spf13/cobra"
)

func addPaginationFlags(cmd *cobra.Command) {
cmd.Flags().String("per-page", "", "Results per page (10-50, default 20)")
cmd.Flags().String("cursor", "", "Pagination cursor for a specific page")
}

func paginationParams(cmd *cobra.Command) api.PaginationParams {
perPage, _ := cmd.Flags().GetString("per-page")
cursor, _ := cmd.Flags().GetString("cursor")
return api.PaginationParams{
PerPage: perPage,
Cursor: cursor,
}
}
155 changes: 155 additions & 0 deletions cmd/transactional.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cmd

import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"text/tabwriter"

"github.com/loops-so/cli/internal/api"
"github.com/loops-so/cli/internal/config"
"github.com/spf13/cobra"
)

func attachmentFromPath(path string) (api.Attachment, error) {
info, err := os.Stat(path)
if err != nil {
return api.Attachment{}, fmt.Errorf("attachment %q: %w", path, err)
}
if info.IsDir() {
return api.Attachment{}, fmt.Errorf("attachment %q: is a directory", path)
}

data, err := os.ReadFile(path)
if err != nil {
return api.Attachment{}, fmt.Errorf("attachment %q: %w", path, err)
}

sniff := data
if len(sniff) > 512 {
sniff = sniff[:512]
}
contentType := http.DetectContentType(sniff)

return api.Attachment{
Filename: filepath.Base(path),
ContentType: contentType,
Data: base64.StdEncoding.EncodeToString(data),
}, nil
}

var transactionalCmd = &cobra.Command{
Use: "transactional",
Short: "Manage transactional emails",
}

var transactionalListCmd = &cobra.Command{
Use: "list",
Short: "List published transactional emails",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}

params := paginationParams(cmd)
client := api.NewClient(cfg.EndpointURL, cfg.APIKey)

var emails []api.TransactionalEmail
if params.Cursor != "" {
emails, _, err = client.ListTransactional(params)
} else {
emails, err = api.Paginate(func(cursor string) ([]api.TransactionalEmail, *api.Pagination, error) {
return client.ListTransactional(api.PaginationParams{
PerPage: params.PerPage,
Cursor: cursor,
})
})
}
if err != nil {
return err
}

if len(emails) == 0 {
fmt.Println("No transactional emails found.")
return nil
}

w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tLAST UPDATED\tVARIABLES")
for _, e := range emails {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.ID, e.Name, e.LastUpdated, strings.Join(e.DataVariables, ", "))
}
w.Flush()

return nil
},
}

var transactionalSendCmd = &cobra.Command{
Use: "send",
Short: "Send a transactional email",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}

email, _ := cmd.Flags().GetString("email")
id, _ := cmd.Flags().GetString("id")
dataRaw, _ := cmd.Flags().GetString("data")

req := api.SendTransactionalRequest{
Email: email,
TransactionalID: id,
}

if cmd.Flags().Changed("add-to-audience") {
v, _ := cmd.Flags().GetBool("add-to-audience")
req.AddToAudience = &v
}

if dataRaw != "" {
if err := json.Unmarshal([]byte(dataRaw), &req.DataVariables); err != nil {
return fmt.Errorf("--data must be a valid JSON object: %w", err)
}
}

paths, _ := cmd.Flags().GetStringArray("attachment")
for _, path := range paths {
a, err := attachmentFromPath(path)
if err != nil {
return err
}
req.Attachments = append(req.Attachments, a)
}

client := api.NewClient(cfg.EndpointURL, cfg.APIKey)
if err := client.SendTransactional(req); err != nil {
return err
}

fmt.Println("Sent.")
return nil
},
}

func init() {
addPaginationFlags(transactionalListCmd)
transactionalCmd.AddCommand(transactionalListCmd)

transactionalSendCmd.Flags().String("email", "", "Recipient email address")
transactionalSendCmd.Flags().String("id", "", "Transactional email ID")
transactionalSendCmd.Flags().BoolP("add-to-audience", "a", false, "Create a contact if one doesn't exist")
transactionalSendCmd.Flags().String("data", "", "Data variables as a JSON object")
transactionalSendCmd.Flags().StringArrayP("attachment", "A", nil, "Path to a file to attach (repeatable)")
transactionalSendCmd.MarkFlagRequired("email")
transactionalSendCmd.MarkFlagRequired("id")
transactionalCmd.AddCommand(transactionalSendCmd)

rootCmd.AddCommand(transactionalCmd)
}
55 changes: 55 additions & 0 deletions cmd/transactional_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cmd

import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)

func TestAttachmentFromPath(t *testing.T) {
t.Run("valid file", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "test-*.txt")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
content := []byte("hello attachment")
f.Write(content)
f.Close()

a, err := attachmentFromPath(f.Name())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if a.Filename != filepath.Base(f.Name()) {
t.Errorf("Filename = %q, want %q", a.Filename, filepath.Base(f.Name()))
}
if !strings.HasPrefix(a.ContentType, "text/plain") {
t.Errorf("ContentType = %q, want text/plain prefix", a.ContentType)
}
if a.Data != base64.StdEncoding.EncodeToString(content) {
t.Errorf("Data = %q, want base64 of content", a.Data)
}
})

t.Run("file not found", func(t *testing.T) {
_, err := attachmentFromPath("/nonexistent/file.pdf")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "/nonexistent/file.pdf") {
t.Errorf("error %q should mention the path", err.Error())
}
})

t.Run("directory", func(t *testing.T) {
_, err := attachmentFromPath(t.TempDir())
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "is a directory") {
t.Errorf("error %q should mention 'is a directory'", err.Error())
}
})
}
2 changes: 1 addition & 1 deletion internal/api/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type APIKeyResponse struct {
}

func (c *Client) GetAPIKey() (*APIKeyResponse, error) {
req, err := c.newRequest(http.MethodGet, "/api-key")
req, err := c.newRequest(http.MethodGet, "/api-key", nil)
if err != nil {
return nil, err
}
Expand Down
20 changes: 15 additions & 5 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"fmt"
"io"
"math/rand/v2"
"net/http"
"time"
Expand Down Expand Up @@ -44,10 +45,16 @@ func NewClient(baseURL, apiKey string) *Client {

func errorFromResponse(resp *http.Response) *APIError {
var body struct {
Error string `json:"error"`
Error string `json:"error"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Error != "" {
return &APIError{StatusCode: resp.StatusCode, Message: body.Error}
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil {
if body.Error != "" {
return &APIError{StatusCode: resp.StatusCode, Message: body.Error}
}
if body.Message != "" {
return &APIError{StatusCode: resp.StatusCode, Message: body.Message}
}
}
return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("unexpected status: %d", resp.StatusCode)}
}
Expand Down Expand Up @@ -83,12 +90,15 @@ func (c *Client) do(req *http.Request) (*http.Response, error) {
return resp, nil
}

func (c *Client) newRequest(method, path string) (*http.Request, error) {
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
url := fmt.Sprintf("%s%s", c.baseURL, path)
req, err := http.NewRequest(method, url, nil)
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
Loading
Loading