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