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}