/
gmail.go
393 lines (346 loc) · 11.1 KB
/
gmail.go
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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
// Package gmail is a wrapper Go library for working with the Google gmail API.
// It is in active development and makes no guarantees about API stability. The long-term goal is to create a standard mail client interface we can use across all email clients, like Gmail and Outlook.
package gmail
import (
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
srclabel "github.com/shared-recruiting-co/shared-recruiting-co/libs/gmail/label"
)
// scopes required for the SRC service
var scopes = []string{
gmail.GmailModifyScope,
}
var (
// senderEmailRe is a regex to extract the email address from a sender string (i.e. "Jo Smo <jo.smo@gmail.com>")
senderEmailRe = regexp.MustCompile(`<([^>]+)>`)
)
func newGmailService(ctx context.Context, creds []byte, auth []byte) (*gmail.Service, error) {
tok := &oauth2.Token{}
err := json.Unmarshal(auth, tok)
config, err := google.ConfigFromJSON(creds, scopes...)
if err != nil {
return nil, err
}
client := config.Client(ctx, tok)
return gmail.NewService(ctx, option.WithHTTPClient(client))
}
// newDefaultGmailService creates a new gmail service with default credentials on the local machine.
// The default credentials for service accounts do not have a client secret, so they will not auto-refresh the token.
// The auth token must be valid for the lifetime of the service.
func newDefaultGmailService(ctx context.Context, auth []byte) (*gmail.Service, error) {
tok := &oauth2.Token{}
err := json.Unmarshal(auth, tok)
if err != nil {
return nil, err
}
ts, err := google.DefaultTokenSource(ctx, scopes...)
trans := &oauth2.Transport{
Source: oauth2.ReuseTokenSource(tok, ts),
Base: http.DefaultTransport,
}
httpClient := &http.Client{Transport: trans}
return gmail.NewService(ctx, option.WithHTTPClient(httpClient))
}
// Service is a wrapper around the gmail service that adds some convenience methods.
type Service struct {
*gmail.Service
UserID string
profile *gmail.Profile
}
// Create
func NewService(ctx context.Context, creds []byte, auth []byte) (*Service, error) {
srv, err := newGmailService(ctx, creds, auth)
if err != nil {
return nil, err
}
return &Service{Service: srv, UserID: "me"}, nil
}
func NewDefaultService(ctx context.Context, auth []byte) (*Service, error) {
srv, err := newDefaultGmailService(ctx, auth)
if err != nil {
return nil, err
}
// only set UserID to 'me' for now until we have a use-case for non-authenticated users
return &Service{Service: srv, UserID: "me"}, nil
}
func (s *Service) Profile() (*gmail.Profile, error) {
// cache the profile
if s.profile != nil {
return s.profile, nil
}
profile, err := ExecuteWithRetries(func() (*gmail.Profile, error) {
return s.Users.GetProfile(s.UserID).Do()
})
if err != nil {
return nil, err
}
s.profile = profile
return profile, nil
}
// Labels
func (s *Service) ListLabels() (*gmail.ListLabelsResponse, error) {
return ExecuteWithRetries(func() (*gmail.ListLabelsResponse, error) {
return s.Users.Labels.List(s.UserID).Do()
})
}
func (s *Service) GetOrCreateLabel(l *gmail.Label) (*gmail.Label, error) {
labels, err := s.ListLabels()
if err != nil {
return nil, err
}
for _, label := range labels.Labels {
if label.Name == l.Name {
return label, err
}
}
return s.CreateLabel(l)
}
func (s *Service) CreateLabel(l *gmail.Label) (*gmail.Label, error) {
return ExecuteWithRetries(func() (*gmail.Label, error) {
return s.Users.Labels.Create(s.UserID, l).Do()
})
}
// GetOrCreateSRCLabels fetches or creates all of the labels managed by SRC
// Label IDs are unique to each gmail account, so we need to list all labels and match based off names.
func (s *Service) GetOrCreateSRCLabels() (*srclabel.Labels, error) {
// TODO: What is a better data structure or interface to make this function more DRY?
// list all labels
labels, err := s.ListLabels()
if err != nil {
return nil, err
}
result := srclabel.Labels{}
// for each label,
// if it exists, add it to the struct
// if it doesn't exist, create it and add it to the struct
for _, label := range labels.Labels {
switch label.Name {
case srclabel.SRC.Name:
result.SRC = label
break
case srclabel.Jobs.Name:
result.Jobs = label
break
case srclabel.JobsOpportunity.Name:
result.JobsOpportunity = label
break
case srclabel.Allow.Name:
result.Allow = label
break
case srclabel.AllowSender.Name:
result.AllowSender = label
break
case srclabel.AllowDomain.Name:
result.AllowDomain = label
break
case srclabel.Block.Name:
result.Block = label
break
case srclabel.BlockSender.Name:
result.BlockSender = label
break
case srclabel.BlockDomain.Name:
result.BlockDomain = label
break
case srclabel.BlockGraveyard.Name:
result.BlockGraveyard = label
break
}
}
// create any labels that don't exist
if result.SRC == nil {
result.SRC, err = s.CreateLabel(&srclabel.SRC)
if err != nil {
return nil, err
}
}
if result.Jobs == nil {
result.Jobs, err = s.CreateLabel(&srclabel.Jobs)
if err != nil {
return nil, err
}
}
if result.JobsOpportunity == nil {
result.JobsOpportunity, err = s.CreateLabel(&srclabel.JobsOpportunity)
if err != nil {
return nil, err
}
}
if result.Allow == nil {
result.Allow, err = s.CreateLabel(&srclabel.Allow)
if err != nil {
return nil, err
}
}
if result.AllowSender == nil {
result.AllowSender, err = s.CreateLabel(&srclabel.AllowSender)
if err != nil {
return nil, err
}
}
if result.AllowDomain == nil {
result.AllowDomain, err = s.CreateLabel(&srclabel.AllowDomain)
if err != nil {
return nil, err
}
}
if result.Block == nil {
result.Block, err = s.CreateLabel(&srclabel.Block)
if err != nil {
return nil, err
}
}
if result.BlockSender == nil {
result.BlockSender, err = s.CreateLabel(&srclabel.BlockSender)
if err != nil {
return nil, err
}
}
if result.BlockDomain == nil {
result.BlockDomain, err = s.CreateLabel(&srclabel.BlockDomain)
if err != nil {
return nil, err
}
}
if result.BlockGraveyard == nil {
result.BlockGraveyard, err = s.CreateLabel(&srclabel.BlockGraveyard)
if err != nil {
return nil, err
}
}
return &result, nil
}
// IsSenderAllowed checks if message is on the user's allow list
func (s *Service) IsSenderAllowed(sender string) (bool, error) {
// if there is a sender display name ("John Doe <john.doe@gmail.com>")
// extract the email address
if senderEmailRe.MatchString(sender) {
sender = senderEmailRe.FindStringSubmatch(sender)[1]
}
// check if sender is allowed
// leverage Gmail's native query engine to check
q := fmt.Sprintf("from:%s label:%s", sender, srclabel.AllowSender.Name)
resp, err := ExecuteWithRetries(func() (*gmail.ListMessagesResponse, error) {
return s.Users.Messages.List(s.UserID).Q(q).Do()
})
if err != nil {
return false, err
}
if len(resp.Messages) > 0 {
return true, nil
}
// check if sender's email domain is allowed
parts := strings.SplitAfter(sender, "@")
if len(parts) != 2 {
return false, fmt.Errorf("error allowing message: unable to parse domain from sender: %s", sender)
}
// remove name related characters (i.e Jo Smo <joe@smo.com>)
domain := strings.ReplaceAll("@"+parts[1], ">", "")
q = fmt.Sprintf("from:%s label:%s", domain, srclabel.AllowDomain.Name)
resp, err = ExecuteWithRetries(func() (*gmail.ListMessagesResponse, error) {
return s.Users.Messages.List(s.UserID).Q(q).Do()
})
return len(resp.Messages) > 0, err
}
// IsSenderBlocked checks if message is on the user's block list
func (s *Service) IsSenderBlocked(sender string) (bool, error) {
// if there is a sender display name ("John Doe <john.doe@gmail.com>")
// extract the email address
if senderEmailRe.MatchString(sender) {
sender = senderEmailRe.FindStringSubmatch(sender)[1]
}
// check if sender is blocked
// leverage Gmail's native query engine to check
q := fmt.Sprintf("from:%s label:%s", sender, srclabel.BlockSender.Name)
resp, err := ExecuteWithRetries(func() (*gmail.ListMessagesResponse, error) {
return s.Users.Messages.List(s.UserID).Q(q).Do()
})
if err != nil {
return false, err
}
if len(resp.Messages) > 0 {
return true, nil
}
// check if sender's email domain is blocked
parts := strings.SplitAfter(sender, "@")
if len(parts) != 2 {
return false, fmt.Errorf("error blocking message: unable to parse domain from sender: %s", sender)
}
// remove name related characters (i.e Jo Smo <joe@smo.com>)
domain := strings.ReplaceAll("@"+parts[1], ">", "")
q = fmt.Sprintf("from:%s label:%s", domain, srclabel.BlockDomain.Name)
resp, err = ExecuteWithRetries(func() (*gmail.ListMessagesResponse, error) {
return s.Users.Messages.List(s.UserID).Q(q).Do()
})
return len(resp.Messages) > 0, err
}
// Messages
// BlockMessage blocks a message by moving moving out of the users inbox and into the block graveyard
func (s *Service) BlockMessage(id string, labels *srclabel.Labels) error {
_, err := ExecuteWithRetries(func() (*gmail.Message, error) {
return s.Users.Messages.Modify(s.UserID, id, &gmail.ModifyMessageRequest{
AddLabelIds: []string{labels.SRC.Id, labels.Block.Id, labels.BlockGraveyard.Id},
RemoveLabelIds: []string{"UNREAD", "INBOX"},
}).Do()
})
return err
}
// ForwardEmail is a good enough implementation of forwarding an email in the same format as the gmail client.
// It is good enough because it doesn't naively handle HTML mime-type content or when there are multiple parent messages.
// This is sufficient for our purposes.
func (s *Service) ForwardEmail(messageID, to string) (*gmail.Message, error) {
f := func() (*gmail.Message, error) {
// 1. get the original message
parent, err := s.GetMessage(messageID)
if err != nil {
return nil, err
}
// 2. Get the current user's email address
profile, err := s.Profile()
if err != nil {
return nil, err
}
// 3. Create the forwarded message
fwd := ForwardMessage{
Sender: profile.EmailAddress,
To: to,
Parent: parent,
}
// send the message
return s.Users.Messages.Send(s.UserID, fwd.Create()).Do()
}
return ExecuteWithRetries(f)
}
// GetMessage() fetches a message by ID
// It is a convenience method that wraps the gmail API call.
// It opens opportunities for caching and rate-limiting handling
func (s *Service) GetMessage(id string) (*gmail.Message, error) {
f := func() (*gmail.Message, error) {
return s.Users.Messages.Get(s.UserID, id).Do()
}
return ExecuteWithRetries(f)
}
// Threads
// GetThread() fetches a thread by ID with messages in the given format
// See https://developers.google.com/gmail/api/reference/rest/v1/users.threads/get#Format for format values
//
// It is a convenience method that wraps the gmail API call
// It opens opportunities for caching and rate-limiting handling
func (s *Service) GetThread(id, format string) (*gmail.Thread, error) {
f := func() (*gmail.Thread, error) {
return s.Users.Threads.Get(s.UserID, id).Format(format).Do()
}
return ExecuteWithRetries(f)
}
// Ideas for future
// ListMessages()
// cache GetMessage calls until next ListMessages call
// Something to help with setting the default query for listing src and non-src messages