all repos — postern

@ 9799a3215ac0185fb9b4e7d6ec0645c2cb2ce55b

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	userRemoveCmd := &cobra.Command{
117		Use:   "remove <username>",
118		Short: "Remove a user",
119		Args:  cobra.ExactArgs(1),
120		RunE:  c.removeUser,
121	}
122
123	userCmd.AddCommand(userListCmd)
124	userCmd.AddCommand(addUserCmd)
125	userCmd.AddCommand(addUserAddressCmd)
126	userCmd.AddCommand(userRemoveCmd)
127
128	backupMxCmd := &cobra.Command{
129		Use:   "backup-mx",
130		Short: "Manage inbound SMTP authentication",
131	}
132
133	backupMxListCmd := &cobra.Command{
134		Use:   "list",
135		Short: "List all SMTP auth users",
136		Args:  cobra.NoArgs,
137		RunE:  c.listSMTPAuth,
138	}
139
140	backupMxAddCmd := &cobra.Command{
141		Use:   "add <name>",
142		Short: "Add SMTP auth user",
143		Args:  cobra.ExactArgs(1),
144		RunE:  c.addSMTPAuth,
145	}
146
147	backupMxRemoveCmd := &cobra.Command{
148		Use:   "remove <name>",
149		Short: "Remove SMTP auth user",
150		Args:  cobra.ExactArgs(1),
151		RunE:  c.removeSMTPAuth,
152	}
153
154	backupMxCmd.AddCommand(backupMxListCmd)
155	backupMxCmd.AddCommand(backupMxAddCmd)
156	backupMxCmd.AddCommand(backupMxRemoveCmd)
157
158	dkimCmd := &cobra.Command{
159		Use:   "dkim",
160		Short: "Manage DKIM selectors",
161	}
162
163	dkimListCmd := &cobra.Command{
164		Use:   "list",
165		Short: "List all available selectors",
166		Args:  cobra.NoArgs,
167		RunE:  c.listDKIM,
168	}
169
170	addDKIMCmd := &cobra.Command{
171		Use:   "add [selector] [domain]",
172		Short: "Add DKIM selector for domain",
173		Args:  cobra.ExactArgs(2),
174		RunE:  c.addDKIM,
175	}
176
177	getDKIMCmd := &cobra.Command{
178		Use:   "get [selector] [domain]",
179		Short: "Get public DKIM record for the given selector and domain",
180		Args:  cobra.ExactArgs(2),
181		RunE:  c.getDKIM,
182	}
183
184	enableDKIMCmd := &cobra.Command{
185		Use:   "enable [selector] [domain]",
186		Short: "Enable DKIM selector",
187		Args:  cobra.ExactArgs(2),
188		RunE: func(cmd *cobra.Command, args []string) error {
189			return c.setDKIMEnabled(cmd, args, true)
190		},
191	}
192	enableDKIMCmd.Flags().BoolP("force", "f", false, "Force enable DKIM even if validation fails")
193
194	disableDKIMCmd := &cobra.Command{
195		Use:   "disable [selector] [domain]",
196		Short: "Disable DKIM selector",
197		Args:  cobra.ExactArgs(2),
198		RunE: func(cmd *cobra.Command, args []string) error {
199			return c.setDKIMEnabled(cmd, args, false)
200		},
201	}
202
203	dkimRemoveCmd := &cobra.Command{
204		Use:   "remove [selector] [domain]",
205		Short: "Remove DKIM selector",
206		Args:  cobra.ExactArgs(2),
207		RunE:  c.removeDKIM,
208	}
209
210	dkimCmd.AddCommand(dkimListCmd)
211	dkimCmd.AddCommand(addDKIMCmd)
212	dkimCmd.AddCommand(getDKIMCmd)
213	dkimCmd.AddCommand(enableDKIMCmd)
214	dkimCmd.AddCommand(disableDKIMCmd)
215	dkimCmd.AddCommand(dkimRemoveCmd)
216
217	rootCmd.AddCommand(serveCmd)
218	rootCmd.AddCommand(pruneCmd)
219	rootCmd.AddCommand(userCmd)
220	rootCmd.AddCommand(backupMxCmd)
221	rootCmd.AddCommand(dkimCmd)
222
223	return rootCmd
224}
225
226func (c *Command) Execute() error {
227	return c.rootCmd().Execute()
228}
229
230func (c *Command) startServer(_ *cobra.Command, _ []string) error {
231	// Lazy bootstrap: only initialize resources when serve command is run
232	if c.bootstrap != nil {
233		deps, err := c.bootstrap()
234		if err != nil {
235			return err
236		}
237		c.db = deps.DB
238		c.persistence = deps.Persistence
239		c.dkim = deps.DKIM
240		c.services = deps.Services
241	}
242
243	fmt.Println("Starting postern server...")
244	slog.SetLogLoggerLevel(slog.LevelDebug)
245
246	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
247	defer stop()
248
249	// The admin socket always runs and can't be configured in the policy.star
250	c.services["http"] = NewHTTPService(c.socketPath, c)
251
252	var wg sync.WaitGroup
253	for name, svc := range c.services {
254		wg.Go(func() {
255			err := svc.Start()
256			if err != nil {
257				slog.Error("running service", "name", name, "error", err)
258			}
259		})
260	}
261
262	go func() {
263		<-ctx.Done()
264		slog.Info("shutdown signal received, stopping services")
265		for name, svc := range c.services {
266			if err := svc.Stop(); err != nil {
267				slog.Error("stopping service", "name", name, "error", err)
268			}
269		}
270	}()
271
272	wg.Wait() // wait for services
273
274	if c.db != nil {
275		err := c.db.Close()
276		if err != nil {
277			slog.Error("closing database connection", "error", err)
278		}
279		slog.Info("closing database connection")
280	}
281	return nil
282}