From 65cff40ef4d31530b0dc5c29a1f0c728f4967929 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 27 Sep 2026 15:19:36 +0200 Subject: [PATCH] fix(messages): simplify sending and message lookup --- CHANGELOG.md | 1 + README.md | 7 +- docs/usage.md | 8 +- internal/api/errors.go | 9 + internal/api/resources.go | 6 +- internal/command/message_workflow_test.go | 283 +++++++++++++++++++ internal/command/presentation.go | 6 +- internal/command/root.go | 29 +- internal/command/root_test.go | 4 +- internal/presentation/help.go | 2 +- internal/presentation/presentation.go | 14 +- internal/presentation/results.go | 6 +- skills/lettermint-cli/references/messages.md | 16 +- 13 files changed, 358 insertions(+), 33 deletions(-) create mode 100644 internal/command/message_workflow_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1cf0b..386dad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2a09f16..9835120 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,10 @@ lettermint messages send \ --from "Orders " \ --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: @@ -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 diff --git a/docs/usage.md b/docs/usage.md index de90ba5..008b40a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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: @@ -53,7 +53,7 @@ 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 @@ -61,7 +61,7 @@ Use the same `--idempotency-key` and exact input after an uncertain send result. ```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. @@ -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. diff --git a/internal/api/errors.go b/internal/api/errors.go index 8e2e14a..c3fa43d 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -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()} diff --git a/internal/api/resources.go b/internal/api/resources.go index 78742ab..4f6833c 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -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 } diff --git a/internal/command/message_workflow_test.go b/internal/command/message_workflow_test.go new file mode 100644 index 0000000..27db51b --- /dev/null +++ b/internal/command/message_workflow_test.go @@ -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()) + } + }) + } + } +} diff --git a/internal/command/presentation.go b/internal/command/presentation.go index ec83924..f5b84a1 100644 --- a/internal/command/presentation.go +++ b/internal/command/presentation.go @@ -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": @@ -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] { diff --git a/internal/command/root.go b/internal/command/root.go index b199d1f..c7ab1ac 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -37,8 +37,8 @@ func newWithStore(version, clientID string, store *config.Store) *cobra.Command a := &app{version: version, clientID: clientID, store: store} root := &cobra.Command{Use: "lettermint", Short: "Send email and develop with Lettermint", SilenceUsage: true, SilenceErrors: true} root.PersistentFlags().StringVar(&a.profile, "profile", "", "Saved profile") - root.PersistentFlags().StringVar(&a.project, "project", "", "Project ID; overrides the profile default") - root.PersistentFlags().StringVar(&a.route, "route", "", "Route ID; overrides the profile default") + root.PersistentFlags().StringVar(&a.project, "project", "", "Project ID; overrides the profile default; message lookup uses no default") + root.PersistentFlags().StringVar(&a.route, "route", "", "Route ID; overrides the profile default; ignored for message lookup") root.PersistentFlags().BoolVar(&a.display.JSON, "json", false, "Write JSON; listeners write newline-delimited JSON (automatic in pipes)") root.PersistentFlags().BoolVar(&a.display.Plain, "plain", false, "Write readable text without color, banners, or animation") root.PersistentFlags().StringVar(&a.display.Color, "color", "auto", "Human output color: auto, always, or never") @@ -81,6 +81,13 @@ func (a *app) client(c *cobra.Command) (*api.Client, config.Profile, string, err if c.Flags().Changed("route") || c.InheritedFlags().Changed("route") { p.Route = a.route } + switch c.CommandPath() { + case "lettermint messages get", "lettermint messages events", "lettermint messages content": + if !c.Flags().Changed("project") && !c.InheritedFlags().Changed("project") { + p.Project = "" + } + p.Route = "" + } h := api.Transport() a.scope = presentation.Context{Profile: name, Project: p.Project, Route: p.Route} return &api.Client{BaseURL: p.APIURL, HTTP: h, Token: auth.TokenSource(s, name, p, h)}, p, name, nil @@ -208,7 +215,7 @@ func (a *app) resource(parent *cobra.Command, group, action string) { path = "/v1/projects/" + url.PathEscape(p.Project) + "/routes" query = url.Values{} } - if group == "messages" && p.Project == "" { + if group == "messages" && action == "list" && p.Project == "" { return errors.New("select a project with --project or context set") } if group == "projects" || group == "listeners" { @@ -303,6 +310,9 @@ func (a *app) sendCommand(parent *cobra.Command) { var file, key, from, subject, html, text, headers, metadata, attachments, tags, settings string var to, cc, bcc, reply []string cmd := &cobra.Command{Use: "send", Short: "Send one message; an accepted response does not confirm delivery", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { + if c.Flags().Changed("idempotency-key") && (len(key) == 0 || len(key) > 255) { + return errors.New("--idempotency-key must contain 1 to 255 bytes") + } fields := []string{"from", "to", "cc", "bcc", "reply-to", "subject", "html", "text", "headers", "metadata", "attachments", "tags", "settings"} if file != "" { for _, field := range fields { @@ -369,14 +379,13 @@ func (a *app) sendCommand(parent *cobra.Command) { } b, err := client.Send(c.Context(), p.Project, key, message) if err != nil { - return err + return &api.SendError{Err: err, HasIdempotencyKey: key != ""} } return a.output(c, b) }} f := cmd.Flags() f.StringVar(&file, "file", "", "JSON message file; use - for standard input") - f.StringVar(&key, "idempotency-key", "", "Stable key; reuse it with the same input after an uncertain response") - _ = cmd.MarkFlagRequired("idempotency-key") + f.StringVar(&key, "idempotency-key", "", "Optional key (1 to 255 bytes); reuse it with the same input after an uncertain response") for _, v := range []struct { target *string name, help string @@ -399,11 +408,11 @@ func (a *app) contentCommand(parent *cobra.Command) { if err != nil { return err } - if p.Project == "" { - return errors.New("select a project") - } endpoint := format - query := url.Values{"filter[project]": {p.Project}} + query := url.Values{} + if p.Project != "" { + query.Set("filter[project]", p.Project) + } if format == "raw" { endpoint = "source" query.Set("format", "stored") diff --git a/internal/command/root_test.go b/internal/command/root_test.go index f727c5e..c7e0669 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -140,7 +140,7 @@ func TestSharedResourceContract(t *testing.T) { {"domain assign", []string{"domains", "assign", "domain-one", "--file", "-", "--yes"}, `{"project_ids":["project-one"]}`, "PUT", "/v1/domains/domain-one/projects", map[string]string{"filter[project]": "project-one"}, map[string]any{"project_ids": []any{"project-one"}}}, {"project defaults", []string{"projects", "create", "--file", "-"}, `{"name":"New project"}`, "POST", "/v1/projects", nil, map[string]any{"name": "New project", "smtp_enabled": false, "initial_routes": "transactional"}}, {"project explicit options", []string{"projects", "create", "--file", "-"}, `{"name":"New project","smtp_enabled":true,"initial_routes":"both"}`, "POST", "/v1/projects", nil, map[string]any{"name": "New project", "smtp_enabled": true, "initial_routes": "both"}}, - {"message events", []string{"messages", "events", "message-one", "--limit", "2"}, "", "GET", "/v1/messages/message-one/events", map[string]string{"filter[project]": "project-one", "filter[route_id]": "route-one", "page[size]": "2"}, nil}, + {"message events", []string{"messages", "events", "message-one", "--limit", "2"}, "", "GET", "/v1/messages/message-one/events", map[string]string{"page[size]": "2"}, nil}, {"listener list", []string{"listeners", "list", "--limit", "2"}, "", "GET", "/v1/listeners", map[string]string{"limit": "2"}, nil}, } for _, tc := range cases { @@ -214,7 +214,7 @@ func TestContentUsesExistingEndpointsWithoutChangingBytes(t *testing.T) { t.Error("missing exact source format") } } - if r.URL.Path != "/v1/messages/message-one/"+endpoint || r.URL.Query().Get("filter[project]") != "project-one" { + if r.URL.Path != "/v1/messages/message-one/"+endpoint || r.URL.Query().Has("filter[project]") { t.Errorf("wrong content request: %s", r.URL) } w.Write(content) diff --git a/internal/presentation/help.go b/internal/presentation/help.go index 9f7c4de..12359a7 100644 --- a/internal/presentation/help.go +++ b/internal/presentation/help.go @@ -11,7 +11,7 @@ func (p *Presenter) Help(command, description, example, usage string, root bool) if root { b.WriteString(p.heading("Start here") + "\n") b.WriteString(" lettermint auth login --name work\n") - b.WriteString(" lettermint messages send --project PROJECT_ID --file message.json --idempotency-key order-1042\n") + b.WriteString(" lettermint messages send --project PROJECT_ID --file message.json\n") b.WriteString(" lettermint webhooks listen --project PROJECT_ID --forward-to http://localhost:3000/webhooks/lettermint\n\n") } else { fmt.Fprintf(&b, "%s\n\n%s\n\n", p.heading(Text(command)), Text(description)) diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index 9ea84a6..e150773 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -221,15 +221,23 @@ func (p *Presenter) Error(err error) error { case 5: hint = "Check the resource ID and the selected project." case 7: - hint = "Retry later. For a send, keep the same input and idempotency key." + hint = "Retry later." case 8: - hint = "For an uncertain send result, keep the same input and idempotency key." + hint = "Check the service status before you retry." case 1: hint = "Use the command's --help to check its arguments." } var network net.Error if errors.As(err, &network) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { - hint = "Check the connection and retry later. For an uncertain send, keep the same input and idempotency key." + hint = "Check the connection before you retry." + } + var send *api.SendError + if errors.As(err, &send) && (api.ExitCode(err) == 1 || api.ExitCode(err) == 7 || api.ExitCode(err) == 8) { + if send.HasIdempotencyKey { + hint = "For an uncertain send result, retry with the same profile, project, route, input, and idempotency key." + } else { + hint = "This send did not use an idempotency key. Check the message list before you retry; another send can create a duplicate." + } } if hint != "" { fmt.Fprintf(&b, "\n%s\n", hint) diff --git a/internal/presentation/results.go b/internal/presentation/results.go index c7a70d3..6277d91 100644 --- a/internal/presentation/results.go +++ b/internal/presentation/results.go @@ -101,7 +101,11 @@ func (p *Presenter) Result(command string, value any, scope Context) error { case "messages send": b.WriteString("\nAccepted for processing. Delivery is not yet confirmed.\n") if m, ok := data.(map[string]any); ok { - fmt.Fprintf(&b, "Check delivery with: lettermint messages events %s\n", scalar(m["message_id"])) + fmt.Fprintf(&b, "Check delivery with: lettermint messages events %s", scalar(m["message_id"])) + if scope.Profile != "" { + fmt.Fprintf(&b, " --profile %s", Text(scope.Profile)) + } + b.WriteByte('\n') } case "auth logout": if revoked, ok := root["revoked"].(bool); ok && !revoked { diff --git a/skills/lettermint-cli/references/messages.md b/skills/lettermint-cli/references/messages.md index a7b2d1c..b2f091d 100644 --- a/skills/lettermint-cli/references/messages.md +++ b/skills/lettermint-cli/references/messages.md @@ -2,14 +2,22 @@ Check `lettermint messages send --help`. Prepare a JSON file with the sender, recipients, subject, and HTML or text. Include attachments as base64 content if required. Scheduling and batch sends are not part of v1. +```sh +lettermint messages send --profile work --project PROJECT_ID --file message.json --json --no-input +lettermint messages get MESSAGE_ID --profile work --json --no-input +lettermint messages events MESSAGE_ID --profile work --json --no-input +lettermint messages content MESSAGE_ID --profile work --format raw --output message.eml --no-input +``` + +An idempotency key is optional. Without a key, each command sends a new message. For a send that must support retries, supply a key on the first attempt: + ```sh lettermint messages send --profile work --project PROJECT_ID --file message.json --idempotency-key change-1042 --json --no-input -lettermint messages get MESSAGE_ID --profile work --project PROJECT_ID --json --no-input -lettermint messages events MESSAGE_ID --profile work --project PROJECT_ID --json --no-input -lettermint messages content MESSAGE_ID --profile work --project PROJECT_ID --format raw --output message.eml --no-input ``` -Use the same idempotency key and exact input after an uncertain send result. Do not generate a new key for a retry. An accepted response means the mail pipeline accepted the message. It does not mean the recipient received it. Use the delivery events to check delivery. +After an uncertain result, repeat this command with the same profile, project, route, key, and exact input. Do not generate a new key for a retry. If the first attempt 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. + +Message lookup ignores saved project and route defaults. Use `--project` only to restrict a lookup. The selected profile still controls the team and access permissions. An accepted response means the mail pipeline accepted the message. It does not mean the recipient received it. Use the delivery events to check delivery. Message metadata and message content have separate permissions. If content access is denied, stop that operation. Do not use another command, profile, or webhook to obtain the same content. Source export preserves bytes. Keep exported files private.