all repos — postern

@ c3d755b5d212b0d99538e0143bde5801275e4cd0

Modern mail management

clone
git clone https:///postern.git
add list and remove option for user addresses
Raphael Sprenger
Tue, 06 Oct 2026 15:05:07 +0200
commit

c3d755b5d212b0d99538e0143bde5801275e4cd0

parent

087f9dd30bc5a591621f1184af323569d6a0d240

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

@@ -112,6 +112,44 @@ fmt.Printf("address <%s> added for %q\n", address, username)

return nil } +func (c *Command) removeUserAddress(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // suppress runtime error help text + ctx := cmd.Context() + username := args[0] + address := args[1] + + err := c.clientRemoveUserAddress(ctx, username, address) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "address <%s> removed for %q\n", address, username) + return nil +} + +func (c *Command) listUserAddresses(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // suppress runtime error help text + ctx := cmd.Context() + username := args[0] + + addresses, err := c.clientListUserAddresses(ctx, username) + if err != nil { + return err + } + + if len(addresses) == 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "No addresses found for %q\n", username) + return nil + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Address") + for _, address := range addresses { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), address) + } + + return nil +} + func (c *Command) addDKIM(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true selector := args[0]

@@ -420,6 +458,33 @@ return fmt.Errorf("%w: invalid address does not have a domain part %q", model.ErrInvalidInput, parsedAddress.Address)

} return c.db.InsertUserAddress(ctx, username, parsedAddress.Address) +} + +// RemoveUserAddress removes an email address from a user. It validates that the +// user exists and delegates to the DB, which reports a not-found error if the +// address is not present. +func (c *Command) RemoveUserAddress(ctx context.Context, username, address string) error { + exists, err := c.db.UserExists(ctx, username) + if err != nil { + return err + } else if !exists { + return fmt.Errorf("%w: user %q", model.ErrNotFound, username) + } + + return c.db.RemoveUserAddress(ctx, username, address) +} + +// ListUserAddresses returns all email addresses for a user. It validates that +// the user exists before delegating to the DB. +func (c *Command) ListUserAddresses(ctx context.Context, username string) ([]string, error) { + exists, err := c.db.UserExists(ctx, username) + if err != nil { + return nil, err + } else if !exists { + return nil, fmt.Errorf("%w: user %q", model.ErrNotFound, username) + } + + return c.db.ListUserAddresses(ctx, username) } // CreateSMTPAuth creates a new SMTP authentication user with a generated password and returns the user with the password.
M internal/cli/cli.go → internal/cli/cli.go

@@ -113,6 +113,20 @@ Args: cobra.ExactArgs(2),

RunE: c.addUserAddress, } + removeAddressCmd := &cobra.Command{ + Use: "remove-address <username> <address>", + Short: "Remove an address for a user", + Args: cobra.ExactArgs(2), + RunE: c.removeUserAddress, + } + + listAddressesCmd := &cobra.Command{ + Use: "list-addresses <username>", + Short: "List all addresses for a user", + Args: cobra.ExactArgs(1), + RunE: c.listUserAddresses, + } + userRemoveCmd := &cobra.Command{ Use: "remove <username>", Short: "Remove a user",

@@ -130,6 +144,8 @@

userCmd.AddCommand(userListCmd) userCmd.AddCommand(addUserCmd) userCmd.AddCommand(addUserAddressCmd) + userCmd.AddCommand(removeAddressCmd) + userCmd.AddCommand(listAddressesCmd) userCmd.AddCommand(userRemoveCmd) userCmd.AddCommand(rotatePasswordCmd)
M internal/cli/client.go → internal/cli/client.go

@@ -140,6 +140,56 @@

return nil } +// clientRemoveUserAddress removes an address for a user via the daemon. +func (c *Command) clientRemoveUserAddress(ctx context.Context, username, address string) (err error) { + path, err := url.JoinPath("http://unix/users/", username, "addresses", address) + if err != nil { + return fmt.Errorf("failed to create address request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create delete address request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to remove user address: %w", err) + } + defer model.Wrap(resp.Body.Close, &err, "clientRemoveUserAddress closing response body") + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to remove user address: %d", resp.StatusCode) + } + + return nil +} + +// clientListUserAddresses fetches the list of addresses for a user from the daemon. +func (c *Command) clientListUserAddresses(ctx context.Context, username string) (_ []string, err error) { + path, err := url.JoinPath("http://unix/users/", username, "addresses") + if err != nil { + return nil, fmt.Errorf("failed to create address request: %w", err) + } + + resp, err := c.httpClient.Get(path) + if err != nil { + return nil, fmt.Errorf("failed to list user addresses: %w", err) + } + defer model.Wrap(resp.Body.Close, &err, "clientListUserAddresses closing response body") + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list user addresses: %d", resp.StatusCode) + } + + var addresses []string + if err := json.NewDecoder(resp.Body).Decode(&addresses); err != nil { + return nil, fmt.Errorf("failed to decode user addresses response: %w", err) + } + + return addresses, nil +} + // clientListSMTPAuth fetches the list of SMTP auth users from the daemon. func (c *Command) clientListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) { resp, err := c.httpClient.Get("http://unix/smtp-auth")
M internal/cli/http.go → internal/cli/http.go

@@ -91,6 +91,8 @@ mux.HandleFunc("GET /users", h.listUsers)

mux.HandleFunc("POST /users", h.createUser) mux.HandleFunc("DELETE /users/{username}", h.deleteUser) mux.HandleFunc("POST /users/{username}/addresses", h.addUserAddress) + mux.HandleFunc("GET /users/{username}/addresses", h.listUserAddresses) + mux.HandleFunc("DELETE /users/{username}/addresses/{address}", h.deleteUserAddress) mux.HandleFunc("POST /users/{username}/rotate-password", h.rotateUserPassword) // SMTP auth endpoints

@@ -213,6 +215,42 @@ return

} w.WriteHeader(http.StatusCreated) +} + +func (h *HTTPService) listUserAddresses(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + + addresses, err := h.cmd.ListUserAddresses(r.Context(), username) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, addresses) +} + +func (h *HTTPService) deleteUserAddress(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + address := r.PathValue("address") + if address == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "address is required"}) + return + } + + err := h.cmd.RemoveUserAddress(r.Context(), username, address) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) } func (h *HTTPService) rotateUserPassword(w http.ResponseWriter, r *http.Request) {
M internal/db/address.go → internal/db/address.go

@@ -4,6 +4,7 @@ import (

"context" "database/sql" "errors" + "fmt" "time" "postern/internal/model"

@@ -32,6 +33,65 @@ if err != nil {

return err } return tx.Commit() +} + +// RemoveUserAddress removes a specific address from a user. It returns +// model.ErrNotFound wrapped error if the user or address does not exist. +func (db *DB) RemoveUserAddress(ctx context.Context, username, address string) error { + tx, err := db.write.BeginTx(ctx, nil) + if err != nil { + return err + } + defer txRollback(tx) + + // Get userID + var userID int + row := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE name = ?`, username) + if err = row.Scan(&userID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: user %q", model.ErrNotFound, username) + } + return err + } + + res, err := tx.ExecContext(ctx, `DELETE FROM addresses WHERE user_id = ? AND name = ?`, userID, address) + if err != nil { + return err + } + + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("%w: address %q", model.ErrNotFound, address) + } + return tx.Commit() +} + +// ListUserAddresses returns all addresses for a user, ordered by name. +func (db *DB) ListUserAddresses(ctx context.Context, username string) ([]string, error) { + rows, err := db.read.QueryContext( + ctx, `SELECT addresses.name FROM addresses INNER JOIN users ON users.id = addresses.user_id WHERE users.name = ? ORDER BY addresses.name`, username) + if err != nil { + return nil, err + } + defer func(rows *sql.Rows) { + _ = rows.Close() + }(rows) + + var addresses []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + addresses = append(addresses, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + return addresses, nil } func (db *DB) GetUserForAddress(ctx context.Context, address string) (model.User, error) {
M internal/db/user_test.go → internal/db/user_test.go

@@ -1,10 +1,13 @@

package db import ( + "errors" "log" "path" "slices" "testing" + + "postern/internal/model" ) // TestUsers tests db operations around user management

@@ -103,6 +106,41 @@ if len(u.Addresses) != 0 {

t.Errorf("got %d addresses, want 0", len(u.Addresses)) } } + } + + // Test ListUserAddresses + addrs, err := posternDB.ListUserAddresses(t.Context(), "testuser1") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 2 { + t.Errorf("got %d addresses, want 2", len(addrs)) + } + if !slices.Contains(addrs, "test1@example.com") || !slices.Contains(addrs, "test2@example.com") { + t.Errorf("expected both addresses, got %v", addrs) + } + + // Test RemoveUserAddress + err = posternDB.RemoveUserAddress(t.Context(), "testuser1", "test1@example.com") + if err != nil { + t.Fatal(err) + } + addrs, err = posternDB.ListUserAddresses(t.Context(), "testuser1") + if err != nil { + t.Fatal(err) + } + if len(addrs) != 1 || addrs[0] != "test2@example.com" { + t.Errorf("got %v, want [test2@example.com]", addrs) + } + + // Test removing a non-existent address + if err = posternDB.RemoveUserAddress(t.Context(), "testuser1", "nope@example.com"); !errors.Is(err, model.ErrNotFound) { + t.Errorf("expected ErrNotFound for missing address, got %v", err) + } + + // Test removing an address of a non-existent user + if err = posternDB.RemoveUserAddress(t.Context(), "non-existent", "nope@example.com"); !errors.Is(err, model.ErrNotFound) { + t.Errorf("expected ErrNotFound for missing user, got %v", err) } err = posternDB.RemoveUser(t.Context(), "testuser1")