-
Notifications
You must be signed in to change notification settings - Fork 49
Expand file tree
/
Copy pathemailop.go
More file actions
258 lines (218 loc) · 10.5 KB
/
Copy pathemailop.go
File metadata and controls
258 lines (218 loc) · 10.5 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
package email
import (
"context"
"encoding/json"
"fmt"
"github.com/samber/lo"
"github.com/theopenlane/newman"
"github.com/theopenlane/newman/render"
"github.com/theopenlane/core/internal/integrations/providerkit"
"github.com/theopenlane/core/internal/integrations/types"
"github.com/theopenlane/core/pkg/logx"
)
// Recipient is implemented by all email operation input types to provide recipient information
type Recipient interface {
// GetRecipient returns the RecipientInfo
GetRecipient() RecipientInfo
}
// Dispatcher describes a catalog-addressable email operation that can be invoked by key
// The implementation owns typed decoding of the payload and projection into render.EmailContent;
// callers supply a raw JSON payload whose shape matches the registered input type
type Dispatcher interface {
// Name returns the catalog key identifying the dispatcher
Name() string
// Registration returns the integration operation registration for this entry
Registration() types.OperationRegistration
// SendByKey decodes the payload into the entry's typed input, runs any registered PreHook,
// and dispatches through the shared render/send pipeline. Additional newman options are
// appended after the operation's own MessageOptions
SendByKey(ctx context.Context, req types.OperationRequest, client *Client, payload json.RawMessage, extraOpts ...newman.MessageOption) error
// RenderMessage decodes the payload and renders the email into a newman message without sending it, for use in batch send paths
RenderMessage(ctx context.Context, client *Client, payload json.RawMessage, extraOpts ...newman.MessageOption) (*newman.EmailMessage, error)
}
// RecipientInfo holds recipient addressing fields embedded in every email operation input
type RecipientInfo struct {
// Email is the recipient email address; it is the primary recipient used for personalization and footer links
Email string `json:"email" jsonschema:"required,description=Recipient email address"`
// Recipients optionally addresses the message to multiple recipients in a single send; when set it replaces Email as the To list
Recipients []string `json:"recipients,omitempty" jsonschema:"description=Recipient email addresses for a single multi-recipient message; replaces the single recipient when set"`
// FirstName is the recipient first name
FirstName string `json:"firstName,omitempty" jsonschema:"description=Recipient first name"`
// LastName is the recipient last name
LastName string `json:"lastName,omitempty" jsonschema:"description=Recipient last name"`
// Tags are delivery tracking tags forwarded to the email provider for webhook correlation
Tags []newman.Tag `json:"tags,omitempty" jsonschema:"description=Delivery tracking tags"`
}
// GetRecipient returns the recipient info, satisfying the Recipient interface
func (r RecipientInfo) GetRecipient() RecipientInfo {
return r
}
// CampaignContext carries campaign-scoped metadata for catalog entries that render campaign-bound
// emails. Entries that need campaign fields embed this struct in their input type; the campaign
// dispatcher populates the JSON overlay before calling SendByKey
type CampaignContext struct {
// CampaignID is the identifier of the campaign producing the send
CampaignID string `json:"campaignId,omitempty" jsonschema:"description=Campaign identifier"`
// CampaignName is the display name of the campaign
CampaignName string `json:"campaignName,omitempty" jsonschema:"description=Campaign display name"`
// CampaignDescription is the long-form description of the campaign
CampaignDescription string `json:"campaignDescription,omitempty" jsonschema:"description=Campaign description"`
// CampaignDueDate is the response deadline for the campaign, formatted for display
CampaignDueDate string `json:"campaignDueDate,omitempty" jsonschema:"description=Campaign response due date"`
}
var (
dispatchers []Dispatcher
dispatcherIndex = map[string]Dispatcher{}
)
// RegisterEmailOperation constructs an Operation[T] and adds it to the dispatcher registry
func RegisterEmailOperation[T Recipient](op Operation[T]) Operation[T] {
dispatchers = append(dispatchers, op)
dispatcherIndex[op.Name()] = op
return op
}
// Operation is a generic helper which defines a single system email type as a registered integration operation
// this allows us to do AllEmailOperations() in the builder rather than manually wiring each
type Operation[T Recipient] struct {
// Op is the typed operation ref with name derived from the schema definition key
Op types.OperationRef[T]
// Schema is the reflected JSON schema for the input type
Schema json.RawMessage
// Description is the human-readable summary shown in the catalog picker
Description string
// CustomerSelectable gates whether the entry is exposed via the customer-facing catalog query
CustomerSelectable *bool
// Subject returns the rendered subject line for the email
Subject func(cfg RuntimeEmailConfig, input T) string
// Theme is the newman render theme applied to this email
Theme *render.Theme
// Build returns the structured body content for newman rendering
Build func(cfg RuntimeEmailConfig, input T) render.ContentBody
// Config is an optional per-op override for the installation config
Config func(cfg RuntimeEmailConfig, input T) RuntimeEmailConfig
// MessageOptions returns additional newman message options for per-operation customization such as attachment
MessageOptions func(cfg RuntimeEmailConfig, input T) []newman.MessageOption
// PreHook is an optional hook invoked before rendering to resolve dynamic fields
PreHook func(ctx context.Context, req types.OperationRequest, input *T) error
}
// Name returns the catalog key for the operation, satisfying the Dispatcher interface
func (e Operation[T]) Name() string {
return e.Op.Name()
}
// decodePayload interpolates template expressions in the raw JSON and unmarshals into T
func decodePayload[T Recipient](client *Client, payload json.RawMessage) (T, error) {
var input T
if len(payload) == 0 {
return input, nil
}
resolved, err := interpolatePayload(client, payload)
if err != nil {
return input, err
}
if err := json.Unmarshal(resolved, &input); err != nil {
return input, fmt.Errorf("%w: %w", ErrTemplateRenderFailed, err)
}
return input, nil
}
// SendByKey decodes the payload into T and dispatches through the shared render pipeline
func (e Operation[T]) SendByKey(ctx context.Context, req types.OperationRequest, client *Client, payload json.RawMessage, extraOpts ...newman.MessageOption) error {
input, err := decodePayload[T](client, payload)
if err != nil {
return err
}
return e.dispatch(ctx, req, client, input, extraOpts...)
}
// RenderMessage decodes the payload and renders the email into a newman message without sending it
func (e Operation[T]) RenderMessage(_ context.Context, client *Client, payload json.RawMessage, extraOpts ...newman.MessageOption) (*newman.EmailMessage, error) {
input, err := decodePayload[T](client, payload)
if err != nil {
return nil, err
}
return e.renderToMessage(client, input, extraOpts...)
}
// dispatch runs PreHook, assembles the per-op newman options, and invokes renderAndSend.
// It is the shared tail of both the operation-framework handler and the catalog-dispatcher
// SendByKey entry points, so the two invocation paths render identically
func (e Operation[T]) dispatch(ctx context.Context, req types.OperationRequest, client *Client, input T, extraOpts ...newman.MessageOption) error {
if e.PreHook != nil {
if err := e.PreHook(ctx, req, &input); err != nil {
return err
}
}
msg, err := e.renderToMessage(client, input, extraOpts...)
if err != nil {
return err
}
if err := client.Sender.SendEmailWithContext(ctx, msg); err != nil {
logx.FromContext(ctx).Error().Err(err).Msg("failed sending email")
return fmt.Errorf("%w: %w", ErrSendFailed, err)
}
return nil
}
// renderToMessage renders the email content and returns a newman message without sending it
func (e Operation[T]) renderToMessage(client *Client, input T, extraOpts ...newman.MessageOption) (*newman.EmailMessage, error) {
var opts []newman.MessageOption
if e.MessageOptions != nil {
opts = e.MessageOptions(client.Config, input)
}
recipient := input.GetRecipient()
for _, tag := range recipient.Tags {
opts = append(opts, newman.WithTag(tag))
}
opts = append(opts, extraOpts...)
content := render.EmailContent{
Request: input,
Config: client.Config,
Body: e.Build(client.Config, input),
}
if e.Config != nil {
content.Config = e.Config(client.Config, input)
}
return renderMessage(client, e.Theme, recipient, e.Subject(client.Config, input), content, opts...)
}
// Registration returns the types.OperationRegistration for wiring into the definition builder.
// Catalog-facing fields (Description, CustomerSelectable) travel on the registration
// so downstream filters (e.g. customer-facing catalog query) can operate on AllEmailOperations directly
func (e Operation[T]) Registration() types.OperationRegistration {
return types.OperationRegistration{
Name: e.Op.Name(),
Description: e.Description,
Topic: DefinitionID.OperationTopic(e.Op.Name()),
ClientRef: emailClientRef.ID(),
ConfigSchema: e.Schema,
CustomerSelectable: lo.ToPtr(e.CustomerSelectable != nil && *e.CustomerSelectable),
Handle: e.handler(),
}
}
// handler returns the typed operation handler that renders and sends the email
func (e Operation[T]) handler() types.OperationHandler {
return providerkit.WithClientRequestConfig(emailClientRef, e.Op, ErrTemplateRenderFailed,
func(ctx context.Context, req types.OperationRequest, client *Client, input T) (json.RawMessage, error) {
return nil, e.dispatch(ctx, req, client, input)
},
)
}
// renderMessage renders an email into a newman message without sending it
func renderMessage(client *Client, theme *render.Theme, recipient RecipientInfo, subject string, content render.EmailContent, extraOpts ...newman.MessageOption) (*newman.EmailMessage, error) {
r := render.NewRenderer(render.WithTheme(theme))
htmlBody, err := r.GenerateHTML(content)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTemplateRenderFailed, err)
}
textBody, err := r.GeneratePlainText(content)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTemplateRenderFailed, err)
}
to := recipient.Recipients
if len(to) == 0 {
to = []string{recipient.Email}
}
opts := []newman.MessageOption{
newman.WithFrom(client.Config.FromEmail),
newman.WithTo(to),
newman.WithSubject(subject),
newman.WithHTML(htmlBody),
newman.WithText(textBody),
}
opts = append(opts, extraOpts...)
return newman.NewEmailMessageWithOptions(opts...), nil
}