all repos — postern @ fc287763b9ce6b6f9823e11c0b1173da0446ebc5

Modern mail management

internal/cli/admin.go (view raw)

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