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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Make send idempotency keys optional and inspect messages by ID without saved project filters. Include the send profile in the delivery command.
- Add a macOS and Linux shell installer to release assets and document website download links.
- Add a branded terminal welcome, readable tables, action results, and local webhook status lines.
- Select JSON automatically in pipes; add `--plain` and `--color` for human output.
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,10 @@ lettermint messages send \
--from "Orders <orders@example.com>" \
--to recipient@example.net \
--subject "Your order is confirmed" \
--text "We received your order and will notify you when it ships." \
--idempotency-key order-1042-confirmation
--text "We received your order and will notify you when it ships."
```

The command uses your saved project. **Accepted** means the message is queued for processing; it does not confirm delivery. Use a new idempotency key for each new message. After a timeout or uncertain response, retry with the same key and exact input.
The command uses your saved project. **Accepted** means the message is queued for processing; it does not confirm delivery. `--idempotency-key` is optional. Without a key, each command sends a new message. For duplicate protection, supply a key on the first attempt and reuse it with the same project, route, and exact input after an uncertain result.

Inspect the result with the returned message ID:

Expand All @@ -65,7 +64,7 @@ lettermint messages get MESSAGE_ID
lettermint messages events MESSAGE_ID
```

Use `--profile`, `--project`, or `--route` to override saved defaults for one command. See the [message guide](skills/lettermint-cli/references/messages.md) for JSON input, HTML, attachments, and content exports.
Message lookup uses the selected profile and ignores saved project and route defaults. Add `--project` to restrict a lookup to that project. Sends and lists still use saved defaults, which you can override with `--project` and `--route`. See the [message guide](skills/lettermint-cli/references/messages.md) for JSON input, HTML, attachments, and content exports.

### Switch teams

Expand Down
8 changes: 4 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The welcome banner appears on bare `lettermint`, top-level help, and interactive

## Profiles and login recovery

Each profile contains a login for one user and one team. Use `--profile` to select it. Use `--project` and `--route` to replace saved defaults for one command. Changing the project clears an old route default.
Each profile contains a login for one user and one team. Use `--profile` to select it. Use `--project` and `--route` to replace saved defaults for one command. Changing the project clears an old route default. For `messages get`, `events`, and `content`, saved project and route defaults do not apply. These commands find the message by ID within the selected profile. An explicit `--project` restricts the lookup; `--route` does not apply.

Normal logout revokes server access and removes the saved login:

Expand Down Expand Up @@ -53,15 +53,15 @@ A new project has a transactional route and SMTP disabled by default. Set `initi

Single-message fields include `from`, `to`, `cc`, `bcc`, `reply_to`, `subject`, `html`, `text`, `headers`, `metadata`, `tags`, `settings`, and `attachments`. Metadata values are strings. A tag has `name` and `value`. An attachment has `filename` and base64 `content`, with optional `content_type` and `content_id`.

Use the same `--idempotency-key` and exact input after an uncertain send result. An accepted message is queued for processing; it is not proof of delivery. Scheduling and batch sends are outside v1.
`--idempotency-key` is optional. If supplied, it must contain 1 to 255 bytes. Without a key, each command sends a new message. For duplicate protection, supply a key on the first attempt and reuse it with the same profile, project, route, and exact input after an uncertain result. The CLI does not generate keys or retry sends automatically. An accepted message is queued for processing; it is not proof of delivery. Scheduling and batch sends are outside v1.

## Lists and content

`--limit` selects 1 to 100 results. Use the returned `next_cursor` with `--cursor` to get the next page. Keep the same profile, project, route, and command. The cursor keeps its original page size.

```sh
lettermint messages list --profile work --project PROJECT_ID --limit 20 --json
lettermint messages content MESSAGE_ID --profile work --project PROJECT_ID --format text --output message.txt
lettermint messages content MESSAGE_ID --profile work --format text --output message.txt
```

Content export supports `raw`, `html`, and `text`. Raw export preserves the original JSON or MIME source. Content access is checked separately from message-list access.
Expand All @@ -76,6 +76,6 @@ Human mode shows the error, field validation messages, and a relevant next step
{"error":{"code":"validation_failed","message":"validation_failed: The input is invalid.","details":{"from":["The sender address is invalid."]}}}
```

Exit codes are 1 for local failures, 2 for API validation errors, 3 for authentication, 4 for permission, 5 for a missing resource, 6 for conflict or expired state, 7 for rate limits, 8 for other API failures, and 130 for cancellation. Use the error code and details to decide the next action. Do not retry a send with a new idempotency key after an uncertain result.
Exit codes are 1 for local failures, 2 for API validation errors, 3 for authentication, 4 for permission, 5 for a missing resource, 6 for conflict or expired state, 7 for rate limits, 8 for other API failures, and 130 for cancellation. Use the error code and details to decide the next action. After an uncertain send with a key, reuse that key and the exact input. If the send had no key, check the message list before sending again. Another send can create a duplicate, even if you add a key to the retry.

See the [message workflows](../skills/lettermint-cli/references/messages.md) and [webhook workflows](../skills/lettermint-cli/references/webhooks.md) for more examples.
9 changes: 9 additions & 0 deletions internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import (
"io"
)

// SendError preserves retry context without changing API error codes or details.
type SendError struct {
Err error
HasIdempotencyKey bool
}

func (e *SendError) Error() string { return e.Err.Error() }
func (e *SendError) Unwrap() error { return e.Err }

// WriteError writes one JSON object, including field errors when the API supplies them.
func WriteError(w io.Writer, err error) error {
body := map[string]any{"code": ErrorCode(err), "message": err.Error()}
Expand Down
6 changes: 5 additions & 1 deletion internal/api/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ func (c *Client) Send(ctx context.Context, project, key string, input SendInput)
}
wire := input
wire.RouteID = ""
raw, err := c.Do(ctx, http.MethodPost, "/v1/send", query, wire, http.Header{"Idempotency-Key": {key}})
headers := http.Header{}
if key != "" {
headers.Set("Idempotency-Key", key)
}
raw, err := c.Do(ctx, http.MethodPost, "/v1/send", query, wire, headers)
if err != nil {
return Response[SendResult]{}, err
}
Expand Down
283 changes: 283 additions & 0 deletions internal/command/message_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package command

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/lettermint/lettermint-cli/internal/api"
"github.com/lettermint/lettermint-cli/internal/config"
"github.com/lettermint/lettermint-cli/internal/presentation"
)

func TestSendWithoutKeyProducesUsableDeliveryCommand(t *testing.T) {
sends, reads := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/send":
sends++
if r.Method != "POST" || r.URL.Query().Get("project_id") != "send-project" || r.URL.Query().Has("route_id") {
t.Errorf("wrong send context: %s %s", r.Method, r.URL)
}
if _, present := r.Header["Idempotency-Key"]; present {
t.Error("unexpected idempotency header")
}
io.WriteString(w, `{"message_id":"message-one","status":"pending"}`)
case "/v1/messages/message-one/events":
reads++
if r.URL.Query().Has("filter[project]") || r.URL.Query().Has("filter[route_id]") {
t.Errorf("unexpected filters: %s", r.URL)
}
io.WriteString(w, `{"data":[],"links":{"next":null}}`)
default:
t.Errorf("unexpected request: %s", r.URL)
http.NotFound(w, r)
}
}))
defer server.Close()
store := authenticatedStore(t, server.URL)
cmd := newWithStore("test", "public", store)
cmd.SetArgs([]string{"messages", "send", "--profile", "work", "--project", "send-project", "--file", "-", "--plain"})
cmd.SetIn(strings.NewReader(`{"from":"sender@example.com","to":["reader@example.net"],"subject":"Order","text":"Hello"}`))
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var next string
for _, line := range strings.Split(out.String(), "\n") {
if suffix, ok := strings.CutPrefix(line, "Check delivery with: lettermint "); ok {
next = suffix
}
}
if next != "messages events message-one --profile work" {
t.Fatalf("wrong delivery hint: %s", out.String())
}
// The copied command must still use work after another profile becomes selected.
if err := store.Update(context.Background(), func(c *config.Config) error {
c.Selected = "other"
c.Profiles["other"] = config.Profile{APIURL: "https://invalid.example"}
return nil
}); err != nil {
t.Fatal(err)
}
lookup := newWithStore("test", "public", store)
lookup.SetArgs(strings.Fields(next))
out.Reset()
lookup.SetOut(&out)
if err := lookup.Execute(); err != nil {
t.Fatal(err)
}
if !json.Valid(out.Bytes()) || sends != 1 || reads != 1 {
t.Fatalf("sends=%d reads=%d output=%s", sends, reads, &out)
}
_, profile, err := store.Resolve("work")
if err != nil || profile.Project != "project-one" || profile.Route != "route-one" {
t.Fatal("saved context changed")
}
}

func TestMessageLookupUsesOnlyExplicitProject(t *testing.T) {
for _, action := range []string{"get", "events", "content"} {
for _, savedProject := range []string{"", "unrelated-project"} {
for _, explicitProject := range []string{"", "message-project", "wrong-project"} {
t.Run(action+"/saved="+savedProject+"/explicit="+explicitProject, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Query().Get("filter[project]") != explicitProject || r.URL.Query().Has("filter[route_id]") {
t.Errorf("wrong filters: %s", r.URL)
}
if explicitProject == "" && r.URL.Query().Has("filter[project]") {
t.Error("empty project filter was sent")
}
if explicitProject == "wrong-project" {
w.WriteHeader(404)
io.WriteString(w, `{"message":"Not found"}`)
return
}
switch action {
case "events":
io.WriteString(w, `{"data":[],"links":{"next":null}}`)
case "get":
io.WriteString(w, `{"data":{"id":"message-one","status":"delivered"}}`)
case "content":
io.WriteString(w, "Exact content\r\n")
}
}))
defer server.Close()
store := authenticatedStore(t, server.URL)
if err := store.Update(context.Background(), func(c *config.Config) error {
p := c.Profiles["work"]
p.Project = savedProject
c.Profiles["work"] = p
return nil
}); err != nil {
t.Fatal(err)
}
cmd := newWithStore("test", "public", store)
args := []string{"messages", action, "message-one", "--plain", "--route", "ignored-route"}
if explicitProject != "" {
args = append(args, "--project", explicitProject)
}
cmd.SetArgs(args)
var out bytes.Buffer
cmd.SetOut(&out)
err := cmd.Execute()
if explicitProject == "wrong-project" {
if api.ExitCode(err) != 5 {
t.Fatalf("expected not found, got %v", err)
}
} else if err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("calls=%d", calls)
}
if action == "content" && err == nil && out.String() != "Exact content\r\n" {
t.Fatal(out.String())
}
if strings.Contains(out.String(), "Route:") || strings.Contains(out.String(), "unrelated-project") || (explicitProject == "" && strings.Contains(out.String(), "Project:")) {
t.Fatalf("ignored context displayed: %s", &out)
}
})
}
}
}
}

func TestEventsCursorIgnoresSavedContextChanges(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Query().Has("filter[project]") || r.URL.Query().Has("filter[route_id]") {
t.Errorf("unexpected filters: %s", r.URL)
}
if calls == 1 {
io.WriteString(w, `{"data":[{"event":"message.accepted"}],"links":{"next":"/v1/messages/message-one/events?page%5Bcursor%5D=next&page%5Bsize%5D=1"}}`)
} else {
if r.URL.Query().Get("page[cursor]") != "next" || r.URL.Query().Get("page[size]") != "1" {
t.Errorf("wrong cursor: %s", r.URL)
}
io.WriteString(w, `{"data":[{"event":"message.delivered"}],"links":{"next":null}}`)
}
}))
defer server.Close()
store := authenticatedStore(t, server.URL)
var cursor string
for page := 0; page < 2; page++ {
cmd := newWithStore("test", "public", store)
args := []string{"messages", "events", "message-one", "--json", "--limit", "1"}
if page == 1 {
args = append(args, "--cursor", cursor)
}
cmd.SetArgs(args)
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var result struct {
NextCursor *string `json:"next_cursor"`
Data []map[string]string `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if page == 0 {
if result.NextCursor == nil {
t.Fatal("missing next cursor")
}
cursor = *result.NextCursor
if err := store.Update(context.Background(), func(c *config.Config) error {
p := c.Profiles["work"]
p.Project = "changed-project"
p.Route = "changed-route"
c.Profiles["work"] = p
return nil
}); err != nil {
t.Fatal(err)
}
} else if result.NextCursor != nil || result.Data[0]["event"] != "message.delivered" {
t.Fatal(out.String())
}
}
if calls != 2 {
t.Fatalf("calls=%d", calls)
}
}

func TestSendRejectsInvalidExplicitKeyBeforeAuthentication(t *testing.T) {
for _, key := range []string{"", strings.Repeat("k", 256), strings.Repeat("é", 128)} {
cmd := New("test", "public")
cmd.SetArgs([]string{"messages", "send", "--idempotency-key", key})
if err := cmd.Execute(); err == nil || err.Error() != "--idempotency-key must contain 1 to 255 bytes" {
t.Fatalf("error=%v", err)
}
}
}

func TestSendFailureKeepsRetryContextAndDoesNotRetry(t *testing.T) {
for _, key := range []string{"", "order-1042", strings.Repeat("k", 255)} {
for _, status := range []int{422, 503} {
t.Run(fmt.Sprintf("key-length=%d/status=%d", len(key), status), func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Idempotency-Key") != key {
t.Error("key changed")
}
if key == "" {
if _, exists := r.Header["Idempotency-Key"]; exists {
t.Error("empty header present")
}
}
w.WriteHeader(status)
io.WriteString(w, `{"message":"Send failed","errors":{"from":["Invalid sender"]}}`)
}))
defer server.Close()
cmd := newWithStore("test", "public", authenticatedStore(t, server.URL))
args := []string{"messages", "send", "--from", "sender@example.com", "--to", "reader@example.net", "--subject", "Order", "--text", "Hello"}
if key != "" {
args = append(args, "--idempotency-key", key)
}
cmd.SetArgs(args)
err := cmd.Execute()
var send *api.SendError
if !errors.As(err, &send) || send.HasIdempotencyKey != (key != "") || calls != 1 {
t.Fatalf("calls=%d error=%v", calls, err)
}
var out, diagnostics bytes.Buffer
p := presentation.New(&out, &diagnostics, presentation.Options{Plain: true})
if renderErr := p.Error(err); renderErr != nil {
t.Fatal(renderErr)
}
if status == 503 {
expected := "another send can create a duplicate"
if key != "" {
expected = "same profile, project, route, input, and idempotency key"
}
if !strings.Contains(diagnostics.String(), expected) {
t.Fatal(diagnostics.String())
}
} else if strings.Contains(diagnostics.String(), "retry") {
t.Fatal("retry advice for validation error")
}
diagnostics.Reset()
if renderErr := presentation.New(&out, &diagnostics, presentation.Options{JSON: true}).Error(err); renderErr != nil {
t.Fatal(renderErr)
}
if !json.Valid(diagnostics.Bytes()) || !strings.Contains(diagnostics.String(), `"from":["Invalid sender"]`) {
t.Fatal(diagnostics.String())
}
})
}
}
}
6 changes: 3 additions & 3 deletions internal/command/presentation.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,9 @@ func commandExample(key string) string {
case "context show":
return "lettermint context show --profile work"
case "messages send":
return "lettermint messages send --profile work --project PROJECT_ID --file message.json --idempotency-key order-1042"
return "lettermint messages send --profile work --project PROJECT_ID --file message.json"
case "messages content":
return "lettermint messages content MESSAGE_ID --project PROJECT_ID --format text --output message.txt"
return "lettermint messages content MESSAGE_ID --profile work --format text --output message.txt"
case "webhooks listen":
return "lettermint webhooks listen --project PROJECT_ID --forward-to http://localhost:3000/webhooks/lettermint"
case "listeners replay":
Expand All @@ -178,7 +178,7 @@ func commandExample(key string) string {
}
base := "lettermint " + key
context := " --profile work"
if parts[0] == "messages" || parts[0] == "routes" {
if (parts[0] == "messages" && parts[1] == "list") || parts[0] == "routes" {
context += " --project PROJECT_ID"
}
switch parts[1] {
Expand Down
Loading
Loading