all repos — postern

@ v0.1.0-alpha

Modern mail management

clone
git clone https:///postern.git

internal/cli/http.go (view raw)

  1package cli
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"log/slog"
  9	"net"
 10	"net/http"
 11	"os"
 12	"path/filepath"
 13	"time"
 14
 15	"postern/internal/model"
 16)
 17
 18// HTTPService implements the cli.Service interface for serving HTTP endpoints.
 19type HTTPService struct {
 20	cmd        *Command
 21	socketPath string
 22	srv        *http.Server
 23}
 24
 25// NewHTTPService creates a new HTTP service that serves REST endpoints.
 26func NewHTTPService(socketPath string, cmd *Command) *HTTPService {
 27	return &HTTPService{
 28		cmd:        cmd,
 29		socketPath: socketPath,
 30	}
 31}
 32
 33// Start starts the HTTP server.
 34func (h *HTTPService) Start() error {
 35	mux := h.HTTPMux()
 36
 37	// Ensure parent dir exists
 38	if err := os.MkdirAll(filepath.Dir(h.socketPath), 0700); err != nil {
 39		return fmt.Errorf("creating socket dir: %w", err)
 40	}
 41	// Remove stale socket from ungraceful shutdown
 42	_ = os.Remove(h.socketPath)
 43
 44	listener, err := net.Listen("unix", h.socketPath)
 45	if err != nil {
 46		return fmt.Errorf("listening on unix socket: %w", err)
 47	}
 48	if err := os.Chmod(h.socketPath, 0660); err != nil {
 49		return fmt.Errorf("setting socket permissions: %w", err)
 50	}
 51
 52	h.srv = &http.Server{
 53		Handler:           mux,
 54		ReadHeaderTimeout: 5 * time.Second,
 55		ReadTimeout:       10 * time.Second,
 56		WriteTimeout:      30 * time.Second,
 57		IdleTimeout:       120 * time.Second,
 58	}
 59
 60	slog.Info("starting HTTP server on unix socket", "path", h.socketPath)
 61	err = h.srv.Serve(listener)
 62	if err != nil && !errors.Is(err, http.ErrServerClosed) {
 63		return err
 64	}
 65	return nil
 66}
 67
 68// Stop stops the HTTP server gracefully.
 69func (h *HTTPService) Stop() error {
 70	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 71	defer cancel()
 72
 73	slog.Info("shutting down HTTP server")
 74	return h.srv.Shutdown(ctx)
 75}
 76
 77// Status returns the status of the HTTP service.
 78func (h *HTTPService) Status() string {
 79	if h.srv != nil {
 80		return "running"
 81	}
 82	return "stopped"
 83}
 84
 85// HTTPMux creates and returns the HTTP ServeMux with all routes.
 86func (h *HTTPService) HTTPMux() *http.ServeMux {
 87	mux := http.NewServeMux()
 88
 89	// User endpoints
 90	mux.HandleFunc("GET /users", h.listUsers)
 91	mux.HandleFunc("POST /users", h.createUser)
 92	mux.HandleFunc("DELETE /users/{username}", h.deleteUser)
 93	mux.HandleFunc("POST /users/{username}/addresses", h.addUserAddress)
 94	mux.HandleFunc("GET /users/{username}/addresses", h.listUserAddresses)
 95	mux.HandleFunc("DELETE /users/{username}/addresses/{address}", h.deleteUserAddress)
 96	mux.HandleFunc("POST /users/{username}/rotate-password", h.rotateUserPassword)
 97
 98	// SMTP auth endpoints
 99	mux.HandleFunc("GET /smtp-auth", h.listSMTPAuth)
100	mux.HandleFunc("POST /smtp-auth", h.createSMTPAuth)
101	mux.HandleFunc("DELETE /smtp-auth/{name}", h.deleteSMTPAuth)
102
103	// DKIM endpoints
104	mux.HandleFunc("GET /dkim", h.listDKIM)
105	mux.HandleFunc("POST /dkim", h.addDKIM)
106	mux.HandleFunc("GET /dkim/{selector}/{domain}", h.getDKIM)
107	mux.HandleFunc("POST /dkim/{selector}/{domain}/enable", h.enableDKIM)
108	mux.HandleFunc("POST /dkim/{selector}/{domain}/disable", h.disableDKIM)
109	mux.HandleFunc("DELETE /dkim/{selector}/{domain}", h.deleteDKIM)
110
111	// Prune endpoint
112	mux.HandleFunc("POST /prune", h.pruneMails)
113
114	// KV endpoints
115	mux.HandleFunc("GET /kv", h.kvList)
116	mux.HandleFunc("GET /kv/{key...}", h.kvGet)
117	mux.HandleFunc("DELETE /kv/{key...}", h.kvPop)
118
119	return mux
120}
121
122// Helper to write JSON responses
123func writeJSON(w http.ResponseWriter, status int, v interface{}) {
124	w.Header().Set("Content-Type", "application/json")
125	w.WriteHeader(status)
126	if err := json.NewEncoder(w).Encode(v); err != nil {
127		slog.Error("failed to encode JSON response", "error", err)
128	}
129}
130
131// Helper to write error responses
132func writeError(w http.ResponseWriter, err error) {
133	switch {
134	case errors.Is(err, model.ErrAlreadyExists):
135		writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
136	case errors.Is(err, model.ErrNotFound):
137		writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
138	case errors.Is(err, model.ErrInvalidInput):
139		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
140	default:
141		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
142	}
143}
144
145func (h *HTTPService) listUsers(w http.ResponseWriter, r *http.Request) {
146	users, err := h.cmd.ListUsers(r.Context())
147	if err != nil {
148		writeError(w, err)
149		return
150	}
151	writeJSON(w, http.StatusOK, users)
152}
153
154func (h *HTTPService) createUser(w http.ResponseWriter, r *http.Request) {
155	var req struct {
156		Username string `json:"username"`
157	}
158	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
159		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
160		return
161	}
162	if req.Username == "" {
163		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
164		return
165	}
166
167	user, err := h.cmd.CreateUser(r.Context(), req.Username)
168	if err != nil {
169		writeError(w, err)
170		return
171	}
172
173	writeJSON(w, http.StatusCreated, user)
174}
175
176func (h *HTTPService) deleteUser(w http.ResponseWriter, r *http.Request) {
177	username := r.PathValue("username")
178	if username == "" {
179		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
180		return
181	}
182
183	err := h.cmd.RemoveUser(r.Context(), username)
184	if err != nil {
185		writeError(w, err)
186		return
187	}
188
189	w.WriteHeader(http.StatusNoContent)
190}
191
192func (h *HTTPService) addUserAddress(w http.ResponseWriter, r *http.Request) {
193	username := r.PathValue("username")
194	if username == "" {
195		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
196		return
197	}
198
199	var req struct {
200		Address string `json:"address"`
201	}
202	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
203		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
204		return
205	}
206	if req.Address == "" {
207		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "address is required"})
208		return
209	}
210
211	err := h.cmd.AddUserAddress(r.Context(), username, req.Address)
212	if err != nil {
213		writeError(w, err)
214		return
215	}
216
217	w.WriteHeader(http.StatusCreated)
218}
219
220func (h *HTTPService) listUserAddresses(w http.ResponseWriter, r *http.Request) {
221	username := r.PathValue("username")
222	if username == "" {
223		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
224		return
225	}
226
227	addresses, err := h.cmd.ListUserAddresses(r.Context(), username)
228	if err != nil {
229		writeError(w, err)
230		return
231	}
232	writeJSON(w, http.StatusOK, addresses)
233}
234
235func (h *HTTPService) deleteUserAddress(w http.ResponseWriter, r *http.Request) {
236	username := r.PathValue("username")
237	if username == "" {
238		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
239		return
240	}
241	address := r.PathValue("address")
242	if address == "" {
243		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "address is required"})
244		return
245	}
246
247	err := h.cmd.RemoveUserAddress(r.Context(), username, address)
248	if err != nil {
249		writeError(w, err)
250		return
251	}
252
253	w.WriteHeader(http.StatusNoContent)
254}
255
256func (h *HTTPService) rotateUserPassword(w http.ResponseWriter, r *http.Request) {
257	username := r.PathValue("username")
258	if username == "" {
259		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"})
260		return
261	}
262
263	user, err := h.cmd.RotateUserPassword(r.Context(), username)
264	if err != nil {
265		writeError(w, err)
266		return
267	}
268
269	writeJSON(w, http.StatusOK, user)
270}
271
272func (h *HTTPService) listSMTPAuth(w http.ResponseWriter, r *http.Request) {
273	auths, err := h.cmd.ListSMTPAuth(r.Context())
274	if err != nil {
275		writeError(w, err)
276		return
277	}
278	writeJSON(w, http.StatusOK, auths)
279}
280
281func (h *HTTPService) createSMTPAuth(w http.ResponseWriter, r *http.Request) {
282	var req struct {
283		Name string `json:"name"`
284	}
285	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
286		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
287		return
288	}
289	if req.Name == "" {
290		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
291		return
292	}
293
294	auth, err := h.cmd.CreateSMTPAuth(r.Context(), req.Name)
295	if err != nil {
296		writeError(w, err)
297		return
298	}
299
300	writeJSON(w, http.StatusCreated, auth)
301}
302
303func (h *HTTPService) deleteSMTPAuth(w http.ResponseWriter, r *http.Request) {
304	name := r.PathValue("name")
305	if name == "" {
306		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
307		return
308	}
309
310	err := h.cmd.RemoveSMTPAuth(r.Context(), name)
311	if err != nil {
312		writeError(w, err)
313		return
314	}
315
316	w.WriteHeader(http.StatusNoContent)
317}
318
319func (h *HTTPService) listDKIM(w http.ResponseWriter, r *http.Request) {
320	dkims, err := h.cmd.ListDKIM(r.Context())
321	if err != nil {
322		writeError(w, err)
323		return
324	}
325	writeJSON(w, http.StatusOK, dkims)
326}
327
328func (h *HTTPService) addDKIM(w http.ResponseWriter, r *http.Request) {
329	var req struct {
330		Selector string `json:"selector"`
331		Domain   string `json:"domain"`
332	}
333	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
334		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
335		return
336	}
337	if req.Selector == "" {
338		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"})
339		return
340	}
341	if req.Domain == "" {
342		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"})
343		return
344	}
345
346	record, err := h.cmd.AddDKIM(r.Context(), req.Selector, req.Domain)
347	if err != nil {
348		writeError(w, err)
349		return
350	}
351
352	writeJSON(w, http.StatusOK, map[string]string{"dns_record": record})
353}
354
355func (h *HTTPService) getDKIM(w http.ResponseWriter, r *http.Request) {
356	selector := r.PathValue("selector")
357	domain := r.PathValue("domain")
358	if selector == "" {
359		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"})
360		return
361	}
362	if domain == "" {
363		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"})
364		return
365	}
366
367	record, err := h.cmd.GetDKIM(r.Context(), selector, domain)
368	if err != nil {
369		writeError(w, err)
370		return
371	}
372
373	writeJSON(w, http.StatusOK, map[string]string{"dns_record": record})
374}
375
376func (h *HTTPService) enableDKIM(w http.ResponseWriter, r *http.Request) {
377	selector := r.PathValue("selector")
378	domain := r.PathValue("domain")
379	if selector == "" {
380		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"})
381		return
382	}
383	if domain == "" {
384		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"})
385		return
386	}
387
388	// Parse force query parameter
389	forceStr := r.URL.Query().Get("force")
390	force := forceStr != "false" // Default to true if not explicitly set to "false"
391
392	err := h.cmd.SetDKIMEnabled(r.Context(), selector, domain, true, force)
393	if err != nil {
394		writeError(w, err)
395		return
396	}
397
398	w.WriteHeader(http.StatusNoContent)
399}
400
401func (h *HTTPService) disableDKIM(w http.ResponseWriter, r *http.Request) {
402	selector := r.PathValue("selector")
403	domain := r.PathValue("domain")
404	if selector == "" {
405		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"})
406		return
407	}
408	if domain == "" {
409		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"})
410		return
411	}
412
413	err := h.cmd.SetDKIMEnabled(r.Context(), selector, domain, false, true)
414	if err != nil {
415		writeError(w, err)
416		return
417	}
418
419	w.WriteHeader(http.StatusNoContent)
420}
421
422func (h *HTTPService) deleteDKIM(w http.ResponseWriter, r *http.Request) {
423	selector := r.PathValue("selector")
424	domain := r.PathValue("domain")
425	if selector == "" {
426		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"})
427		return
428	}
429	if domain == "" {
430		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"})
431		return
432	}
433
434	err := h.cmd.RemoveDKIM(r.Context(), selector, domain)
435	if err != nil {
436		writeError(w, err)
437		return
438	}
439
440	w.WriteHeader(http.StatusNoContent)
441}
442
443func (h *HTTPService) pruneMails(w http.ResponseWriter, r *http.Request) {
444	count, err := h.cmd.PruneMails(r.Context())
445	if err != nil {
446		writeError(w, err)
447		return
448	}
449
450	writeJSON(w, http.StatusOK, map[string]int{"pruned": count})
451}
452
453func (h *HTTPService) kvList(w http.ResponseWriter, r *http.Request) {
454	pairs, err := h.cmd.KVList(r.Context())
455	if err != nil {
456		writeError(w, err)
457		return
458	}
459	writeJSON(w, http.StatusOK, pairs)
460}
461
462func (h *HTTPService) kvGet(w http.ResponseWriter, r *http.Request) {
463	key := r.PathValue("key")
464	if key == "" {
465		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key is required"})
466		return
467	}
468
469	val, err := h.cmd.KVGet(r.Context(), key)
470	if err != nil {
471		writeError(w, err)
472		return
473	}
474
475	writeJSON(w, http.StatusOK, map[string]any{"value": val})
476}
477
478func (h *HTTPService) kvPop(w http.ResponseWriter, r *http.Request) {
479	key := r.PathValue("key")
480	if key == "" {
481		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "key is required"})
482		return
483	}
484
485	val, err := h.cmd.KVPop(r.Context(), key)
486	if err != nil {
487		writeError(w, err)
488		return
489	}
490
491	writeJSON(w, http.StatusOK, map[string]any{"value": val})
492}