1package webapi
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "strings"
12)
13
14// Client can be used to call webapi methods.
15// Client implements [Methods].
16type Client struct {
17 BaseURL string // For example: http://localhost:1080/webapi/v0/.
18 Username string // Added as HTTP basic authentication if not empty.
19 Password string
20 HTTPClient *http.Client // Optional, defaults to http.DefaultClient.
21 Logger *slog.Logger // Optional, defaults to slog.Default().
22}
23
24var _ Methods = Client{}
25
26func (c Client) httpClient() *http.Client {
27 if c.HTTPClient != nil {
28 return c.HTTPClient
29 }
30 return http.DefaultClient
31}
32
33func (c Client) log(ctx context.Context) *slog.Logger {
34 if c.Logger != nil {
35 return c.Logger
36 }
37 return slog.Default()
38}
39
40func transact[T any](ctx context.Context, c Client, fn string, req any) (resp T, rerr error) {
41 hresp, err := httpDo(ctx, c, fn, req)
42 if err != nil {
43 return resp, err
44 }
45 defer func() {
46 err := hresp.Body.Close()
47 if err != nil {
48 c.log(ctx).Debug("closing http response body", slog.Any("err", err))
49 }
50 }()
51
52 if hresp.StatusCode == http.StatusOK {
53 // Text and HTML of a message can each be 1MB. Another MB for other data would be a
54 // lot.
55 err := json.NewDecoder(&limitReader{hresp.Body, 3 * 1024 * 1024}).Decode(&resp)
56 return resp, err
57 }
58 return resp, badResponse(hresp)
59}
60
61func transactReadCloser(ctx context.Context, c Client, fn string, req any) (resp io.ReadCloser, rerr error) {
62 hresp, err := httpDo(ctx, c, fn, req)
63 if err != nil {
64 return nil, err
65 }
66 body := hresp.Body
67 defer func() {
68 if body != nil {
69 err := body.Close()
70 if err != nil {
71 c.log(ctx).Debug("closing http response body", slog.Any("err", err))
72 }
73 }
74 }()
75 if hresp.StatusCode == http.StatusOK {
76 r := body
77 body = nil
78 return r, nil
79 }
80 return nil, badResponse(hresp)
81}
82
83func httpDo(ctx context.Context, c Client, fn string, req any) (*http.Response, error) {
84 reqbuf, err := json.Marshal(req)
85 if err != nil {
86 return nil, fmt.Errorf("marshal request: %v", err)
87 }
88 data := url.Values{}
89 data.Add("request", string(reqbuf))
90 hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+fn, strings.NewReader(data.Encode()))
91 if err != nil {
92 return nil, fmt.Errorf("new request: %v", err)
93 }
94 hreq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
95 if c.Username != "" {
96 hreq.SetBasicAuth(c.Username, c.Password)
97 }
98 hresp, err := c.httpClient().Do(hreq)
99 if err != nil {
100 return nil, fmt.Errorf("http transaction: %v", err)
101 }
102 return hresp, nil
103}
104
105func badResponse(hresp *http.Response) error {
106 if hresp.StatusCode != http.StatusBadRequest {
107 return fmt.Errorf("http status %v, expected 200 ok", hresp.Status)
108 }
109 buf, err := io.ReadAll(&limitReader{R: hresp.Body, Limit: 10 * 1024})
110 if err != nil {
111 return fmt.Errorf("reading error from remote: %v", err)
112 }
113 var xerr Error
114 err = json.Unmarshal(buf, &xerr)
115 if err != nil {
116 if len(buf) > 512 {
117 buf = buf[:512]
118 }
119 return fmt.Errorf("error parsing error from remote: %v (first 512 bytes of response: %s)", err, string(buf))
120 }
121 return xerr
122}
123
124// Send composes a message and submits it to the queue for delivery for all
125// recipients (to, cc, bcc).
126//
127// Configure your account to use unique SMTP MAIL FROM addresses ("fromid") and to
128// keep history of retired messages, for better handling of transactional email,
129// automatically managing a suppression list.
130//
131// Configure webhooks to receive updates about deliveries.
132//
133// If the request is a multipart/form-data, uploaded files with the form keys
134// "alternativefile", "inlinefile" and/or "attachedfile" will be added to the
135// message. If the uploaded file has content-type and/or content-id headers, they
136// will be included. If no content-type is present in the request, and it can be
137// detected, it is included automatically.
138//
139// Example call with a text and html message, with an inline and an attached image:
140//
141// curl --user mox@localhost:moxmoxmox \
142// --form request='{"To": [{"Address": "mox@localhost"}], "Text": "hi ☺", "HTML": "<img src=\"cid:hi\" />"}' \
143// --form 'inlinefile=@hi.png;headers="Content-ID: <hi>"' \
144// --form attachedfile=@mox.png \
145// http://localhost:1080/webapi/v0/Send
146//
147// Error codes:
148//
149// - badAddress, if an email address is invalid.
150// - missingBody, if no text and no html body was specified.
151// - multipleFrom, if multiple from addresses were specified.
152// - badFrom, if a from address was specified that isn't configured for the account.
153// - noRecipients, if no recipients were specified.
154// - messageLimitReached, if the outgoing message rate limit was reached.
155// - recipientLimitReached, if the outgoing new recipient rate limit was reached.
156// - messageTooLarge, message larger than configured maximum size.
157// - malformedMessageID, if MessageID is specified but invalid.
158// - sentOverQuota, message submitted, but not stored in Sent mailbox due to quota reached.
159func (c Client) Send(ctx context.Context, req SendRequest) (resp SendResult, err error) {
160 return transact[SendResult](ctx, c, "Send", req)
161}
162
163// SuppressionList returns the addresses on the per-account suppression list.
164func (c Client) SuppressionList(ctx context.Context, req SuppressionListRequest) (resp SuppressionListResult, err error) {
165 return transact[SuppressionListResult](ctx, c, "SuppressionList", req)
166}
167
168// SuppressionAdd adds an address to the suppression list of the account.
169//
170// Error codes:
171//
172// - badAddress, if the email address is invalid.
173func (c Client) SuppressionAdd(ctx context.Context, req SuppressionAddRequest) (resp SuppressionAddResult, err error) {
174 return transact[SuppressionAddResult](ctx, c, "SuppressionAdd", req)
175}
176
177// SuppressionRemove removes an address from the suppression list of the account.
178//
179// Error codes:
180//
181// - badAddress, if the email address is invalid.
182func (c Client) SuppressionRemove(ctx context.Context, req SuppressionRemoveRequest) (resp SuppressionRemoveResult, err error) {
183 return transact[SuppressionRemoveResult](ctx, c, "SuppressionRemove", req)
184}
185
186// SuppressionPresent returns whether an address is present in the suppression list of the account.
187//
188// Error codes:
189//
190// - badAddress, if the email address is invalid.
191func (c Client) SuppressionPresent(ctx context.Context, req SuppressionPresentRequest) (resp SuppressionPresentResult, err error) {
192 return transact[SuppressionPresentResult](ctx, c, "SuppressionPresent", req)
193}
194
195// MessageGet returns a message from the account storage in parsed form.
196//
197// Use [Client.MessageRawGet] for the raw message (internet message file).
198//
199// Error codes:
200// - messageNotFound, if the message does not exist.
201func (c Client) MessageGet(ctx context.Context, req MessageGetRequest) (resp MessageGetResult, err error) {
202 return transact[MessageGetResult](ctx, c, "MessageGet", req)
203}
204
205// MessageRawGet returns the full message in its original form, as stored on disk.
206//
207// Error codes:
208// - messageNotFound, if the message does not exist.
209func (c Client) MessageRawGet(ctx context.Context, req MessageRawGetRequest) (resp io.ReadCloser, err error) {
210 return transactReadCloser(ctx, c, "MessageRawGet", req)
211}
212
213// MessagePartGet returns a single part from a multipart message, by a "parts
214// path", a series of indices into the multipart hierarchy as seen in the parsed
215// message. The initial selection is the body of the outer message (excluding
216// headers).
217//
218// Error codes:
219// - messageNotFound, if the message does not exist.
220// - partNotFound, if the part does not exist.
221func (c Client) MessagePartGet(ctx context.Context, req MessagePartGetRequest) (resp io.ReadCloser, err error) {
222 return transactReadCloser(ctx, c, "MessagePartGet", req)
223}
224
225// MessageDelete permanently removes a message from the account storage (not moving
226// to a Trash folder).
227//
228// Error codes:
229// - messageNotFound, if the message does not exist.
230func (c Client) MessageDelete(ctx context.Context, req MessageDeleteRequest) (resp MessageDeleteResult, err error) {
231 return transact[MessageDeleteResult](ctx, c, "MessageDelete", req)
232}
233
234// MessageFlagsAdd adds (sets) flags on a message, like the well-known flags
235// beginning with a backslash like \seen, \answered, \draft, or well-known flags
236// beginning with a dollar like $junk, $notjunk, $forwarded, or custom flags.
237// Existing flags are left unchanged.
238//
239// Error codes:
240// - messageNotFound, if the message does not exist.
241func (c Client) MessageFlagsAdd(ctx context.Context, req MessageFlagsAddRequest) (resp MessageFlagsAddResult, err error) {
242 return transact[MessageFlagsAddResult](ctx, c, "MessageFlagsAdd", req)
243}
244
245// MessageFlagsRemove removes (clears) flags on a message.
246// Other flags are left unchanged.
247//
248// Error codes:
249// - messageNotFound, if the message does not exist.
250func (c Client) MessageFlagsRemove(ctx context.Context, req MessageFlagsRemoveRequest) (resp MessageFlagsRemoveResult, err error) {
251 return transact[MessageFlagsRemoveResult](ctx, c, "MessageFlagsRemove", req)
252}
253
254// MessageMove moves a message to a new mailbox name (folder). The destination
255// mailbox name must already exist.
256//
257// Error codes:
258// - messageNotFound, if the message does not exist.
259func (c Client) MessageMove(ctx context.Context, req MessageMoveRequest) (resp MessageMoveResult, err error) {
260 return transact[MessageMoveResult](ctx, c, "MessageMove", req)
261}
262