package cli import ( "bufio" "context" "crypto/rand" "errors" "fmt" "log/slog" "math/big" "net" "net/mail" "os" "strings" "text/tabwriter" "postern/internal/db" "postern/internal/model" "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt" ) func (c *Command) addUser(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // suppress runtime error help text ctx := cmd.Context() username := args[0] user, err := c.clientCreateUser(ctx, username) if err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q created. Your password will only be shown once.\n", user.Name) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password) return nil } func (c *Command) removeUser(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // suppress runtime error help text ctx := cmd.Context() username := args[0] err := c.clientRemoveUser(ctx, username) if err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", username) return nil } func (c *Command) rotateUserPassword(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // suppress runtime error help text ctx := cmd.Context() username := args[0] fmt.Fprintf(cmd.OutOrStdout(), "Rotate password for %q? Type \"yes\" to confirm: ", username) reader := bufio.NewReader(cmd.InOrStdin()) line, err := reader.ReadString('\n') if err != nil && line == "" { return fmt.Errorf("reading confirmation: %w", err) } if !strings.EqualFold(strings.TrimSpace(line), "yes") { _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") return nil } user, err := c.clientRotateUserPassword(ctx, username) if err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password rotated for %q. Your password will only be shown once.\n", username) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password) return nil } func (c *Command) listUsers(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true users, err := c.clientListUsers(cmd.Context()) if err != nil { fmt.Println("Error listing users") return err } w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0) _, _ = fmt.Fprintln(w, "Name") for _, user := range users { _, _ = fmt.Fprintf(w, "%s\n", user.Name) } _ = w.Flush() return nil } func (c *Command) addUserAddress(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.clientAddUserAddress(ctx, username, address) if err != nil { fmt.Printf("Could not parse address: %s\n", address) return err } 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] domain := args[1] record, err := c.clientAddDKIM(cmd.Context(), selector, domain) if err != nil { return err } fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain)) fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)") fmt.Println(record) return nil } func (c *Command) listDKIM(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true // suppress runtime error help text dkims, err := c.clientListDKIM(cmd.Context()) if err != nil { fmt.Println("Error list DKIM") return err } if len(dkims) == 0 { fmt.Println("No DKIM records found") return nil } w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0) _, _ = fmt.Fprintln(w, "Identifier\tKey Type\tEnabled") for _, dkim := range dkims { _, _ = fmt.Fprintf(w, "%s\t%s\t%v\n", domainkeyName(dkim.Selector, dkim.Domain), dkim.KeyType, dkim.Enabled) } _ = w.Flush() return nil } func (c *Command) getDKIM(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true selector := args[0] domain := args[1] record, err := c.clientGetDKIM(cmd.Context(), selector, domain) if err != nil { return err } fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain)) fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)") fmt.Println(record) return nil } func (c *Command) setDKIMEnabled(cmd *cobra.Command, args []string, enable bool) error { cmd.SilenceUsage = true selector := args[0] domain := args[1] // For cobra, we respect the force flag force, err := cmd.Flags().GetBool("force") if err != nil { return err } err = c.clientSetDKIMEnabled(cmd.Context(), selector, domain, enable, force) if err != nil { return err } if enable { fmt.Printf("Enabling DKIM selector %q for domain %q\n", selector, domain) } else { fmt.Printf("Disabling DKIM selector %q for domain %q\n", selector, domain) } return nil } func (c *Command) removeDKIM(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true selector := args[0] domain := args[1] err := c.clientRemoveDKIM(cmd.Context(), selector, domain) if err != nil { return err } return nil } func (c *Command) listSMTPAuth(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true smtpAuths, err := c.clientListSMTPAuth(cmd.Context()) if err != nil { fmt.Println("Error listing SMTP auth users") return err } w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0) _, _ = fmt.Fprintln(w, "Name") for _, auth := range smtpAuths { _, _ = fmt.Fprintf(w, "%s\n", auth.Name) } _ = w.Flush() return nil } func (c *Command) addSMTPAuth(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // suppress runtime error help text ctx := cmd.Context() name := args[0] auth, err := c.clientCreateSMTPAuth(ctx, name) if err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "SMTP Auth %q created. The password will only be shown once.\n", name) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", auth.Password) return nil } func (c *Command) removeSMTPAuth(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // suppress runtime error help text ctx := cmd.Context() name := args[0] err := c.clientRemoveSMTPAuth(ctx, name) if err != nil { return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", name) return nil } func domainkeyName(selector, domain string) string { if !strings.HasSuffix(domain, ".") { domain = domain + "." } return fmt.Sprintf("%s._domainkey.%s", selector, domain) } // Define the character set: // - Numbers: 2-9 (No 0, 1) // - Uppercase: A-Z (No I, O) // - Lowercase: a-z (No l, o) const letterBytes = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" func generateSafeToken(length int) (string, error) { if length <= 0 { return "", fmt.Errorf("length must be greater than 0") } ret := make([]byte, length) // We calculate the max index based on the charset length charsetLen := big.NewInt(int64(len(letterBytes))) for i := range length { // rand.Int returns a uniform random value in [0, max). // It automatically handles modulo bias, ensuring an even distribution. num, err := rand.Int(rand.Reader, charsetLen) if err != nil { return "", err } // Use the random number as an index to pick a character ret[i] = letterBytes[num.Int64()] } return string(ret), nil } // CreateUser creates a new user with a generated password and returns the user with the password. func (c *Command) CreateUser(ctx context.Context, username string) (model.User, error) { if exists, err := c.db.UserExists(ctx, username); err != nil { return model.User{}, err } else if exists { return model.User{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, username) } password, err := generateSafeToken(16) if err != nil { return model.User{}, err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return model.User{}, err } tx, err := c.db.GetWriteTx(ctx) if err != nil { return model.User{}, fmt.Errorf("beginning transaction: %w", err) } userID, err := db.InsertUser(ctx, tx, username, hash[:]) if err != nil { txErr := tx.Rollback() if txErr != nil { slog.Error("rolling back transaction", "error", err) } return model.User{}, fmt.Errorf("inserting user %q: %w", username, err) } if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil { txErr := tx.Rollback() if txErr != nil { slog.Error("rolling back transaction", "error", err) } return model.User{}, fmt.Errorf("inserting default mailboxes: %w", err) } if err := tx.Commit(); err != nil { return model.User{}, fmt.Errorf("committing transaction: %w", err) } // subscribe user to all mailboxes allMailboxes, err := c.db.GetUserMailboxes(ctx, userID) if err != nil { return model.User{}, err } for _, mb := range allMailboxes { err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name) if err != nil { return model.User{}, fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err) } slog.Debug("subscribed mailbox", "mailbox", mb.Name) } // Return user with password user := model.User{ ID: userID, Name: username, Addresses: []string{}, Password: password, } user.SetPassword(hash[:]) return user, nil } // RotateUserPassword generates a new password for an existing user and // returns the user with the new one-time plaintext password. func (c *Command) RotateUserPassword(ctx context.Context, username string) (model.User, error) { if exists, err := c.db.UserExists(ctx, username); err != nil { return model.User{}, err } else if !exists { return model.User{}, fmt.Errorf("%w: user %q", model.ErrNotFound, username) } password, err := generateSafeToken(16) if err != nil { return model.User{}, err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return model.User{}, err } if err := c.db.UpdateUserPassword(ctx, username, hash[:]); err != nil { return model.User{}, err } return model.User{ Name: username, Password: password, }, nil } // RemoveUser removes a user by name. func (c *Command) RemoveUser(ctx context.Context, username string) error { if exists, err := c.db.UserExists(ctx, username); err != nil { return err } else if !exists { return fmt.Errorf("%w: user %q", model.ErrNotFound, username) } return c.db.RemoveUser(ctx, username) } // ListUsers returns all users. func (c *Command) ListUsers(ctx context.Context) ([]model.User, error) { return c.db.ListUsers(ctx) } // AddUserAddress adds an email address to a user. func (c *Command) AddUserAddress(ctx context.Context, username, address string) error { parsedAddress, err := mail.ParseAddress(address) if err != nil { return fmt.Errorf("%w: could not parse address %q", model.ErrInvalidInput, address) } i := strings.LastIndex(parsedAddress.Address, "@") if i == -1 { 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. func (c *Command) CreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) { if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil { return model.SMTPAuth{}, err } else if exists { return model.SMTPAuth{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, name) } password, err := generateSafeToken(16) if err != nil { return model.SMTPAuth{}, err } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return model.SMTPAuth{}, err } err = c.db.InsertSMTPAuthUser(ctx, name, hash[:]) if err != nil { return model.SMTPAuth{}, fmt.Errorf("inserting smtp auth %q: %w", name, err) } // Return smtp auth with password auth := model.SMTPAuth{ ID: 0, // Will be set by DB Name: name, Password: password, } auth.SetPassword(hash[:]) return auth, nil } // RemoveSMTPAuth removes an SMTP authentication user by name. func (c *Command) RemoveSMTPAuth(ctx context.Context, name string) error { if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil { return err } else if !exists { return fmt.Errorf("%w: user %q", model.ErrNotFound, name) } return c.db.RemoveSMTPAuthUser(ctx, name) } // ListSMTPAuth returns all SMTP authentication users. func (c *Command) ListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) { return c.db.ListSMTPAuthUsers(ctx) } // AddDKIM adds a new DKIM selector and returns the DNS record. func (c *Command) AddDKIM(ctx context.Context, selector, domain string) (string, error) { return c.dkim.Add(ctx, selector, domain) } // ListDKIM returns all DKIM selectors. func (c *Command) ListDKIM(ctx context.Context) ([]model.DKIM, error) { return c.dkim.List(ctx) } // GetDKIM returns the public DNS record for a DKIM selector. func (c *Command) GetDKIM(ctx context.Context, selector, domain string) (string, error) { return c.dkim.PublicDNSRecord(ctx, selector, domain) } // SetDKIMEnabled enables or disables a DKIM selector. func (c *Command) SetDKIMEnabled(ctx context.Context, selector, domain string, enable, force bool) error { if enable { if !force { // Perform DNS validation when force is false s := domainkeyName(selector, domain) records, err := net.LookupTXT(s) if err != nil { return fmt.Errorf("resolving TXT records: %w", err) } expected, err := c.dkim.PublicDNSRecord(ctx, selector, domain) if err != nil { return err } if expected != strings.Join(records, "") { return errors.New("DKIM record does not match the expected record") } } } return c.dkim.SetEnabled(ctx, selector, domain, enable) } // RemoveDKIM removes a DKIM selector. func (c *Command) RemoveDKIM(ctx context.Context, selector, domain string) error { return c.dkim.Remove(ctx, selector, domain) }