Skip to content
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ through verbatim.
| `not_authenticated` | Missing or invalid credentials (HTTP 401) |
| `forbidden` | Authenticated but not allowed (HTTP 403) |
| `not_found` | Unknown resource (HTTP 404) |
| `invalid_input` | Bad request / validation failure (HTTP 400, 422) |
| `invalid_input` | Bad request or CLI usage error (HTTP 400, 422) |
| `rate_limited` | Throttled by the server (HTTP 429) |
| `try_again` | Verification is still processing (HTTP 249) |
| `server_error` | Server-side failure (HTTP 5xx) |
Expand Down Expand Up @@ -409,9 +409,8 @@ EMAILABLE_DEBUG=1 emailable verify hello@example.com

### Quiet mode

Pass `--quiet` (or `-q`) to suppress non-error human output — success
lines, hints, notices, progress bars and spinners. Errors still print, and
`--json` output is unaffected (quiet is a human-mode-only modifier).
Pass `--quiet` (or `-q`) to suppress hints, progress, and status messages.
Results and errors still print, and `--json` output is unaffected.
Mirrors the convention in `curl`, `docker`, and `gh`.

```bash
Expand Down
7 changes: 7 additions & 0 deletions cmd/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ func newAccountCmd() *cobra.Command {
account := &cobra.Command{
Use: "account",
Short: "Manage your Emailable account",
Args: unknownSubcommand,
RunE: showHelp,
SilenceUsage: true,
Example: ` # Show the owner email and remaining credits
emailable account status`,
Expand Down Expand Up @@ -37,6 +39,11 @@ func newAccountCmd() *cobra.Command {
if err != nil {
return err
}
// JSON prints the response itself so its raw body passes through
// with any fields AccountView doesn't model.
if jsonOutput {
return newOutput(cmd.OutOrStdout(), true).Print(a)
}
view := &output.AccountView{
OwnerEmail: a.OwnerEmail,
AvailableCredits: a.AvailableCredits,
Expand Down
79 changes: 79 additions & 0 deletions cmd/auth_display_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"net/http"
"strings"
"testing"

"github.com/emailable/emailable-cli/internal/credentials"
)

func TestAccountStatus_JSONPassesThroughUnknownFields(t *testing.T) {
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"owner_email": "o@x.com", "available_credits": 5, "plan": "pro"})
}))
env.seedAPIKey(t, "sk_test_xxx")

res := runRoot(t, "account", "status", "--json")
if res.Err != nil {
t.Fatalf("execute: %v", res.Err)
}
if payload := decodeJSON(t, res.Stdout.Bytes()); payload["plan"] != "pro" {
t.Errorf("expected unmodeled field to pass through, got %v", payload)
}

res = runRoot(t, "account", "status", "--jq", ".plan")
if res.Err != nil {
t.Fatalf("execute: %v", res.Err)
}
if got := strings.TrimSpace(res.Stdout.String()); got != "pro" {
t.Errorf("--jq .plan: got %q", got)
}
}

func seedStoredKeyWithEmail(t *testing.T, env *testEnv) {
t.Helper()
creds := &credentials.Credentials{APIKey: "sk_test_xxx", OwnerEmail: "owner@example.com"}
if err := creds.Save(env.CredentialsPath); err != nil {
t.Fatal(err)
}
}

func TestStatus_StoredAPIKeyShowsEmail(t *testing.T) {
env := newTestEnv(t, http.NotFoundHandler())
seedStoredKeyWithEmail(t, env)

res := runRoot(t, "status", "--json")
if res.Err != nil {
t.Fatalf("execute: %v", res.Err)
}
if payload := decodeJSON(t, res.Stdout.Bytes()); payload["owner_email"] != "owner@example.com" {
t.Errorf("expected owner_email, got %v", payload)
}

res = runRoot(t, "status")
if res.Err != nil {
t.Fatalf("execute: %v", res.Err)
}
if !strings.Contains(res.Stdout.String(), "owner@example.com") {
t.Errorf("expected Account row, got %q", res.Stdout.String())
}
}

func TestStatus_EnvAPIKeyHidesStoredEmail(t *testing.T) {
env := newTestEnv(t, http.NotFoundHandler())
seedStoredKeyWithEmail(t, env)
t.Setenv("EMAILABLE_API_KEY", "sk_env_yyy")

res := runRoot(t, "status", "--json")
if res.Err != nil {
t.Fatalf("execute: %v", res.Err)
}
if payload := decodeJSON(t, res.Stdout.Bytes()); payload["owner_email"] != nil {
t.Errorf("stored email must not show for an env key, got %v", payload["owner_email"])
}
res = runRoot(t, "status")
if strings.Contains(res.Stdout.String(), "owner@example.com") {
t.Errorf("stored email must not show for an env key, got %q", res.Stdout.String())
}
}
70 changes: 63 additions & 7 deletions cmd/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ func newBatchCmd() *cobra.Command {
batch := &cobra.Command{
Use: "batch",
Short: "Verify a batch of emails",
Args: unknownSubcommand,
RunE: showHelp,
SilenceUsage: true,
Example: ` # Submit a batch and wait for completion
emailable batch verify emails.csv --wait
Expand All @@ -36,7 +38,7 @@ func newBatchCmd() *cobra.Command {
"once complete. Use `--wait` to poll until completion, or " +
"`--partial` to include partial results while still verifying " +
"(batches ≤ 1,000 emails only).",
Args: wrapInvalidInputArgs(cobra.ExactArgs(1)),
Args: cobra.ExactArgs(1),
SilenceUsage: true,
Example: ` # Get the latest status / results for a batch
emailable batch get bch_123
Expand All @@ -52,6 +54,9 @@ func newBatchCmd() *cobra.Command {
partial, _ := cmd.Flags().GetBool("partial")
outPath, _ := cmd.Flags().GetString("output")
showAll, _ := cmd.Flags().GetBool("all")
if wait && partial {
return NewInvalidInput("--wait and --partial can't be combined: --wait already polls until completion")
}

cctx, err := newCmdCtxFor(cmd, jsonOutput)
if err != nil {
Expand All @@ -63,9 +68,6 @@ func newBatchCmd() *cobra.Command {
}

if wait {
if partial {
return fmt.Errorf("--wait and --partial can't be combined: --wait already polls until completion")
}
s, err := waitForCompletion(cmd.Context(), client, id, cctx.JSONMode || cctx.Quiet, cmd.ErrOrStderr())
if err != nil {
return err
Expand All @@ -91,7 +93,7 @@ func newBatchCmd() *cobra.Command {
Long: "Verify a batch of emails. Accepts one or more emails or `.csv` / " +
"`.json` / `.txt` files. Prints the batch ID; use `--wait` to poll " +
"until complete.",
Args: wrapInvalidInputArgs(cobra.MinimumNArgs(1)),
Args: cobra.MinimumNArgs(1),
SilenceUsage: true,
Example: ` # Verify a CSV file and block until results are ready
emailable batch verify emails.csv --wait
Expand Down Expand Up @@ -359,6 +361,10 @@ func saveToFile(cmd *cobra.Command, cctx *cmdCtx, v any, path string) error {
if err != nil {
return err
}
return reportSaved(cmd, cctx, n, path)
}

func reportSaved(cmd *cobra.Command, cctx *cmdCtx, n int, path string) error {
if !cctx.JSONMode {
h := &output.Human{W: cmd.ErrOrStderr(), Quiet: cctx.Quiet}
msg := savedMessage(n, path)
Expand All @@ -367,6 +373,49 @@ func saveToFile(cmd *cobra.Command, cctx *cmdCtx, v any, path string) error {
return nil
}

// saveBatchToFile refuses to write a header-only file for a batch whose rows
// aren't available, and fetches the results file for large batches.
func saveBatchToFile(cmd *cobra.Command, cctx *cmdCtx, status *api.BatchStatus, batchID, path string) error {
// Only `batch get` has --partial; the lookup fails (false) elsewhere.
partial, _ := cmd.Flags().GetBool("partial")
if !status.IsComplete() && !partial {
Comment thread
jclusso marked this conversation as resolved.
if processed, total, ok := status.Progress(); ok {
return NewInvalidInputf("batch %s is still verifying (%d/%d); use --wait or --partial", batchID, processed, total)
}
return NewInvalidInputf("batch %s is still verifying; use --wait or --partial", batchID)
}
if status.DownloadFile != "" {
return saveDownloadToFile(cmd, cctx, status, path)
}
if !status.IsComplete() && len(status.Emails) == 0 {
return NewInvalidInputf("batch %s has no verified emails yet; try again shortly or use --wait", batchID)
}
if _, total, ok := status.Progress(); ok && total > 0 && status.IsComplete() && len(status.Emails) == 0 {
return fmt.Errorf("batch %s returned no per-email results to save", batchID)
}
return saveToFile(cmd, cctx, status, path)
}

func saveDownloadToFile(cmd *cobra.Command, cctx *cmdCtx, status *api.BatchStatus, path string) error {
sp := ui.NewTo(cmd.ErrOrStderr(), "Downloading results")
if !cctx.JSONMode && !cctx.Quiet {
sp.Start()
}
d, err := api.FetchDownload(cmd.Context(), status.DownloadFile, nil)
sp.Stop()
if err != nil {
return err
}
n, err := output.WriteDownload(d, status, output.SaveOptions{
Path: path,
ForceJSON: cctx.JSONMode,
})
if err != nil {
return err
}
return reportSaved(cmd, cctx, n, path)
}

func savedMessage(n int, path string) string {
switch {
case n <= 0:
Expand All @@ -380,12 +429,19 @@ func savedMessage(n int, path string) string {

func renderBatchOutcome(cmd *cobra.Command, cctx *cmdCtx, status *api.BatchStatus, batchID, outPath string, showAll bool) error {
if outPath != "" {
return saveToFile(cmd, cctx, status, outPath)
return saveBatchToFile(cmd, cctx, status, batchID, outPath)
}
if cctx.JSONMode {
return newOutput(cmd.OutOrStdout(), true).Print(status)
}
if status.DownloadFile != "" || len(status.Emails) == 0 {
if status.DownloadFile != "" {
if err := newOutput(cmd.OutOrStdout(), false).Print(status); err != nil {
return err
}
h := &output.Human{W: cmd.OutOrStdout(), Quiet: cctx.Quiet}
return h.Hint(fmt.Sprintf("Run `emailable batch get %s -o results.csv` to save the results.", batchID))
}
if len(status.Emails) == 0 {
return newOutput(cmd.OutOrStdout(), false).Print(status)
}

Expand Down
17 changes: 14 additions & 3 deletions cmd/batch_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,13 +454,24 @@ func TestRenderBatchOutcome_DownloadFile(t *testing.T) {
cmd.SetOut(&stdout)
cmd.SetErr(&bytes.Buffer{})

status := &api.BatchStatus{ID: "bch_big", DownloadFile: "https://files.example/big.csv"}
status := &api.BatchStatus{
ID: "bch_big",
DownloadFile: "https://files.example/big.csv",
TotalCounts: &api.BatchTotalCounts{Total: 3, Processed: 3, Deliverable: 2, Unknown: 1},
}
cctx := &cmdCtx{JSONMode: false}
if err := renderBatchOutcome(cmd, cctx, status, "bch_big", "", false); err != nil {
t.Fatalf("renderBatchOutcome: %v", err)
}
if !strings.Contains(stdout.String(), "big.csv") {
t.Errorf("expected download URL in output, got %q", stdout.String())
out := stdout.String()
if !strings.Contains(out, "Verified 3 emails: 2 Deliverable, 1 Unknown") {
t.Errorf("expected a summary from total_counts, got %q", out)
}
if !strings.Contains(out, "batch get bch_big -o results.csv") {
t.Errorf("expected the -o hint, got %q", out)
}
if strings.Contains(out, "big.csv") {
t.Errorf("the presigned URL should stay out of human output, got %q", out)
}
}

Expand Down
Loading
Loading