all repos — postern

@ 087f9dd30bc5a591621f1184af323569d6a0d240

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// clientListSMTPAuth fetches the list of SMTP auth users from the daemon.
144func (c *Command) clientListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
145	resp, err := c.httpClient.Get("http://unix/smtp-auth")
146	if err != nil {
147		return nil, fmt.Errorf("failed to list smtp auth: %w", err)
148	}
149	defer model.Wrap(resp.Body.Close, &err, "clientListSMTPAuth closing response body")
150
151	if resp.StatusCode != http.StatusOK {
152		return nil, fmt.Errorf("failed to list smtp auth: %d", resp.StatusCode)
153	}
154
155	var auths []model.SMTPAuth
156	if err := json.NewDecoder(resp.Body).Decode(&auths); err != nil {
157		return nil, fmt.Errorf("failed to decode smtp auth response: %w", err)
158	}
159
160	return auths, nil
161}
162
163// clientCreateSMTPAuth creates a new SMTP auth user via the daemon.
164func (c *Command) clientCreateSMTPAuth(_ context.Context, name string) (model.SMTPAuth, error) {
165	reqBody := map[string]string{"name": name}
166	body, err := json.Marshal(reqBody)
167	if err != nil {
168		return model.SMTPAuth{}, fmt.Errorf("failed to marshal smtp auth request: %w", err)
169	}
170
171	resp, err := c.httpClient.Post("http://unix/smtp-auth", "application/json", bytes.NewReader(body))
172	if err != nil {
173		return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %w", err)
174	}
175	defer model.Wrap(resp.Body.Close, &err, "clientCreateSMTPAuth closing response body")
176
177	if resp.StatusCode != http.StatusCreated {
178		return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %d", resp.StatusCode)
179	}
180
181	var auth model.SMTPAuth
182	if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil {
183		return model.SMTPAuth{}, fmt.Errorf("failed to decode smtp auth response: %w", err)
184	}
185
186	return auth, nil
187}
188
189// clientRemoveSMTPAuth removes an SMTP auth user via the daemon.
190func (c *Command) clientRemoveSMTPAuth(ctx context.Context, name string) error {
191	path, err := url.JoinPath("http://unix/smtp-auth", name)
192	if err != nil {
193		return fmt.Errorf("failed to create remove smtp auth request path: %w", err)
194	}
195	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
196	if err != nil {
197		return fmt.Errorf("failed to create delete smtp auth request: %w", err)
198	}
199
200	resp, err := c.httpClient.Do(req)
201	if err != nil {
202		return fmt.Errorf("failed to remove smtp auth: %w", err)
203	}
204	defer model.Wrap(resp.Body.Close, &err, "clientRemoveSMTPAuth closing response body")
205
206	if resp.StatusCode != http.StatusNoContent {
207		return fmt.Errorf("failed to remove smtp auth: %d", resp.StatusCode)
208	}
209
210	return nil
211}
212
213// clientListDKIM fetches the list of DKIM selectors from the daemon.
214func (c *Command) clientListDKIM(ctx context.Context) ([]model.DKIM, error) {
215	resp, err := c.httpClient.Get("http://unix/dkim")
216	if err != nil {
217		return nil, fmt.Errorf("failed to list dkim: %w", err)
218	}
219	defer model.Wrap(resp.Body.Close, &err, "clientListDKIM closing response body")
220
221	if resp.StatusCode != http.StatusOK {
222		return nil, fmt.Errorf("failed to list dkim: %d", resp.StatusCode)
223	}
224
225	var dkims []model.DKIM
226	if err := json.NewDecoder(resp.Body).Decode(&dkims); err != nil {
227		return nil, fmt.Errorf("failed to decode dkim response: %w", err)
228	}
229
230	return dkims, nil
231}
232
233// clientAddDKIM adds a new DKIM selector via the daemon.
234func (c *Command) clientAddDKIM(ctx context.Context, selector, domain string) (string, error) {
235	reqBody := map[string]string{"selector": selector, "domain": domain}
236	body, err := json.Marshal(reqBody)
237	if err != nil {
238		return "", fmt.Errorf("failed to marshal dkim request: %w", err)
239	}
240
241	resp, err := c.httpClient.Post("http://unix/dkim", "application/json", bytes.NewReader(body))
242	if err != nil {
243		return "", fmt.Errorf("failed to add dkim: %w", err)
244	}
245	defer model.Wrap(resp.Body.Close, &err, "clientAddDKIM closing response body")
246
247	if resp.StatusCode != http.StatusOK {
248		return "", fmt.Errorf("failed to add dkim: %d", resp.StatusCode)
249	}
250
251	var result map[string]string
252	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
253		return "", fmt.Errorf("failed to decode dkim response: %w", err)
254	}
255
256	return result["dns_record"], nil
257}
258
259// clientGetDKIM gets the public DNS record for a DKIM selector via the daemon.
260func (c *Command) clientGetDKIM(_ context.Context, selector, domain string) (string, error) {
261	path, err := url.JoinPath("http://unix/dkim", selector, domain)
262	if err != nil {
263		return "", fmt.Errorf("failed to create dkim request path: %w", err)
264	}
265	resp, err := c.httpClient.Get(path)
266	if err != nil {
267		return "", fmt.Errorf("failed to get dkim: %w", err)
268	}
269	defer model.Wrap(resp.Body.Close, &err, "clientGetDKIM closing response body")
270
271	if resp.StatusCode != http.StatusOK {
272		return "", fmt.Errorf("failed to get dkim: %d", resp.StatusCode)
273	}
274
275	var result map[string]string
276	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
277		return "", fmt.Errorf("failed to decode dkim response: %w", err)
278	}
279
280	return result["dns_record"], nil
281}
282
283// clientSetDKIMEnabled enables/disables a DKIM selector via the daemon.
284func (c *Command) clientSetDKIMEnabled(_ context.Context, selector, domain string, enable bool, force bool) error {
285	enableKeyword := "enable"
286	if !enable {
287		enableKeyword = "disable"
288	}
289
290	path, err := url.JoinPath("http://unix/dkim", selector, domain, enableKeyword)
291	if err != nil {
292		return fmt.Errorf("failed to create dkim request path: %w", err)
293	}
294
295	// Include force parameter in query string
296	if !force {
297		path += "?force=false"
298	}
299
300	resp, err := c.httpClient.Post(path, "application/json", nil)
301	if err != nil {
302		return fmt.Errorf("failed to set dkim enabled: %w", err)
303	}
304	defer model.Wrap(resp.Body.Close, &err, "clientSetDKIMEnabled closing response body")
305
306	if resp.StatusCode != http.StatusNoContent {
307		return fmt.Errorf("failed to set dkim enabled: %d", resp.StatusCode)
308	}
309
310	return nil
311}
312
313// clientRemoveDKIM removes a DKIM selector via the daemon.
314func (c *Command) clientRemoveDKIM(ctx context.Context, selector, domain string) error {
315	path, err := url.JoinPath("http://unix/dkim", selector, domain)
316	if err != nil {
317		return fmt.Errorf("failed to create dkim request path: %w", err)
318	}
319	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
320	if err != nil {
321		return fmt.Errorf("failed to create delete dkim request: %w", err)
322	}
323
324	resp, err := c.httpClient.Do(req)
325	if err != nil {
326		return fmt.Errorf("failed to remove dkim: %w", err)
327	}
328	defer model.Wrap(resp.Body.Close, &err, "clientRemoveDKIM closing response body")
329
330	if resp.StatusCode != http.StatusNoContent {
331		return fmt.Errorf("failed to remove dkim: %d", resp.StatusCode)
332	}
333
334	return nil
335}
336
337// clientPruneMails prunes orphaned mails via the daemon.
338func (c *Command) clientPruneMails(_ context.Context) (int, error) {
339	resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil)
340	if err != nil {
341		return 0, fmt.Errorf("failed to prune mails: %w", err)
342	}
343	defer model.Wrap(resp.Body.Close, &err, "clientPruneMails closing response body")
344
345	if resp.StatusCode != http.StatusOK {
346		return 0, fmt.Errorf("failed to prune mails: %d", resp.StatusCode)
347	}
348
349	var result map[string]int
350	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
351		return 0, fmt.Errorf("failed to decode prune response: %w", err)
352	}
353
354	return result["pruned"], nil
355}
356
357// clientKVList fetches all key-value pairs from the daemon.
358func (c *Command) clientKVList(_ context.Context) (map[string]any, error) {
359	resp, err := c.httpClient.Get("http://unix/kv")
360	if err != nil {
361		return nil, fmt.Errorf("failed to list kv: %w", err)
362	}
363	defer model.Wrap(resp.Body.Close, &err, "clientKVList closing response body")
364
365	if resp.StatusCode != http.StatusOK {
366		return nil, fmt.Errorf("failed to list kv: %d", resp.StatusCode)
367	}
368
369	var pairs map[string]any
370	if err := json.NewDecoder(resp.Body).Decode(&pairs); err != nil {
371		return nil, fmt.Errorf("failed to decode kv response: %w", err)
372	}
373
374	return pairs, nil
375}
376
377// clientKVGet fetches the value stored under key from the daemon.
378// The key is URL-encoded so keys containing slashes work.
379func (c *Command) clientKVGet(_ context.Context, key string) (any, error) {
380	path := "http://unix/kv/" + url.PathEscape(key)
381
382	resp, err := c.httpClient.Get(path)
383	if err != nil {
384		return nil, fmt.Errorf("failed to get kv: %w", err)
385	}
386	defer model.Wrap(resp.Body.Close, &err, "clientKVGet closing response body")
387
388	if resp.StatusCode != http.StatusOK {
389		return nil, fmt.Errorf("failed to get kv: %d", resp.StatusCode)
390	}
391
392	var result struct {
393		Value any `json:"value"`
394	}
395	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
396		return nil, fmt.Errorf("failed to decode kv response: %w", err)
397	}
398
399	return result.Value, nil
400}
401
402// clientKVPop removes the value stored under key via the daemon and returns it.
403// The key is URL-encoded so keys containing slashes work.
404func (c *Command) clientKVPop(ctx context.Context, key string) (any, error) {
405	path := "http://unix/kv/" + url.PathEscape(key)
406	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil)
407	if err != nil {
408		return nil, fmt.Errorf("failed to create pop kv request: %w", err)
409	}
410
411	resp, err := c.httpClient.Do(req)
412	if err != nil {
413		return nil, fmt.Errorf("failed to pop kv: %w", err)
414	}
415	defer model.Wrap(resp.Body.Close, &err, "clientKVPop closing response body")
416
417	if resp.StatusCode != http.StatusOK {
418		return nil, fmt.Errorf("failed to pop kv: %d", resp.StatusCode)
419	}
420
421	var result struct {
422		Value any `json:"value"`
423	}
424	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
425		return nil, fmt.Errorf("failed to decode kv response: %w", err)
426	}
427
428	return result.Value, nil
429}