Repository navigation
Expand file tree
/
Copy pathbuilder.go
More file actions
326 lines (288 loc) · 10.9 KB
/
Copy pathbuilder.go
File metadata and controls
326 lines (288 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
package lettermint
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"maps"
"slices"
"time"
)
// Attachment is a file for EmailBuilder.Attach. Set Content to the raw bytes
// (the SDK base64-encodes them) or ContentBase64 to content that is already
// base64-encoded, not both.
type Attachment struct {
Filename string
// Content is the raw file content.
Content []byte
// ContentBase64 is the base64-encoded file content.
ContentBase64 string
// ContentType is the MIME type, for example application/pdf. The API
// detects it when empty.
ContentType string
// ContentID makes the attachment inline: reference it as cid:<ContentID>
// in the HTML body.
ContentID string
}
// EmailBuilder composes an email. Create it with client.Emails.Compose().
//
// EmailBuilder is a value. Every setter returns a new builder and leaves the
// one it was called on unchanged, so a builder can be kept as a template and
// reused, also across goroutines. When you build an email over several
// statements, keep the returned builder:
//
// email := client.Emails.Compose().From("hello@acme.com").To(user.Email).Subject("Your invoice")
// if user.Accountant != "" {
// email = email.CC(user.Accountant)
// }
// resp, err := email.HTML(invoiceHTML).Send(ctx)
//
// A setter that receives invalid input, such as an invalid tag, records the
// error on the builder it returns; Err, Build, BuildBatchItem and Send return
// it, and Send makes no request. The builder it was called on stays valid.
//
// To, CC, BCC, ReplyTo, Headers, Metadata and Tags replace their value;
// Attach adds an attachment. An empty string removes HTML, Text, Tag, Route
// and ScheduledAt.
type EmailBuilder struct {
emails *EmailsService
message SendMailRequest
err error
}
// with returns a copy of the builder with the change applied to a copy of the
// message, then validates the message.
func (b EmailBuilder) with(change func(*SendMailRequest)) EmailBuilder {
next := EmailBuilder{emails: b.emails, message: b.message, err: b.err}
change(&next.message)
if next.err == nil {
next.err = validateMessage(next.message, "")
}
return next
}
// From sets the sender, for example "Acme <hello@acme.com>".
func (b EmailBuilder) From(address string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.From = address })
}
// To replaces the recipients.
func (b EmailBuilder) To(addresses ...string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.To = append([]string{}, addresses...) })
}
// CC replaces the CC recipients.
func (b EmailBuilder) CC(addresses ...string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.CC = slices.Clone(addresses) })
}
// BCC replaces the BCC recipients.
func (b EmailBuilder) BCC(addresses ...string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.BCC = slices.Clone(addresses) })
}
// ReplyTo replaces the Reply-To addresses.
func (b EmailBuilder) ReplyTo(addresses ...string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.ReplyTo = slices.Clone(addresses) })
}
// Subject sets the subject line.
func (b EmailBuilder) Subject(subject string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Subject = subject })
}
// HTML sets the HTML body. An empty string removes it.
func (b EmailBuilder) HTML(html string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.HTML = optionalString(html) })
}
// Text sets the plain-text body. An empty string removes it.
func (b EmailBuilder) Text(text string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Text = optionalString(text) })
}
// Headers replaces the custom email headers.
func (b EmailBuilder) Headers(headers map[string]string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Headers = maps.Clone(headers) })
}
// Metadata replaces the metadata: data tracked with the email and included
// in webhooks, not added as headers.
func (b EmailBuilder) Metadata(metadata map[string]string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Metadata = maps.Clone(metadata) })
}
// Tag sets the legacy single tag. An empty string removes it.
func (b EmailBuilder) Tag(tag string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Tag = optionalString(tag) })
}
// Tags replaces the name/value tags: up to 20, or 19 with a legacy Tag.
// Names match ^[A-Za-z0-9_-]{1,32}$, must be unique and may not start with
// __lettermint; values match ^[A-Za-z0-9_-]{1,64}$.
func (b EmailBuilder) Tags(tags ...MessageTagInput) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Tags = slices.Clone(tags) })
}
// Route sets the slug of the route to send through. An empty string removes it.
func (b EmailBuilder) Route(slug string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Route = optionalPointer(slug) })
}
// ScheduledAt schedules delivery: ISO 8601, or English such as "tomorrow
// 9am". An empty string removes the schedule.
func (b EmailBuilder) ScheduledAt(when string) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.ScheduledAt = optionalPointer(when) })
}
// ScheduledAtTime schedules delivery at a time, sent as ISO 8601 in UTC. The
// zero time removes the schedule.
func (b EmailBuilder) ScheduledAtTime(when time.Time) EmailBuilder {
if when.IsZero() {
return b.ScheduledAt("")
}
return b.ScheduledAt(when.UTC().Format("2006-01-02T15:04:05.000Z07:00"))
}
// Settings sets per-email settings that override the route settings.
func (b EmailBuilder) Settings(settings SendMailRequestSettings) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.Settings = &settings })
}
// SandboxResult sets the result a Sandbox project simulates for every recipient.
func (b EmailBuilder) SandboxResult(result SandboxResult) EmailBuilder {
return b.with(func(m *SendMailRequest) { m.SandboxResult = &result })
}
// Attach adds an attachment.
func (b EmailBuilder) Attach(attachment Attachment) EmailBuilder {
input := MessageAttachmentInput{Filename: attachment.Filename}
var err error
switch {
case attachment.Content != nil && attachment.ContentBase64 != "":
err = &ClientValidationError{Field: "attachments", Message: "set Content or ContentBase64 of an attachment, not both"}
case attachment.Content != nil:
input.Content = base64.StdEncoding.EncodeToString(attachment.Content)
default:
input.Content = attachment.ContentBase64
}
if attachment.ContentType != "" {
input.ContentType = Value(attachment.ContentType)
}
if attachment.ContentID != "" {
input.ContentID = Value(attachment.ContentID)
}
next := b.with(func(m *SendMailRequest) {
m.Attachments = append(slices.Clip(m.Attachments), input)
})
if next.err == nil {
next.err = err
}
return next
}
// Err returns the first error a setter recorded, or nil.
func (b EmailBuilder) Err() error { return b.err }
// Build returns a copy of the message in the API's format, or the error a
// setter recorded.
func (b EmailBuilder) Build() (SendMailRequest, error) {
if b.err != nil {
return SendMailRequest{}, b.err
}
return cloneMessage(b.message), nil
}
// BuildBatchItem returns a copy of the message as a SendBatchMailItem for
// Emails.SendBatchItems, with idempotencyKey (1-255 characters) as its
// per-email idempotency key, or the error a setter recorded. The key is only
// part of the returned item: Send never sends it.
func (b EmailBuilder) BuildBatchItem(idempotencyKey string) (SendBatchMailItem, error) {
if b.err != nil {
return SendBatchMailItem{}, b.err
}
if err := validateBatchIdempotencyKey(idempotencyKey, "idempotency_key"); err != nil {
return SendBatchMailItem{}, err
}
item := batchItem(cloneMessage(b.message))
item.IdempotencyKey = Ptr(idempotencyKey)
return item, nil
}
// Send sends the email. The builder stays unchanged and can be sent again.
func (b EmailBuilder) Send(ctx context.Context, options ...IdempotentOption) (*SendMailResponse, error) {
if b.emails == nil {
return nil, &ConfigError{Message: "create an EmailBuilder with client.Emails.Compose()"}
}
if b.err != nil {
return nil, b.err
}
return b.emails.Send(ctx, cloneMessage(b.message), options...)
}
func (b EmailBuilder) view() view {
return view{name: "EmailBuilder", fields: []viewField{{"Message", messageJSON(b.message)}}}
}
// String shows the message in the API's format. It never contains a token.
func (b EmailBuilder) String() string { return b.view().String() }
// GoString shows the message in the API's format. It never contains a token.
func (b EmailBuilder) GoString() string { return b.view().String() }
// Format shows the message for every verb. It never prints a token.
func (b EmailBuilder) Format(f fmt.State, verb rune) { b.view().format(f, verb) }
// LogValue implements slog.LogValuer without credentials.
func (b EmailBuilder) LogValue() slog.Value { return b.view().logValue() }
// MarshalJSON encodes the message in the API's format.
func (b EmailBuilder) MarshalJSON() ([]byte, error) { return json.Marshal(b.message) }
func messageJSON(message SendMailRequest) string {
data, err := json.Marshal(message)
if err != nil {
return "{}"
}
return string(data)
}
func optionalString(value string) Nullable[string] {
if value == "" {
return Nullable[string]{}
}
return Value(value)
}
func optionalPointer[T ~string](value T) *T {
if value == "" {
return nil
}
return &value
}
// cloneMessage copies a message deeply enough that neither copy can change
// the other: slices and maps are copied.
// batchItem copies every field of a message into a batch item. The batch item
// has the same fields plus IdempotencyKey; a test checks that they stay in
// step when the types are regenerated.
func batchItem(m SendMailRequest) SendBatchMailItem {
return SendBatchMailItem{
Route: m.Route,
From: m.From,
To: m.To,
CC: m.CC,
BCC: m.BCC,
ReplyTo: m.ReplyTo,
Subject: m.Subject,
ScheduledAt: m.ScheduledAt,
SandboxResult: m.SandboxResult,
Headers: m.Headers,
Metadata: m.Metadata,
Tag: m.Tag,
Tags: m.Tags,
Settings: m.Settings,
HTML: m.HTML,
Text: m.Text,
Attachments: m.Attachments,
}
}
func cloneMessage(message SendMailRequest) SendMailRequest {
clone := message
clone.To = slices.Clone(message.To)
clone.CC = slices.Clone(message.CC)
clone.BCC = slices.Clone(message.BCC)
clone.ReplyTo = slices.Clone(message.ReplyTo)
clone.Headers = maps.Clone(message.Headers)
clone.Metadata = maps.Clone(message.Metadata)
clone.Tags = slices.Clone(message.Tags)
clone.Attachments = slices.Clone(message.Attachments)
if message.Settings != nil {
settings := *message.Settings
clone.Settings = &settings
}
if message.Route != nil {
clone.Route = Ptr(*message.Route)
}
if message.ScheduledAt != nil {
clone.ScheduledAt = Ptr(*message.ScheduledAt)
}
if message.SandboxResult != nil {
clone.SandboxResult = Ptr(*message.SandboxResult)
}
return clone
}
// Ptr returns a pointer to v, for optional fields of request types:
//
// lettermint.UpdateProjectData{Name: lettermint.Ptr("Production")}
func Ptr[T any](v T) *T {
return &v
}