all repos — postern

@ main

Modern mail management

clone
git clone https:///postern.git

internal/cli/client.go (view raw)

  1package cli
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"net"
  9	"net/http"
 10	"net/url"
 11
 12	"postern/internal/model"
 13)
 14
 15func newUnixHTTPClient(socketPath string) *http.Client {
 16	return &http.Client{
 17		Transport: &http.Transport{
 18			DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
 19				var d net.Dialer
 20				return d.DialContext(ctx, "unix", socketPath)
 21			},
 22		},
 23	}
 24}
 25
 26// clientListUsers fetches the list of users from the daemon.
 27func (c *Command) clientListUsers(ctx context.Context) (_ []model.User, err error) {
 28	resp, err := c.httpClient.Get("http://unix/users")
 29	if err != nil {
 30		return nil, fmt.Errorf("failed to list users: %w", err)
 31	}
 32	defer model.Wrap(resp.Body.Close, &err, "clientListUsers closing response body")
 33
 34	if resp.StatusCode != http.StatusOK {
 35		return nil, fmt.Errorf("failed to list users: %d", resp.StatusCode)
 36	}
 37
 38	var users []model.User
 39	if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
 40		return nil, fmt.Errorf("failed to decode users response: %w", err)
 41	}
 42
 43	return users, nil
 44}
 45
 46// clientCreateUser creates a new user via the daemon.
 47func (c *Command) clientCreateUser(ctx context.Context, username string) (_ model.User, err error) {
 48	reqBody := map[string]string{"username": username}
 49	body, err := json.Marshal(reqBody)
 50	if err != nil {
 51		return model.User{}, fmt.Errorf("failed to marshal user request: %w", err)
 52	}
 53
 54	resp, err := c.httpClient.Post("http://unix/users", "application/json", bytes.NewReader(body))
 55	if err != nil {
 56		return model.User{}, fmt.Errorf("failed to create user: %w", err)
 57	}
 58	defer model.Wrap(resp.Body.Close, &err, "clientCreateUser closing response body")
 59
 60	if resp.StatusCode != http.StatusCreated {
 61		return model.User{}, fmt.Errorf("failed to create user: %d", resp.StatusCode)
 62	}
 63
 64	var user model.User
 65	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
 66		return model.User{}, fmt.Errorf("failed to decode user response: %w", err)
 67	}
 68
 69	return user, nil
 70}
 71
 72// clientRotateUserPassword rotates a user's password via the daemon.
 73func (c *Command) clientRotateUserPassword(ctx context.Context, username string) (_ model.User, err error) {
 74	path, err := url.JoinPath("http://unix/users", username, "rotate-password")
 75	if err != nil {
 76		return model.User{}, fmt.Errorf("failed to create rotate password request path: %w", err)
 77	}
 78
 79	resp, err := c.httpClient.Post(path, "application/json", nil)
 80	if err != nil {
 81		return model.User{}, fmt.Errorf("failed to rotate user password: %w", err)
 82	}
 83	defer model.Wrap(resp.Body.Close, &err, "clientRotateUserPassword closing response body")
 84
 85	if resp.StatusCode != http.StatusOK {
 86		return model.User{}, fmt.Errorf("failed to rotate user password: %d", resp.StatusCode)
 87	}
 88
 89	var user model.User
 90	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
 91		return model.User{}, fmt.Errorf("failed to decode user response: %w", err)
 92	}
 93
 94	return user, nil
 95}
 96
 97// clientRemoveUser removes a user via the daemon.
 98func (c *Command) clientRemoveUser(ctx context.Context, username string) (err error) {
 99	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://unix/users/"+username, nil)
100	if err != nil {
101		return fmt.Errorf("failed to create delete user request: %w", err)
102	}
103
104	resp, err := c.httpClient.Do(req)
105	if err != nil {
106		return fmt.Errorf("failed to remove user: %w", err)
107	}
108	defer model.Wrap(resp.Body.Close, &err, "clientRemoveUser closing response body")
109
110	if resp.StatusCode != http.StatusNoContent {
111		return fmt.Errorf("failed to remove user: %d", resp.StatusCode)
112	}
113
114	return nil
115}
116
117// clientAddUserAddress adds an address for a user via the daemon.
118func (c *Command) clientAddUserAddress(ctx context.Context, username, address string) error {
119	reqBody := map[string]string{"address": address}
120	body, err := json.Marshal(reqBody)
121	if err != nil {
122		return fmt.Errorf("failed to marshal user address request: %w", err)
123	}
124
125	path, err := url.JoinPath("http://unix/users/", username, "addresses")
126	if err != nil {
127		return fmt.Errorf("failed to create address request: %w", err)
128	}
129
130	resp, err := c.httpClient.Post(path, "application/json", bytes.NewReader(body))
131	if err != nil {
132		return fmt.Errorf("failed to add user address: %w", err)
133	}
134	defer model.Wrap(resp.Body.Close, &err, "clientAddUserAddress closing response body")
135
136	if resp.StatusCode != http.StatusCreated {
137		return fmt.Errorf("failed to add user address: %d", resp.StatusCode)
138	}
139
140	return nil
141}
142
143// clientRemoveUserAddress removes an address for a user via the daemon.
144func (c *Command) clientRemoveUserAddress(ctx context.Context, username, address string) (err error) {
145	path, err := url.JoinPath("http://unix/users/", username, "addresses", address)
146	if err != nil {
147		return fmt.Errorf("failed to create address request: %w", err)
148	}
149
150	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
151	if err != nil {
152		return fmt.Errorf("failed to create delete address request: %w", err)
153	}
154
155	resp, err := c.httpClient.Do(req)
156	if err != nil {
157		return fmt.Errorf("failed to remove user address: %w", err)
158	}
159	defer model.Wrap(resp.Body.Close, &err, "clientRemoveUserAddress closing response body")
160
161	if resp.StatusCode != http.StatusNoContent {
162		return fmt.Errorf("failed to remove user address: %d", resp.StatusCode)
163	}
164
165	return nil
166}
167
168// clientListUserAddresses fetches the list of addresses for a user from the daemon.
169func (c *Command) clientListUserAddresses(ctx context.Context, username string) (_ []string, err error) {
170	path, err := url.JoinPath("http://unix/users/", username, "addresses")
171	if err != nil {
172		return nil, fmt.Errorf("failed to create address request: %w", err)
173	}
174
175	resp, err := c.httpClient.Get(path)
176	if err != nil {
177		return nil, fmt.Errorf("failed to list user addresses: %w", err)
178	}
179	defer model.Wrap(resp.Body.Close, &err, "clientListUserAddresses closing response body")
180
181	if resp.StatusCode != http.StatusOK {
182		return nil, fmt.Errorf("failed to list user addresses: %d", resp.StatusCode)
183	}
184
185	var addresses []string
186	if err := json.NewDecoder(resp.Body).Decode(&addresses); err != nil {
187		return nil, fmt.Errorf("failed to decode user addresses response: %w", err)
188	}
189
190	return addresses, nil
191}
192
193// clientListSMTPAuth fetches the list of SMTP auth users from the daemon.
194func (c *Command) clientListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
195	resp, err := c.httpClient.Get("http://unix/smtp-auth")
196	if err != nil {
197		return nil, fmt.Errorf("failed to list smtp auth: %w", err)
198	}
199	defer model.Wrap(resp.Body.Close, &err, "clientListSMTPAuth closing response body")
200
201	if resp.StatusCode != http.StatusOK {
202		return nil, fmt.Errorf("failed to list smtp auth: %d", resp.StatusCode)
203	}
204
205	var auths []model.SMTPAuth
206	if err := json.NewDecoder(resp.Body).Decode(&auths); err != nil {
207		return nil, fmt.Errorf("failed to decode smtp auth response: %w", err)
208	}
209
210	return auths, nil
211}
212
213// clientCreateSMTPAuth creates a new SMTP auth user via the daemon.
214func (c *Command) clientCreateSMTPAuth(_ context.Context, name string) (model.SMTPAuth, error) {
215	reqBody := map[string]string{"name": name}
216	body, err := json.Marshal(reqBody)
217	if err != nil {
218		return model.SMTPAuth{}, fmt.Errorf("failed to marshal smtp auth request: %w", err)
219	}
220
221	resp, err := c.httpClient.Post("http://unix/smtp-auth", "application/json", bytes.NewReader(body))
222	if err != nil {
223		return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %w", err)
224	}
225	defer model.Wrap(resp.Body.Close, &err, "clientCreateSMTPAuth closing response body")
226
227	if resp.StatusCode != http.StatusCreated {
228		return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %d", resp.StatusCode)
229	}
230
231	var auth model.SMTPAuth
232	if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil {
233		return model.SMTPAuth{}, fmt.Errorf("failed to decode smtp auth response: %w", err)
234	}
235
236	return auth, nil
237}
238
239// clientRemoveSMTPAuth removes an SMTP auth user via the daemon.
240func (c *Command) clientRemoveSMTPAuth(ctx context.Context, name string) error {
241	path, err := url.JoinPath("http://unix/smtp-auth", name)
242	if err != nil {
243		return fmt.Errorf("failed to create remove smtp auth request path: %w", err)
244	}
245	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
246	if err != nil {
247		return fmt.Errorf("failed to create delete smtp auth request: %w", err)
248	}
249
250	resp, err := c.httpClient.Do(req)
251	if err != nil {
252		return fmt.Errorf("failed to remove smtp auth: %w", err)
253	}
254	defer model.Wrap(resp.Body.Close, &err, "clientRemoveSMTPAuth closing response body")
255
256	if resp.StatusCode != http.StatusNoContent {
257		return fmt.Errorf("failed to remove smtp auth: %d", resp.StatusCode)
258	}
259
260	return nil
261}
262
263// clientListDKIM fetches the list of DKIM selectors from the daemon.
264func (c *Command) clientListDKIM(ctx context.Context) ([]model.DKIM, error) {
265	resp, err := c.httpClient.Get("http://unix/dkim")
266	if err != nil {
267		return nil, fmt.Errorf("failed to list dkim: %w", err)
268	}
269	defer model.Wrap(resp.Body.Close, &err, "clientListDKIM closing response body")
270
271	if resp.StatusCode != http.StatusOK {
272		return nil, fmt.Errorf("failed to list dkim: %d", resp.StatusCode)
273	}
274
275	var dkims []model.DKIM
276	if err := json.NewDecoder(resp.Body).Decode(&dkims); err != nil {
277		return nil, fmt.Errorf("failed to decode dkim response: %w", err)
278	}
279
280	return dkims, nil
281}
282
283// clientAddDKIM adds a new DKIM selector via the daemon.
284func (c *Command) clientAddDKIM(ctx context.Context, selector, domain string) (string, error) {
285	reqBody := map[string]string{"selector": selector, "domain": domain}
286	body, err := json.Marshal(reqBody)
287	if err != nil {
288		return "", fmt.Errorf("failed to marshal dkim request: %w", err)
289	}
290
291	resp, err := c.httpClient.Post("http://unix/dkim", "application/json", bytes.NewReader(body))
292	if err != nil {
293		return "", fmt.Errorf("failed to add dkim: %w", err)
294	}
295	defer model.Wrap(resp.Body.Close, &err, "clientAddDKIM closing response body")
296
297	if resp.StatusCode != http.StatusOK {
298		return "", fmt.Errorf("failed to add dkim: %d", resp.StatusCode)
299	}
300
301	var result map[string]string
302	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
303		return "", fmt.Errorf("failed to decode dkim response: %w", err)
304	}
305
306	return result["dns_record"], nil
307}
308
309// clientGetDKIM gets the public DNS record for a DKIM selector via the daemon.
310func (c *Command) clientGetDKIM(_ context.Context, selector, domain string) (string, error) {
311	path, err := url.JoinPath("http://unix/dkim", selector, domain)
312	if err != nil {
313		return "", fmt.Errorf("failed to create dkim request path: %w", err)
314	}
315	resp, err := c.httpClient.Get(path)
316	if err != nil {
317		return "", fmt.Errorf("failed to get dkim: %w", err)
318	}
319	defer model.Wrap(resp.Body.Close, &err, "clientGetDKIM closing response body")
320
321	if resp.StatusCode != http.StatusOK {
322		return "", fmt.Errorf("failed to get dkim: %d", resp.StatusCode)
323	}
324
325	var result map[string]string
326	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
327		return "", fmt.Errorf("failed to decode dkim response: %w", err)
328	}
329
330	return result["dns_record"], nil
331}
332
333// clientSetDKIMEnabled enables/disables a DKIM selector via the daemon.
334func (c *Command) clientSetDKIMEnabled(_ context.Context, selector, domain string, enable bool, force bool) error {
335	enableKeyword := "enable"
336	if !enable {
337		enableKeyword = "disable"
338	}
339
340	path, err := url.JoinPath("http://unix/dkim", selector, domain, enableKeyword)
341	if err != nil {
342		return fmt.Errorf("failed to create dkim request path: %w", err)
343	}
344
345	// Include force parameter in query string
346	if !force {
347		path += "?force=false"
348	}
349
350	resp, err := c.httpClient.Post(path, "application/json", nil)
351	if err != nil {
352		return fmt.Errorf("failed to set dkim enabled: %w", err)
353	}
354	defer model.Wrap(resp.Body.Close, &err, "clientSetDKIMEnabled closing response body")
355
356	if resp.StatusCode != http.StatusNoContent {
357		return fmt.Errorf("failed to set dkim enabled: %d", resp.StatusCode)
358	}
359
360	return nil
361}
362
363// clientRemoveDKIM removes a DKIM selector via the daemon.
364func (c *Command) clientRemoveDKIM(ctx context.Context, selector, domain string) error {
365	path, err := url.JoinPath("http://unix/dkim", selector, domain)
366	if err != nil {
367		return fmt.Errorf("failed to create dkim request path: %w", err)
368	}
369	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
370	if err != nil {
371		return fmt.Errorf("failed to create delete dkim request: %w", err)
372	}
373
374	resp, err := c.httpClient.Do(req)
375	if err != nil {
376		return fmt.Errorf("failed to remove dkim: %w", err)
377	}
378	defer model.Wrap(resp.Body.Close, &err, "clientRemoveDKIM closing response body")
379
380	if resp.StatusCode != http.StatusNoContent {
381		return fmt.Errorf("failed to remove dkim: %d", resp.StatusCode)
382	}
383
384	return nil
385}
386
387// clientPruneMails prunes orphaned mails via the daemon.
388func (c *Command) clientPruneMails(_ context.Context) (int, error) {
389	resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil)
390	if err != nil {
391		return 0, fmt.Errorf("failed to prune mails: %w", err)
392	}
393	defer model.Wrap(resp.Body.Close, &err, "clientPruneMails closing response body")
394
395	if resp.StatusCode != http.StatusOK {
396		return 0, fmt.Errorf("failed to prune mails: %d", resp.StatusCode)
397	}
398
399	var result map[string]int
400	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
401		return 0, fmt.Errorf("failed to decode prune response: %w", err)
402	}
403
404	return result["pruned"], nil
405}
406
407// clientKVList fetches all key-value pairs from the daemon.
408func (c *Command) clientKVList(_ context.Context) (map[string]any, error) {
409	resp, err := c.httpClient.Get("http://unix/kv")
410	if err != nil {
411		return nil, fmt.Errorf("failed to list kv: %w", err)
412	}
413	defer model.Wrap(resp.Body.Close, &err, "clientKVList closing response body")
414
415	if resp.StatusCode != http.StatusOK {
416		return nil, fmt.Errorf("failed to list kv: %d", resp.StatusCode)
417	}
418
419	var pairs map[string]any
420	if err := json.NewDecoder(resp.Body).Decode(&pairs); err != nil {
421		return nil, fmt.Errorf("failed to decode kv response: %w", err)
422	}
423
424	return pairs, nil
425}
426
427// clientKVGet fetches the value stored under key from the daemon.
428// The key is URL-encoded so keys containing slashes work.
429func (c *Command) clientKVGet(_ context.Context, key string) (any, error) {
430	path := "http://unix/kv/" + url.PathEscape(key)
431
432	resp, err := c.httpClient.Get(path)
433	if err != nil {
434		return nil, fmt.Errorf("failed to get kv: %w", err)
435	}
436	defer model.Wrap(resp.Body.Close, &err, "clientKVGet closing response body")
437
438	if resp.StatusCode != http.StatusOK {
439		return nil, fmt.Errorf("failed to get kv: %d", resp.StatusCode)
440	}
441
442	var result struct {
443		Value any `json:"value"`
444	}
445	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
446		return nil, fmt.Errorf("failed to decode kv response: %w", err)
447	}
448
449	return result.Value, nil
450}
451
452// clientKVPop removes the value stored under key via the daemon and returns it.
453// The key is URL-encoded so keys containing slashes work.
454func (c *Command) clientKVPop(ctx context.Context, key string) (any, error) {
455	path := "http://unix/kv/" + url.PathEscape(key)
456	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
457	if err != nil {
458		return nil, fmt.Errorf("failed to create pop kv request: %w", err)
459	}
460
461	resp, err := c.httpClient.Do(req)
462	if err != nil {
463		return nil, fmt.Errorf("failed to pop kv: %w", err)
464	}
465	defer model.Wrap(resp.Body.Close, &err, "clientKVPop closing response body")
466
467	if resp.StatusCode != http.StatusOK {
468		return nil, fmt.Errorf("failed to pop kv: %d", resp.StatusCode)
469	}
470
471	var result struct {
472		Value any `json:"value"`
473	}
474	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
475		return nil, fmt.Errorf("failed to decode kv response: %w", err)
476	}
477
478	return result.Value, nil
479}