all repos — postern

@ 50dc541db37adabbbb220f4abf62aba899c48f5d

Modern mail management

clone
git clone https:///postern.git

internal/cli/admin.go (view raw)

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