all repos — postern

@ 50dc541db37adabbbb220f4abf62aba899c48f5d

Modern mail management

clone
git clone https:///postern.git
add kv management for CLI
Raphael Sprenger
Thu, 01 Oct 2026 19:12:31 +0200
commit

50dc541db37adabbbb220f4abf62aba899c48f5d

parent

8bbd7a84fa063eaf76ebf26f4887c9c2644053bf

M internal/cli/cli.go → internal/cli/cli.go

@@ -214,11 +214,42 @@ dkimCmd.AddCommand(enableDKIMCmd)

dkimCmd.AddCommand(disableDKIMCmd) dkimCmd.AddCommand(dkimRemoveCmd) + kvCmd := &cobra.Command{ + Use: "kv", + Short: "Inspect the key-value store", + } + + kvListCmd := &cobra.Command{ + Use: "list", + Short: "List all keys", + Args: cobra.NoArgs, + RunE: c.kvList, + } + + kvGetCmd := &cobra.Command{ + Use: "get <key>", + Short: "Get the value for a key", + Args: cobra.ExactArgs(1), + RunE: c.kvGet, + } + + kvPopCmd := &cobra.Command{ + Use: "pop <key>", + Short: "Remove key and return its value", + Args: cobra.ExactArgs(1), + RunE: c.kvPop, + } + + kvCmd.AddCommand(kvListCmd) + kvCmd.AddCommand(kvGetCmd) + kvCmd.AddCommand(kvPopCmd) + rootCmd.AddCommand(serveCmd) rootCmd.AddCommand(pruneCmd) rootCmd.AddCommand(userCmd) rootCmd.AddCommand(backupMxCmd) rootCmd.AddCommand(dkimCmd) + rootCmd.AddCommand(kvCmd) return rootCmd }
M internal/cli/client.go → internal/cli/client.go

@@ -310,7 +310,7 @@ return nil

} // clientPruneMails prunes orphaned mails via the daemon. -func (c *Command) clientPruneMails(ctx context.Context) (int, error) { +func (c *Command) clientPruneMails(_ context.Context) (int, error) { resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil) if err != nil { return 0, fmt.Errorf("failed to prune mails: %w", err)

@@ -328,3 +328,77 @@ }

return result["pruned"], nil } + +// clientKVList fetches all key-value pairs from the daemon. +func (c *Command) clientKVList(_ context.Context) (map[string]any, error) { + resp, err := c.httpClient.Get("http://unix/kv") + if err != nil { + return nil, fmt.Errorf("failed to list kv: %w", err) + } + defer model.Wrap(resp.Body.Close, &err, "clientKVList closing response body") + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list kv: %d", resp.StatusCode) + } + + var pairs map[string]any + if err := json.NewDecoder(resp.Body).Decode(&pairs); err != nil { + return nil, fmt.Errorf("failed to decode kv response: %w", err) + } + + return pairs, nil +} + +// clientKVGet fetches the value stored under key from the daemon. +// The key is URL-encoded so keys containing slashes work. +func (c *Command) clientKVGet(_ context.Context, key string) (any, error) { + path := "http://unix/kv/" + url.PathEscape(key) + + resp, err := c.httpClient.Get(path) + if err != nil { + return nil, fmt.Errorf("failed to get kv: %w", err) + } + defer model.Wrap(resp.Body.Close, &err, "clientKVGet closing response body") + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get kv: %d", resp.StatusCode) + } + + var result struct { + Value any `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode kv response: %w", err) + } + + return result.Value, nil +} + +// clientKVPop removes the value stored under key via the daemon and returns it. +// The key is URL-encoded so keys containing slashes work. +func (c *Command) clientKVPop(ctx context.Context, key string) (any, error) { + path := "http://unix/kv/" + url.PathEscape(key) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create pop kv request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to pop kv: %w", err) + } + defer model.Wrap(resp.Body.Close, &err, "clientKVPop closing response body") + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to pop kv: %d", resp.StatusCode) + } + + var result struct { + Value any `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode kv response: %w", err) + } + + return result.Value, nil +}
M internal/cli/http.go → internal/cli/http.go

@@ -108,6 +108,11 @@

// Prune endpoint mux.HandleFunc("POST /prune", h.pruneMails) + // KV endpoints + mux.HandleFunc("GET /kv", h.kvList) + mux.HandleFunc("GET /kv/{key...}", h.kvGet) + mux.HandleFunc("DELETE /kv/{key...}", h.kvPop) + return mux }

@@ -389,3 +394,44 @@ }

writeJSON(w, http.StatusOK, map[string]int{"pruned": count}) } + +func (h *HTTPService) kvList(w http.ResponseWriter, r *http.Request) { + pairs, err := h.cmd.KVList(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, pairs) +} + +func (h *HTTPService) kvGet(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if key == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key is required"}) + return + } + + val, err := h.cmd.KVGet(r.Context(), key) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"value": val}) +} + +func (h *HTTPService) kvPop(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if key == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key is required"}) + return + } + + val, err := h.cmd.KVPop(r.Context(), key) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"value": val}) +}
A internal/cli/kv.go

@@ -0,0 +1,119 @@

+package cli + +import ( + "context" + "database/sql" + "errors" + "fmt" + "text/tabwriter" + + "postern/internal/model" + + "github.com/spf13/cobra" +) + +// KVList returns all stored key-value pairs. +func (c *Command) KVList(ctx context.Context) (map[string]any, error) { + pairs, err := c.db.KVList(ctx) + if err != nil { + return nil, err + } + for key, value := range pairs { + pairs[key] = normalizeKVValue(value) + } + return pairs, nil +} + +// KVGet returns the value stored under key. +func (c *Command) KVGet(ctx context.Context, key string) (any, error) { + val, err := c.db.KVGet(ctx, key) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: key %q", model.ErrNotFound, key) + } + return nil, err + } + return normalizeKVValue(val), nil +} + +// KVPop removes the value stored under key and returns it. +func (c *Command) KVPop(ctx context.Context, key string) (any, error) { + val, err := c.db.KVPop(ctx, key) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: key %q", model.ErrNotFound, key) + } + return nil, err + } + return normalizeKVValue(val), nil +} + +// normalizeKVValue converts []byte values to string so they are not +// base64-encoded when serialized to JSON and print cleanly. +func normalizeKVValue(v any) any { + if b, ok := v.([]byte); ok { + return string(b) + } + return v +} + +// kvFormat renders a stored value for display, converting []byte to string. +func kvFormat(v any) string { + if b, ok := v.([]byte); ok { + return string(b) + } + return fmt.Sprint(v) +} + +func (c *Command) kvList(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + + pairs, err := c.clientKVList(ctx) + if err != nil { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Error listing keys") + return err + } + + if len(pairs) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No keys found") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 1, 1, 1, ' ', 0) + _, _ = fmt.Fprintln(w, "Key\tValue") + for key, value := range pairs { + _, _ = fmt.Fprintf(w, "%s\t%s\n", key, kvFormat(value)) + } + _ = w.Flush() + + return nil +} + +func (c *Command) kvGet(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + key := args[0] + + val, err := c.clientKVGet(ctx, key) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), kvFormat(val)) + return nil +} + +func (c *Command) kvPop(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + ctx := cmd.Context() + key := args[0] + + val, err := c.clientKVPop(ctx, key) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), kvFormat(val)) + return nil +}
A internal/cli/kv_test.go

@@ -0,0 +1,161 @@

+package cli + +import ( + "fmt" + "path" + "strings" + "testing" + "time" + + "postern/internal/db" +) + +// newKVTestCommand sets up a DB-backed Command with a running HTTP service and +// returns it. The service is stopped when the test finishes. +func newKVTestCommand(t *testing.T) *Command { + t.Helper() + f := t.TempDir() + + posternDB, err := db.OpenDB(&db.Config{ + DBPath: path.Join(f, "meta.db"), + }) + if err != nil { + t.Fatal(err) + } + + c, err := New(&Config{ + DB: posternDB, + }) + if err != nil { + t.Fatal(err) + } + + tempSocket := path.Join(f, "test.sock") + c.socketPath = tempSocket + c.httpClient = newUnixHTTPClient(tempSocket) + + service := NewHTTPService(tempSocket, c) + go func() { + _ = service.Start() + }() + + timeout := time.After(10 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for service.Status() != "running" { + select { + case <-timeout: + t.Fatal("timed out waiting for http service to start") + case <-ticker.C: + } + } + t.Cleanup(func() { _ = service.Stop() }) + + return c +} + +func TestKVListCommand(t *testing.T) { + c := newKVTestCommand(t) + ctx := t.Context() + + seed := map[string]any{ + "counter": 42, + "message": "hello world", + "a/b": "slash value", + } + for key, value := range seed { + if err := c.db.KVSet(ctx, key, value); err != nil { + t.Fatalf("seeding %q: %v", key, err) + } + } + + out, err := executeCommand(c.rootCmd(), "kv", "list") + if err != nil { + t.Fatalf("kv list: %v", err) + } + + for wantKey, wantValue := range seed { + if !strings.Contains(out, wantKey) { + t.Errorf("kv list output missing %q; got:\n%s", wantKey, out) + } + if !strings.Contains(out, fmt.Sprint(wantValue)) { + t.Errorf("kv list output missing %q; got:\n%s", wantValue, out) + } + } +} + +func TestKVGetCommand(t *testing.T) { + c := newKVTestCommand(t) + ctx := t.Context() + + if err := c.db.KVSet(ctx, "counter", 42); err != nil { + t.Fatal(err) + } + if err := c.db.KVSet(ctx, "a/b", "slash value"); err != nil { + t.Fatal(err) + } + + // Plain key. + out, err := executeCommand(c.rootCmd(), "kv", "get", "counter") + if err != nil { + t.Fatalf("kv get counter: %v", err) + } + if got := strings.TrimSpace(out); got != "42" { + t.Errorf("kv get counter = %q, want %q", got, "42") + } + + // Key containing a slash. + out, err = executeCommand(c.rootCmd(), "kv", "get", "a/b") + if err != nil { + t.Fatalf("kv get a/b: %v", err) + } + if got := strings.TrimSpace(out); got != "slash value" { + t.Errorf("kv get a/b = %q, want %q", got, "slash value") + } + + // Missing key should error. + if _, err := executeCommand(c.rootCmd(), "kv", "get", "missing"); err == nil { + t.Error("kv get missing: expected error, got nil") + } +} + +func TestKVPopCommand(t *testing.T) { + c := newKVTestCommand(t) + ctx := t.Context() + + if err := c.db.KVSet(ctx, "counter", 42); err != nil { + t.Fatal(err) + } + if err := c.db.KVSet(ctx, "a/b", "slash value"); err != nil { + t.Fatal(err) + } + + // Pop a plain key and confirm it returns the value and is deleted. + out, err := executeCommand(c.rootCmd(), "kv", "pop", "counter") + if err != nil { + t.Fatalf("kv pop counter: %v", err) + } + if got := strings.TrimSpace(out); got != "42" { + t.Errorf("kv pop counter = %q, want %q", got, "42") + } + if _, err := c.db.KVGet(ctx, "counter"); err == nil { + t.Error("counter still present after kv pop") + } + + // Pop a key containing a slash. + out, err = executeCommand(c.rootCmd(), "kv", "pop", "a/b") + if err != nil { + t.Fatalf("kv pop a/b: %v", err) + } + if got := strings.TrimSpace(out); got != "slash value" { + t.Errorf("kv pop a/b = %q, want %q", got, "slash value") + } + if _, err := c.db.KVGet(ctx, "a/b"); err == nil { + t.Error("a/b still present after kv pop") + } + + // Popping a missing key should error. + if _, err := executeCommand(c.rootCmd(), "kv", "pop", "missing"); err == nil { + t.Error("kv pop missing: expected error, got nil") + } +}