all repos — postern

@ c3d755b5d212b0d99538e0143bde5801275e4cd0

Modern mail management

clone
git clone https:///postern.git

internal/cli/admin.go (view raw)

  1package cli
  2
  3import (
  4	"bufio"
  5	"context"
  6	"crypto/rand"
  7	"errors"
  8	"fmt"
  9	"log/slog"
 10	"math/big"
 11	"net"
 12	"net/mail"
 13	"os"
 14	"strings"
 15	"text/tabwriter"
 16
 17	"postern/internal/db"
 18	"postern/internal/model"
 19
 20	"github.com/spf13/cobra"
 21	"golang.org/x/crypto/bcrypt"
 22)
 23
 24func (c *Command) addUser(cmd *cobra.Command, args []string) error {
 25	cmd.SilenceUsage = true // suppress runtime error help text
 26	ctx := cmd.Context()
 27	username := args[0]
 28
 29	user, err := c.clientCreateUser(ctx, username)
 30	if err != nil {
 31		return err
 32	}
 33
 34	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q created. Your password will only be shown once.\n", user.Name)
 35	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password)
 36	return nil
 37}
 38
 39func (c *Command) removeUser(cmd *cobra.Command, args []string) error {
 40	cmd.SilenceUsage = true // suppress runtime error help text
 41	ctx := cmd.Context()
 42	username := args[0]
 43
 44	err := c.clientRemoveUser(ctx, username)
 45	if err != nil {
 46		return err
 47	}
 48
 49	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", username)
 50	return nil
 51}
 52
 53func (c *Command) rotateUserPassword(cmd *cobra.Command, args []string) error {
 54	cmd.SilenceUsage = true // suppress runtime error help text
 55	ctx := cmd.Context()
 56	username := args[0]
 57
 58	fmt.Fprintf(cmd.OutOrStdout(), "Rotate password for %q? Type \"yes\" to confirm: ", username)
 59	reader := bufio.NewReader(cmd.InOrStdin())
 60	line, err := reader.ReadString('\n')
 61	if err != nil && line == "" {
 62		return fmt.Errorf("reading confirmation: %w", err)
 63	}
 64
 65	if !strings.EqualFold(strings.TrimSpace(line), "yes") {
 66		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
 67		return nil
 68	}
 69
 70	user, err := c.clientRotateUserPassword(ctx, username)
 71	if err != nil {
 72		return err
 73	}
 74
 75	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password rotated for %q. Your password will only be shown once.\n", username)
 76	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password)
 77	return nil
 78}
 79
 80func (c *Command) listUsers(cmd *cobra.Command, _ []string) error {
 81	cmd.SilenceUsage = true
 82
 83	users, err := c.clientListUsers(cmd.Context())
 84	if err != nil {
 85		fmt.Println("Error listing users")
 86		return err
 87	}
 88
 89	w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
 90	_, _ = fmt.Fprintln(w, "Name")
 91	for _, user := range users {
 92		_, _ = fmt.Fprintf(w, "%s\n", user.Name)
 93	}
 94	_ = w.Flush()
 95
 96	return nil
 97}
 98
 99func (c *Command) addUserAddress(cmd *cobra.Command, args []string) error {
100	cmd.SilenceUsage = true // suppress runtime error help text
101	ctx := cmd.Context()
102	username := args[0]
103	address := args[1]
104
105	err := c.clientAddUserAddress(ctx, username, address)
106	if err != nil {
107		fmt.Printf("Could not parse address: %s\n", address)
108		return err
109	}
110
111	fmt.Printf("address <%s> added for %q\n", address, username)
112	return nil
113}
114
115func (c *Command) removeUserAddress(cmd *cobra.Command, args []string) error {
116	cmd.SilenceUsage = true // suppress runtime error help text
117	ctx := cmd.Context()
118	username := args[0]
119	address := args[1]
120
121	err := c.clientRemoveUserAddress(ctx, username, address)
122	if err != nil {
123		return err
124	}
125
126	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "address <%s> removed for %q\n", address, username)
127	return nil
128}
129
130func (c *Command) listUserAddresses(cmd *cobra.Command, args []string) error {
131	cmd.SilenceUsage = true // suppress runtime error help text
132	ctx := cmd.Context()
133	username := args[0]
134
135	addresses, err := c.clientListUserAddresses(ctx, username)
136	if err != nil {
137		return err
138	}
139
140	if len(addresses) == 0 {
141		_, _ = fmt.Fprintf(cmd.OutOrStdout(), "No addresses found for %q\n", username)
142		return nil
143	}
144
145	_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Address")
146	for _, address := range addresses {
147		_, _ = fmt.Fprintln(cmd.OutOrStdout(), address)
148	}
149
150	return nil
151}
152
153func (c *Command) addDKIM(cmd *cobra.Command, args []string) error {
154	cmd.SilenceUsage = true
155	selector := args[0]
156	domain := args[1]
157
158	record, err := c.clientAddDKIM(cmd.Context(), selector, domain)
159	if err != nil {
160		return err
161	}
162
163	fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
164	fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
165	fmt.Println(record)
166	return nil
167}
168
169func (c *Command) listDKIM(cmd *cobra.Command, _ []string) error {
170	cmd.SilenceUsage = true // suppress runtime error help text
171
172	dkims, err := c.clientListDKIM(cmd.Context())
173	if err != nil {
174		fmt.Println("Error list DKIM")
175		return err
176	}
177
178	if len(dkims) == 0 {
179		fmt.Println("No DKIM records found")
180		return nil
181	}
182
183	w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
184	_, _ = fmt.Fprintln(w, "Identifier\tKey Type\tEnabled")
185	for _, dkim := range dkims {
186		_, _ = fmt.Fprintf(w, "%s\t%s\t%v\n", domainkeyName(dkim.Selector, dkim.Domain), dkim.KeyType, dkim.Enabled)
187	}
188	_ = w.Flush()
189
190	return nil
191}
192
193func (c *Command) getDKIM(cmd *cobra.Command, args []string) error {
194	cmd.SilenceUsage = true
195	selector := args[0]
196	domain := args[1]
197
198	record, err := c.clientGetDKIM(cmd.Context(), selector, domain)
199	if err != nil {
200		return err
201	}
202
203	fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
204	fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
205	fmt.Println(record)
206
207	return nil
208}
209
210func (c *Command) setDKIMEnabled(cmd *cobra.Command, args []string, enable bool) error {
211	cmd.SilenceUsage = true
212	selector := args[0]
213	domain := args[1]
214
215	// For cobra, we respect the force flag
216	force, err := cmd.Flags().GetBool("force")
217	if err != nil {
218		return err
219	}
220
221	err = c.clientSetDKIMEnabled(cmd.Context(), selector, domain, enable, force)
222	if err != nil {
223		return err
224	}
225
226	if enable {
227		fmt.Printf("Enabling DKIM selector %q for domain %q\n", selector, domain)
228	} else {
229		fmt.Printf("Disabling DKIM selector %q for domain %q\n", selector, domain)
230	}
231	return nil
232}
233
234func (c *Command) removeDKIM(cmd *cobra.Command, args []string) error {
235	cmd.SilenceUsage = true
236	selector := args[0]
237	domain := args[1]
238
239	err := c.clientRemoveDKIM(cmd.Context(), selector, domain)
240	if err != nil {
241		return err
242	}
243
244	return nil
245}
246
247func (c *Command) listSMTPAuth(cmd *cobra.Command, _ []string) error {
248	cmd.SilenceUsage = true
249
250	smtpAuths, err := c.clientListSMTPAuth(cmd.Context())
251	if err != nil {
252		fmt.Println("Error listing SMTP auth users")
253		return err
254	}
255
256	w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
257	_, _ = fmt.Fprintln(w, "Name")
258	for _, auth := range smtpAuths {
259		_, _ = fmt.Fprintf(w, "%s\n", auth.Name)
260	}
261	_ = w.Flush()
262
263	return nil
264}
265
266func (c *Command) addSMTPAuth(cmd *cobra.Command, args []string) error {
267	cmd.SilenceUsage = true // suppress runtime error help text
268	ctx := cmd.Context()
269	name := args[0]
270
271	auth, err := c.clientCreateSMTPAuth(ctx, name)
272	if err != nil {
273		return err
274	}
275
276	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "SMTP Auth %q created. The password will only be shown once.\n", name)
277	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", auth.Password)
278	return nil
279}
280
281func (c *Command) removeSMTPAuth(cmd *cobra.Command, args []string) error {
282	cmd.SilenceUsage = true // suppress runtime error help text
283	ctx := cmd.Context()
284	name := args[0]
285
286	err := c.clientRemoveSMTPAuth(ctx, name)
287	if err != nil {
288		return err
289	}
290
291	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", name)
292	return nil
293}
294
295func domainkeyName(selector, domain string) string {
296	if !strings.HasSuffix(domain, ".") {
297		domain = domain + "."
298	}
299	return fmt.Sprintf("%s._domainkey.%s", selector, domain)
300}
301
302// Define the character set:
303// - Numbers: 2-9 (No 0, 1)
304// - Uppercase: A-Z (No I, O)
305// - Lowercase: a-z (No l, o)
306const letterBytes = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
307
308func generateSafeToken(length int) (string, error) {
309	if length <= 0 {
310		return "", fmt.Errorf("length must be greater than 0")
311	}
312
313	ret := make([]byte, length)
314
315	// We calculate the max index based on the charset length
316	charsetLen := big.NewInt(int64(len(letterBytes)))
317
318	for i := range length {
319		// rand.Int returns a uniform random value in [0, max).
320		// It automatically handles modulo bias, ensuring an even distribution.
321		num, err := rand.Int(rand.Reader, charsetLen)
322		if err != nil {
323			return "", err
324		}
325
326		// Use the random number as an index to pick a character
327		ret[i] = letterBytes[num.Int64()]
328	}
329
330	return string(ret), nil
331}
332
333// CreateUser creates a new user with a generated password and returns the user with the password.
334func (c *Command) CreateUser(ctx context.Context, username string) (model.User, error) {
335	if exists, err := c.db.UserExists(ctx, username); err != nil {
336		return model.User{}, err
337	} else if exists {
338		return model.User{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, username)
339	}
340
341	password, err := generateSafeToken(16)
342	if err != nil {
343		return model.User{}, err
344	}
345
346	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
347	if err != nil {
348		return model.User{}, err
349	}
350
351	tx, err := c.db.GetWriteTx(ctx)
352	if err != nil {
353		return model.User{}, fmt.Errorf("beginning transaction: %w", err)
354	}
355
356	userID, err := db.InsertUser(ctx, tx, username, hash[:])
357	if err != nil {
358		txErr := tx.Rollback()
359		if txErr != nil {
360			slog.Error("rolling back transaction", "error", err)
361		}
362		return model.User{}, fmt.Errorf("inserting user %q: %w", username, err)
363	}
364
365	if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil {
366		txErr := tx.Rollback()
367		if txErr != nil {
368			slog.Error("rolling back transaction", "error", err)
369		}
370		return model.User{}, fmt.Errorf("inserting default mailboxes: %w", err)
371	}
372
373	if err := tx.Commit(); err != nil {
374		return model.User{}, fmt.Errorf("committing transaction: %w", err)
375	}
376
377	// subscribe user to all mailboxes
378	allMailboxes, err := c.db.GetUserMailboxes(ctx, userID)
379	if err != nil {
380		return model.User{}, err
381	}
382
383	for _, mb := range allMailboxes {
384		err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name)
385		if err != nil {
386			return model.User{}, fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err)
387		}
388		slog.Debug("subscribed mailbox", "mailbox", mb.Name)
389	}
390
391	// Return user with password
392	user := model.User{
393		ID:        userID,
394		Name:      username,
395		Addresses: []string{},
396		Password:  password,
397	}
398	user.SetPassword(hash[:])
399
400	return user, nil
401}
402
403// RotateUserPassword generates a new password for an existing user and
404// returns the user with the new one-time plaintext password.
405func (c *Command) RotateUserPassword(ctx context.Context, username string) (model.User, error) {
406	if exists, err := c.db.UserExists(ctx, username); err != nil {
407		return model.User{}, err
408	} else if !exists {
409		return model.User{}, fmt.Errorf("%w: user %q", model.ErrNotFound, username)
410	}
411
412	password, err := generateSafeToken(16)
413	if err != nil {
414		return model.User{}, err
415	}
416
417	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
418	if err != nil {
419		return model.User{}, err
420	}
421
422	if err := c.db.UpdateUserPassword(ctx, username, hash[:]); err != nil {
423		return model.User{}, err
424	}
425
426	return model.User{
427		Name:     username,
428		Password: password,
429	}, nil
430}
431
432// RemoveUser removes a user by name.
433func (c *Command) RemoveUser(ctx context.Context, username string) error {
434	if exists, err := c.db.UserExists(ctx, username); err != nil {
435		return err
436	} else if !exists {
437		return fmt.Errorf("%w: user %q", model.ErrNotFound, username)
438	}
439
440	return c.db.RemoveUser(ctx, username)
441}
442
443// ListUsers returns all users.
444func (c *Command) ListUsers(ctx context.Context) ([]model.User, error) {
445	return c.db.ListUsers(ctx)
446}
447
448// AddUserAddress adds an email address to a user.
449func (c *Command) AddUserAddress(ctx context.Context, username, address string) error {
450	parsedAddress, err := mail.ParseAddress(address)
451	if err != nil {
452		return fmt.Errorf("%w: could not parse address %q", model.ErrInvalidInput, address)
453	}
454
455	i := strings.LastIndex(parsedAddress.Address, "@")
456	if i == -1 {
457		return fmt.Errorf("%w: invalid address does not have a domain part %q", model.ErrInvalidInput, parsedAddress.Address)
458	}
459
460	return c.db.InsertUserAddress(ctx, username, parsedAddress.Address)
461}
462
463// RemoveUserAddress removes an email address from a user. It validates that the
464// user exists and delegates to the DB, which reports a not-found error if the
465// address is not present.
466func (c *Command) RemoveUserAddress(ctx context.Context, username, address string) error {
467	exists, err := c.db.UserExists(ctx, username)
468	if err != nil {
469		return err
470	} else if !exists {
471		return fmt.Errorf("%w: user %q", model.ErrNotFound, username)
472	}
473
474	return c.db.RemoveUserAddress(ctx, username, address)
475}
476
477// ListUserAddresses returns all email addresses for a user. It validates that
478// the user exists before delegating to the DB.
479func (c *Command) ListUserAddresses(ctx context.Context, username string) ([]string, error) {
480	exists, err := c.db.UserExists(ctx, username)
481	if err != nil {
482		return nil, err
483	} else if !exists {
484		return nil, fmt.Errorf("%w: user %q", model.ErrNotFound, username)
485	}
486
487	return c.db.ListUserAddresses(ctx, username)
488}
489
490// CreateSMTPAuth creates a new SMTP authentication user with a generated password and returns the user with the password.
491func (c *Command) CreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) {
492	if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
493		return model.SMTPAuth{}, err
494	} else if exists {
495		return model.SMTPAuth{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, name)
496	}
497
498	password, err := generateSafeToken(16)
499	if err != nil {
500		return model.SMTPAuth{}, err
501	}
502
503	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
504	if err != nil {
505		return model.SMTPAuth{}, err
506	}
507
508	err = c.db.InsertSMTPAuthUser(ctx, name, hash[:])
509	if err != nil {
510		return model.SMTPAuth{}, fmt.Errorf("inserting smtp auth %q: %w", name, err)
511	}
512
513	// Return smtp auth with password
514	auth := model.SMTPAuth{
515		ID:       0, // Will be set by DB
516		Name:     name,
517		Password: password,
518	}
519	auth.SetPassword(hash[:])
520
521	return auth, nil
522}
523
524// RemoveSMTPAuth removes an SMTP authentication user by name.
525func (c *Command) RemoveSMTPAuth(ctx context.Context, name string) error {
526	if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
527		return err
528	} else if !exists {
529		return fmt.Errorf("%w: user %q", model.ErrNotFound, name)
530	}
531
532	return c.db.RemoveSMTPAuthUser(ctx, name)
533}
534
535// ListSMTPAuth returns all SMTP authentication users.
536func (c *Command) ListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
537	return c.db.ListSMTPAuthUsers(ctx)
538}
539
540// AddDKIM adds a new DKIM selector and returns the DNS record.
541func (c *Command) AddDKIM(ctx context.Context, selector, domain string) (string, error) {
542	return c.dkim.Add(ctx, selector, domain)
543}
544
545// ListDKIM returns all DKIM selectors.
546func (c *Command) ListDKIM(ctx context.Context) ([]model.DKIM, error) {
547	return c.dkim.List(ctx)
548}
549
550// GetDKIM returns the public DNS record for a DKIM selector.
551func (c *Command) GetDKIM(ctx context.Context, selector, domain string) (string, error) {
552	return c.dkim.PublicDNSRecord(ctx, selector, domain)
553}
554
555// SetDKIMEnabled enables or disables a DKIM selector.
556func (c *Command) SetDKIMEnabled(ctx context.Context, selector, domain string, enable, force bool) error {
557	if enable {
558		if !force {
559			// Perform DNS validation when force is false
560			s := domainkeyName(selector, domain)
561			records, err := net.LookupTXT(s)
562			if err != nil {
563				return fmt.Errorf("resolving TXT records: %w", err)
564			}
565
566			expected, err := c.dkim.PublicDNSRecord(ctx, selector, domain)
567			if err != nil {
568				return err
569			}
570
571			if expected != strings.Join(records, "") {
572				return errors.New("DKIM record does not match the expected record")
573			}
574		}
575	}
576
577	return c.dkim.SetEnabled(ctx, selector, domain, enable)
578}
579
580// RemoveDKIM removes a DKIM selector.
581func (c *Command) RemoveDKIM(ctx context.Context, selector, domain string) error {
582	return c.dkim.Remove(ctx, selector, domain)
583}