all repos — postern

@ v0.1.0-alpha

Modern mail management

clone
git clone https:///postern.git

internal/cli/cli.go (view raw)

  1package cli
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"net/http"
  8	"os"
  9	"os/signal"
 10	"sync"
 11	"syscall"
 12
 13	"postern/internal/db"
 14	"postern/internal/dkim"
 15	"postern/internal/persistence"
 16
 17	"github.com/spf13/cobra"
 18)
 19
 20type Service interface {
 21	Start() error
 22	Stop() error
 23	Status() string
 24}
 25
 26// BootstrapDeps holds the resources created by the Bootstrap closure, which
 27// are only required when running the "serve" command.
 28type BootstrapDeps struct {
 29	DB          *db.DB
 30	Persistence *persistence.Persistence
 31	DKIM        *dkim.DKIM
 32	Services    map[string]Service
 33}
 34
 35type Config struct {
 36	// SocketPath is always required; non-serve commands use it to reach the
 37	// running daemon over a unix socket.
 38	SocketPath string
 39	// Bootstrap is invoked lazily by the "serve" command to create the DB,
 40	// persistence, DKIM, and network services. It may be nil when deps are
 41	// supplied directly (e.g. in tests).
 42	Bootstrap func() (*BootstrapDeps, error)
 43
 44	// Optional pre-populated dependencies (for tests / direct usage).
 45	DB          *db.DB
 46	Persistence *persistence.Persistence
 47	DKIM        *dkim.DKIM
 48	Services    map[string]Service
 49}
 50
 51type Command struct {
 52	db          *db.DB
 53	persistence *persistence.Persistence
 54	dkim        *dkim.DKIM
 55	services    map[string]Service
 56	socketPath  string
 57	httpClient  *http.Client
 58	bootstrap   func() (*BootstrapDeps, error)
 59}
 60
 61func New(config *Config) (*Command, error) {
 62	return &Command{
 63		db:          config.DB,
 64		persistence: config.Persistence,
 65		dkim:        config.DKIM,
 66		services:    config.Services,
 67		socketPath:  config.SocketPath,
 68		httpClient:  newUnixHTTPClient(config.SocketPath),
 69		bootstrap:   config.Bootstrap,
 70	}, nil
 71}
 72
 73func (c *Command) rootCmd() *cobra.Command {
 74	rootCmd := &cobra.Command{
 75		Use:   "postern",
 76		Short: "Modern mail management",
 77	}
 78
 79	serveCmd := &cobra.Command{
 80		Use:   "serve",
 81		Short: "Start the mail server",
 82		RunE:  c.startServer,
 83	}
 84
 85	pruneCmd := &cobra.Command{
 86		Use:   "prune",
 87		Short: "Delete orphaned mails",
 88		RunE:  c.pruneMails,
 89	}
 90
 91	userCmd := &cobra.Command{
 92		Use:   "user",
 93		Short: "Manage local login accounts",
 94	}
 95
 96	userListCmd := &cobra.Command{
 97		Use:   "list",
 98		Short: "List all users",
 99		RunE:  c.listUsers,
100	}
101
102	addUserCmd := &cobra.Command{
103		Use:   "add <username>",
104		Short: "Add a user",
105		Args:  cobra.ExactArgs(1),
106		RunE:  c.addUser,
107	}
108
109	addUserAddressCmd := &cobra.Command{
110		Use:   "add-address <username> <address>",
111		Short: "Add an address for a user",
112		Args:  cobra.ExactArgs(2),
113		RunE:  c.addUserAddress,
114	}
115
116	removeAddressCmd := &cobra.Command{
117		Use:   "remove-address <username> <address>",
118		Short: "Remove an address for a user",
119		Args:  cobra.ExactArgs(2),
120		RunE:  c.removeUserAddress,
121	}
122
123	listAddressesCmd := &cobra.Command{
124		Use:   "list-addresses <username>",
125		Short: "List all addresses for a user",
126		Args:  cobra.ExactArgs(1),
127		RunE:  c.listUserAddresses,
128	}
129
130	userRemoveCmd := &cobra.Command{
131		Use:   "remove <username>",
132		Short: "Remove a user",
133		Args:  cobra.ExactArgs(1),
134		RunE:  c.removeUser,
135	}
136
137	rotatePasswordCmd := &cobra.Command{
138		Use:   "rotate-password <username>",
139		Short: "Generate a new password for a user",
140		Args:  cobra.ExactArgs(1),
141		RunE:  c.rotateUserPassword,
142	}
143
144	userCmd.AddCommand(userListCmd)
145	userCmd.AddCommand(addUserCmd)
146	userCmd.AddCommand(addUserAddressCmd)
147	userCmd.AddCommand(removeAddressCmd)
148	userCmd.AddCommand(listAddressesCmd)
149	userCmd.AddCommand(userRemoveCmd)
150	userCmd.AddCommand(rotatePasswordCmd)
151
152	backupMxCmd := &cobra.Command{
153		Use:   "backup-mx",
154		Short: "Manage inbound SMTP authentication",
155	}
156
157	backupMxListCmd := &cobra.Command{
158		Use:   "list",
159		Short: "List all SMTP auth users",
160		Args:  cobra.NoArgs,
161		RunE:  c.listSMTPAuth,
162	}
163
164	backupMxAddCmd := &cobra.Command{
165		Use:   "add <name>",
166		Short: "Add SMTP auth user",
167		Args:  cobra.ExactArgs(1),
168		RunE:  c.addSMTPAuth,
169	}
170
171	backupMxRemoveCmd := &cobra.Command{
172		Use:   "remove <name>",
173		Short: "Remove SMTP auth user",
174		Args:  cobra.ExactArgs(1),
175		RunE:  c.removeSMTPAuth,
176	}
177
178	backupMxCmd.AddCommand(backupMxListCmd)
179	backupMxCmd.AddCommand(backupMxAddCmd)
180	backupMxCmd.AddCommand(backupMxRemoveCmd)
181
182	dkimCmd := &cobra.Command{
183		Use:   "dkim",
184		Short: "Manage DKIM selectors",
185	}
186
187	dkimListCmd := &cobra.Command{
188		Use:   "list",
189		Short: "List all available selectors",
190		Args:  cobra.NoArgs,
191		RunE:  c.listDKIM,
192	}
193
194	addDKIMCmd := &cobra.Command{
195		Use:   "add [selector] [domain]",
196		Short: "Add DKIM selector for domain",
197		Args:  cobra.ExactArgs(2),
198		RunE:  c.addDKIM,
199	}
200
201	getDKIMCmd := &cobra.Command{
202		Use:   "get [selector] [domain]",
203		Short: "Get public DKIM record for the given selector and domain",
204		Args:  cobra.ExactArgs(2),
205		RunE:  c.getDKIM,
206	}
207
208	enableDKIMCmd := &cobra.Command{
209		Use:   "enable [selector] [domain]",
210		Short: "Enable DKIM selector",
211		Args:  cobra.ExactArgs(2),
212		RunE: func(cmd *cobra.Command, args []string) error {
213			return c.setDKIMEnabled(cmd, args, true)
214		},
215	}
216	enableDKIMCmd.Flags().BoolP("force", "f", false, "Force enable DKIM even if validation fails")
217
218	disableDKIMCmd := &cobra.Command{
219		Use:   "disable [selector] [domain]",
220		Short: "Disable DKIM selector",
221		Args:  cobra.ExactArgs(2),
222		RunE: func(cmd *cobra.Command, args []string) error {
223			return c.setDKIMEnabled(cmd, args, false)
224		},
225	}
226
227	dkimRemoveCmd := &cobra.Command{
228		Use:   "remove [selector] [domain]",
229		Short: "Remove DKIM selector",
230		Args:  cobra.ExactArgs(2),
231		RunE:  c.removeDKIM,
232	}
233
234	dkimCmd.AddCommand(dkimListCmd)
235	dkimCmd.AddCommand(addDKIMCmd)
236	dkimCmd.AddCommand(getDKIMCmd)
237	dkimCmd.AddCommand(enableDKIMCmd)
238	dkimCmd.AddCommand(disableDKIMCmd)
239	dkimCmd.AddCommand(dkimRemoveCmd)
240
241	kvCmd := &cobra.Command{
242		Use:   "kv",
243		Short: "Inspect the key-value store",
244	}
245
246	kvListCmd := &cobra.Command{
247		Use:   "list",
248		Short: "List all keys",
249		Args:  cobra.NoArgs,
250		RunE:  c.kvList,
251	}
252
253	kvGetCmd := &cobra.Command{
254		Use:   "get <key>",
255		Short: "Get the value for a key",
256		Args:  cobra.ExactArgs(1),
257		RunE:  c.kvGet,
258	}
259
260	kvPopCmd := &cobra.Command{
261		Use:   "pop <key>",
262		Short: "Remove key and return its value",
263		Args:  cobra.ExactArgs(1),
264		RunE:  c.kvPop,
265	}
266
267	kvCmd.AddCommand(kvListCmd)
268	kvCmd.AddCommand(kvGetCmd)
269	kvCmd.AddCommand(kvPopCmd)
270
271	rootCmd.AddCommand(serveCmd)
272	rootCmd.AddCommand(pruneCmd)
273	rootCmd.AddCommand(userCmd)
274	rootCmd.AddCommand(backupMxCmd)
275	rootCmd.AddCommand(dkimCmd)
276	rootCmd.AddCommand(kvCmd)
277
278	return rootCmd
279}
280
281func (c *Command) Execute() error {
282	return c.rootCmd().Execute()
283}
284
285func (c *Command) startServer(_ *cobra.Command, _ []string) error {
286	// Lazy bootstrap: only initialize resources when serve command is run
287	if c.bootstrap != nil {
288		deps, err := c.bootstrap()
289		if err != nil {
290			return err
291		}
292		c.db = deps.DB
293		c.persistence = deps.Persistence
294		c.dkim = deps.DKIM
295		c.services = deps.Services
296	}
297
298	fmt.Println("Starting postern server...")
299	slog.SetLogLoggerLevel(slog.LevelDebug)
300
301	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
302	defer stop()
303
304	// The admin socket always runs and can't be configured in the policy.star
305	c.services["http"] = NewHTTPService(c.socketPath, c)
306
307	var wg sync.WaitGroup
308	for name, svc := range c.services {
309		wg.Go(func() {
310			err := svc.Start()
311			if err != nil {
312				slog.Error("running service", "name", name, "error", err)
313			}
314		})
315	}
316
317	go func() {
318		<-ctx.Done()
319		slog.Info("shutdown signal received, stopping services")
320		for name, svc := range c.services {
321			if err := svc.Stop(); err != nil {
322				slog.Error("stopping service", "name", name, "error", err)
323			}
324		}
325	}()
326
327	wg.Wait() // wait for services
328
329	if c.db != nil {
330		err := c.db.Close()
331		if err != nil {
332			slog.Error("closing database connection", "error", err)
333		}
334		slog.Info("closing database connection")
335	}
336	return nil
337}