all repos — postern

@ 087f9dd30bc5a591621f1184af323569d6a0d240

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) addDKIM(cmd *cobra.Command, args []string) error {
116	cmd.SilenceUsage = true
117	selector := args[0]
118	domain := args[1]
119
120	record, err := c.clientAddDKIM(cmd.Context(), selector, domain)
121	if err != nil {
122		return err
123	}
124
125	fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
126	fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
127	fmt.Println(record)
128	return nil
129}
130
131func (c *Command) listDKIM(cmd *cobra.Command, _ []string) error {
132	cmd.SilenceUsage = true // suppress runtime error help text
133
134	dkims, err := c.clientListDKIM(cmd.Context())
135	if err != nil {
136		fmt.Println("Error list DKIM")
137		return err
138	}
139
140	if len(dkims) == 0 {
141		fmt.Println("No DKIM records found")
142		return nil
143	}
144
145	w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
146	_, _ = fmt.Fprintln(w, "Identifier\tKey Type\tEnabled")
147	for _, dkim := range dkims {
148		_, _ = fmt.Fprintf(w, "%s\t%s\t%v\n", domainkeyName(dkim.Selector, dkim.Domain), dkim.KeyType, dkim.Enabled)
149	}
150	_ = w.Flush()
151
152	return nil
153}
154
155func (c *Command) getDKIM(cmd *cobra.Command, args []string) error {
156	cmd.SilenceUsage = true
157	selector := args[0]
158	domain := args[1]
159
160	record, err := c.clientGetDKIM(cmd.Context(), selector, domain)
161	if err != nil {
162		return err
163	}
164
165	fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
166	fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
167	fmt.Println(record)
168
169	return nil
170}
171
172func (c *Command) setDKIMEnabled(cmd *cobra.Command, args []string, enable bool) error {
173	cmd.SilenceUsage = true
174	selector := args[0]
175	domain := args[1]
176
177	// For cobra, we respect the force flag
178	force, err := cmd.Flags().GetBool("force")
179	if err != nil {
180		return err
181	}
182
183	err = c.clientSetDKIMEnabled(cmd.Context(), selector, domain, enable, force)
184	if err != nil {
185		return err
186	}
187
188	if enable {
189		fmt.Printf("Enabling DKIM selector %q for domain %q\n", selector, domain)
190	} else {
191		fmt.Printf("Disabling DKIM selector %q for domain %q\n", selector, domain)
192	}
193	return nil
194}
195
196func (c *Command) removeDKIM(cmd *cobra.Command, args []string) error {
197	cmd.SilenceUsage = true
198	selector := args[0]
199	domain := args[1]
200
201	err := c.clientRemoveDKIM(cmd.Context(), selector, domain)
202	if err != nil {
203		return err
204	}
205
206	return nil
207}
208
209func (c *Command) listSMTPAuth(cmd *cobra.Command, _ []string) error {
210	cmd.SilenceUsage = true
211
212	smtpAuths, err := c.clientListSMTPAuth(cmd.Context())
213	if err != nil {
214		fmt.Println("Error listing SMTP auth users")
215		return err
216	}
217
218	w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
219	_, _ = fmt.Fprintln(w, "Name")
220	for _, auth := range smtpAuths {
221		_, _ = fmt.Fprintf(w, "%s\n", auth.Name)
222	}
223	_ = w.Flush()
224
225	return nil
226}
227
228func (c *Command) addSMTPAuth(cmd *cobra.Command, args []string) error {
229	cmd.SilenceUsage = true // suppress runtime error help text
230	ctx := cmd.Context()
231	name := args[0]
232
233	auth, err := c.clientCreateSMTPAuth(ctx, name)
234	if err != nil {
235		return err
236	}
237
238	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "SMTP Auth %q created. The password will only be shown once.\n", name)
239	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", auth.Password)
240	return nil
241}
242
243func (c *Command) removeSMTPAuth(cmd *cobra.Command, args []string) error {
244	cmd.SilenceUsage = true // suppress runtime error help text
245	ctx := cmd.Context()
246	name := args[0]
247
248	err := c.clientRemoveSMTPAuth(ctx, name)
249	if err != nil {
250		return err
251	}
252
253	_, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", name)
254	return nil
255}
256
257func domainkeyName(selector, domain string) string {
258	if !strings.HasSuffix(domain, ".") {
259		domain = domain + "."
260	}
261	return fmt.Sprintf("%s._domainkey.%s", selector, domain)
262}
263
264// Define the character set:
265// - Numbers: 2-9 (No 0, 1)
266// - Uppercase: A-Z (No I, O)
267// - Lowercase: a-z (No l, o)
268const letterBytes = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
269
270func generateSafeToken(length int) (string, error) {
271	if length <= 0 {
272		return "", fmt.Errorf("length must be greater than 0")
273	}
274
275	ret := make([]byte, length)
276
277	// We calculate the max index based on the charset length
278	charsetLen := big.NewInt(int64(len(letterBytes)))
279
280	for i := range length {
281		// rand.Int returns a uniform random value in [0, max).
282		// It automatically handles modulo bias, ensuring an even distribution.
283		num, err := rand.Int(rand.Reader, charsetLen)
284		if err != nil {
285			return "", err
286		}
287
288		// Use the random number as an index to pick a character
289		ret[i] = letterBytes[num.Int64()]
290	}
291
292	return string(ret), nil
293}
294
295// CreateUser creates a new user with a generated password and returns the user with the password.
296func (c *Command) CreateUser(ctx context.Context, username string) (model.User, error) {
297	if exists, err := c.db.UserExists(ctx, username); err != nil {
298		return model.User{}, err
299	} else if exists {
300		return model.User{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, username)
301	}
302
303	password, err := generateSafeToken(16)
304	if err != nil {
305		return model.User{}, err
306	}
307
308	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
309	if err != nil {
310		return model.User{}, err
311	}
312
313	tx, err := c.db.GetWriteTx(ctx)
314	if err != nil {
315		return model.User{}, fmt.Errorf("beginning transaction: %w", err)
316	}
317
318	userID, err := db.InsertUser(ctx, tx, username, hash[:])
319	if err != nil {
320		txErr := tx.Rollback()
321		if txErr != nil {
322			slog.Error("rolling back transaction", "error", err)
323		}
324		return model.User{}, fmt.Errorf("inserting user %q: %w", username, err)
325	}
326
327	if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil {
328		txErr := tx.Rollback()
329		if txErr != nil {
330			slog.Error("rolling back transaction", "error", err)
331		}
332		return model.User{}, fmt.Errorf("inserting default mailboxes: %w", err)
333	}
334
335	if err := tx.Commit(); err != nil {
336		return model.User{}, fmt.Errorf("committing transaction: %w", err)
337	}
338
339	// subscribe user to all mailboxes
340	allMailboxes, err := c.db.GetUserMailboxes(ctx, userID)
341	if err != nil {
342		return model.User{}, err
343	}
344
345	for _, mb := range allMailboxes {
346		err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name)
347		if err != nil {
348			return model.User{}, fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err)
349		}
350		slog.Debug("subscribed mailbox", "mailbox", mb.Name)
351	}
352
353	// Return user with password
354	user := model.User{
355		ID:        userID,
356		Name:      username,
357		Addresses: []string{},
358		Password:  password,
359	}
360	user.SetPassword(hash[:])
361
362	return user, nil
363}
364
365// RotateUserPassword generates a new password for an existing user and
366// returns the user with the new one-time plaintext password.
367func (c *Command) RotateUserPassword(ctx context.Context, username string) (model.User, error) {
368	if exists, err := c.db.UserExists(ctx, username); err != nil {
369		return model.User{}, err
370	} else if !exists {
371		return model.User{}, fmt.Errorf("%w: user %q", model.ErrNotFound, username)
372	}
373
374	password, err := generateSafeToken(16)
375	if err != nil {
376		return model.User{}, err
377	}
378
379	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
380	if err != nil {
381		return model.User{}, err
382	}
383
384	if err := c.db.UpdateUserPassword(ctx, username, hash[:]); err != nil {
385		return model.User{}, err
386	}
387
388	return model.User{
389		Name:     username,
390		Password: password,
391	}, nil
392}
393
394// RemoveUser removes a user by name.
395func (c *Command) RemoveUser(ctx context.Context, username string) error {
396	if exists, err := c.db.UserExists(ctx, username); err != nil {
397		return err
398	} else if !exists {
399		return fmt.Errorf("%w: user %q", model.ErrNotFound, username)
400	}
401
402	return c.db.RemoveUser(ctx, username)
403}
404
405// ListUsers returns all users.
406func (c *Command) ListUsers(ctx context.Context) ([]model.User, error) {
407	return c.db.ListUsers(ctx)
408}
409
410// AddUserAddress adds an email address to a user.
411func (c *Command) AddUserAddress(ctx context.Context, username, address string) error {
412	parsedAddress, err := mail.ParseAddress(address)
413	if err != nil {
414		return fmt.Errorf("%w: could not parse address %q", model.ErrInvalidInput, address)
415	}
416
417	i := strings.LastIndex(parsedAddress.Address, "@")
418	if i == -1 {
419		return fmt.Errorf("%w: invalid address does not have a domain part %q", model.ErrInvalidInput, parsedAddress.Address)
420	}
421
422	return c.db.InsertUserAddress(ctx, username, parsedAddress.Address)
423}
424
425// CreateSMTPAuth creates a new SMTP authentication user with a generated password and returns the user with the password.
426func (c *Command) CreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) {
427	if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
428		return model.SMTPAuth{}, err
429	} else if exists {
430		return model.SMTPAuth{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, name)
431	}
432
433	password, err := generateSafeToken(16)
434	if err != nil {
435		return model.SMTPAuth{}, err
436	}
437
438	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
439	if err != nil {
440		return model.SMTPAuth{}, err
441	}
442
443	err = c.db.InsertSMTPAuthUser(ctx, name, hash[:])
444	if err != nil {
445		return model.SMTPAuth{}, fmt.Errorf("inserting smtp auth %q: %w", name, err)
446	}
447
448	// Return smtp auth with password
449	auth := model.SMTPAuth{
450		ID:       0, // Will be set by DB
451		Name:     name,
452		Password: password,
453	}
454	auth.SetPassword(hash[:])
455
456	return auth, nil
457}
458
459// RemoveSMTPAuth removes an SMTP authentication user by name.
460func (c *Command) RemoveSMTPAuth(ctx context.Context, name string) error {
461	if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
462		return err
463	} else if !exists {
464		return fmt.Errorf("%w: user %q", model.ErrNotFound, name)
465	}
466
467	return c.db.RemoveSMTPAuthUser(ctx, name)
468}
469
470// ListSMTPAuth returns all SMTP authentication users.
471func (c *Command) ListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
472	return c.db.ListSMTPAuthUsers(ctx)
473}
474
475// AddDKIM adds a new DKIM selector and returns the DNS record.
476func (c *Command) AddDKIM(ctx context.Context, selector, domain string) (string, error) {
477	return c.dkim.Add(ctx, selector, domain)
478}
479
480// ListDKIM returns all DKIM selectors.
481func (c *Command) ListDKIM(ctx context.Context) ([]model.DKIM, error) {
482	return c.dkim.List(ctx)
483}
484
485// GetDKIM returns the public DNS record for a DKIM selector.
486func (c *Command) GetDKIM(ctx context.Context, selector, domain string) (string, error) {
487	return c.dkim.PublicDNSRecord(ctx, selector, domain)
488}
489
490// SetDKIMEnabled enables or disables a DKIM selector.
491func (c *Command) SetDKIMEnabled(ctx context.Context, selector, domain string, enable, force bool) error {
492	if enable {
493		if !force {
494			// Perform DNS validation when force is false
495			s := domainkeyName(selector, domain)
496			records, err := net.LookupTXT(s)
497			if err != nil {
498				return fmt.Errorf("resolving TXT records: %w", err)
499			}
500
501			expected, err := c.dkim.PublicDNSRecord(ctx, selector, domain)
502			if err != nil {
503				return err
504			}
505
506			if expected != strings.Join(records, "") {
507				return errors.New("DKIM record does not match the expected record")
508			}
509		}
510	}
511
512	return c.dkim.SetEnabled(ctx, selector, domain, enable)
513}
514
515// RemoveDKIM removes a DKIM selector.
516func (c *Command) RemoveDKIM(ctx context.Context, selector, domain string) error {
517	return c.dkim.Remove(ctx, selector, domain)
518}