~ci-train-bot/account-polld/account-polld-ubuntu-yakkety-landing-055

« back to all changes in this revision

Viewing changes to plugins/dekko/dekko.go

  • Committer: Bileto Bot
  • Author(s): Alberto Mardegan
  • Date: 2016-07-27 13:38:54 UTC
  • mfrom: (166.3.4 dekko-gmail)
  • Revision ID: ci-train-bot@canonical.com-20160727133854-ytiair2scgj5rf9u
Add Dekko GMail plugin (LP: #1421923)

Approved by: Jonas G. Drange, system-apps-ci-bot

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
/*
 
2
 Copyright 2014 Canonical Ltd.
 
3
 
 
4
 This program is free software: you can redistribute it and/or modify it
 
5
 under the terms of the GNU General Public License version 3, as published
 
6
 by the Free Software Foundation.
 
7
 
 
8
 This program is distributed in the hope that it will be useful, but
 
9
 WITHOUT ANY WARRANTY; without even the implied warranties of
 
10
 MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
 
11
 PURPOSE.  See the GNU General Public License for more details.
 
12
 
 
13
 You should have received a copy of the GNU General Public License along
 
14
 with this program.  If not, see <http://www.gnu.org/licenses/>.
 
15
*/
 
16
 
 
17
package dekko
 
18
 
 
19
import (
 
20
        "encoding/json"
 
21
        "fmt"
 
22
        "net/http"
 
23
        "net/mail"
 
24
        "net/url"
 
25
        "os"
 
26
        "regexp"
 
27
        "sort"
 
28
        "strings"
 
29
        "time"
 
30
 
 
31
        "log"
 
32
 
 
33
        "launchpad.net/account-polld/accounts"
 
34
        "launchpad.net/account-polld/gettext"
 
35
        "launchpad.net/account-polld/plugins"
 
36
        "launchpad.net/account-polld/qtcontact"
 
37
)
 
38
 
 
39
const (
 
40
        APP_ID           = "dekko.dekkoproject_dekko"
 
41
        dekkoDispatchUrl = "dekko://notify/%d/%s/%s"
 
42
        // If there's more than 10 emails in one batch, we don't show 10 notification
 
43
        // bubbles, but instead show one summary. We always show all notifications in the
 
44
        // indicator.
 
45
        individualNotificationsLimit = 10
 
46
        pluginName                   = "dekko"
 
47
)
 
48
 
 
49
type reportedIdMap map[string]time.Time
 
50
 
 
51
var baseUrl, _ = url.Parse("https://www.googleapis.com/gmail/v1/users/me/")
 
52
 
 
53
// timeDelta defines how old messages can be to be reported.
 
54
var timeDelta = time.Duration(time.Hour * 24)
 
55
 
 
56
// trackDelta defines how old messages can be before removed from tracking
 
57
var trackDelta = time.Duration(time.Hour * 24 * 7)
 
58
 
 
59
// relativeTimeDelta is the same as timeDelta
 
60
var relativeTimeDelta string = "1d"
 
61
 
 
62
// regexp for identifying non-ascii characters
 
63
var nonAsciiChars, _ = regexp.Compile("[^\x00-\x7F]")
 
64
 
 
65
type GmailPlugin struct {
 
66
        // reportedIds holds the messages that have already been notified. This
 
67
        // approach is taken against timestamps as it avoids needing to call
 
68
        // get on the message.
 
69
        reportedIds reportedIdMap
 
70
        accountId   uint
 
71
}
 
72
 
 
73
func idsFromPersist(accountId uint) (ids reportedIdMap, err error) {
 
74
        err = plugins.FromPersist(pluginName, accountId, &ids)
 
75
        if err != nil {
 
76
                return nil, err
 
77
        }
 
78
        // discard old ids
 
79
        timestamp := time.Now()
 
80
        for k, v := range ids {
 
81
                delta := timestamp.Sub(v)
 
82
                if delta > trackDelta {
 
83
                        log.Print("gmail plugin ", accountId, ": deleting ", k, " as ", delta, " is greater than ", trackDelta)
 
84
                        delete(ids, k)
 
85
                }
 
86
        }
 
87
        return ids, nil
 
88
}
 
89
 
 
90
func (ids reportedIdMap) persist(accountId uint) (err error) {
 
91
        err = plugins.Persist(pluginName, accountId, ids)
 
92
        if err != nil {
 
93
                log.Print("gmail plugin ", accountId, ": failed to save state: ", err)
 
94
        }
 
95
        return nil
 
96
}
 
97
 
 
98
func New(accountId uint) *GmailPlugin {
 
99
        reportedIds, err := idsFromPersist(accountId)
 
100
        if err != nil {
 
101
                log.Print("gmail plugin ", accountId, ": cannot load previous state from storage: ", err)
 
102
        } else {
 
103
                log.Print("gmail plugin ", accountId, ": last state loaded from storage")
 
104
        }
 
105
        return &GmailPlugin{reportedIds: reportedIds, accountId: accountId}
 
106
}
 
107
 
 
108
func (p *GmailPlugin) ApplicationId() plugins.ApplicationId {
 
109
        return plugins.ApplicationId(APP_ID)
 
110
}
 
111
 
 
112
func (p *GmailPlugin) Poll(authData *accounts.AuthData) ([]*plugins.PushMessageBatch, error) {
 
113
        // This envvar check is to ease testing.
 
114
        if token := os.Getenv("ACCOUNT_POLLD_TOKEN_GMAIL"); token != "" {
 
115
                authData.AccessToken = token
 
116
        }
 
117
 
 
118
        resp, err := p.requestMessageList(authData.AccessToken)
 
119
        if err != nil {
 
120
                return nil, err
 
121
        }
 
122
        messages, err := p.parseMessageListResponse(resp)
 
123
        if err != nil {
 
124
                return nil, err
 
125
        }
 
126
 
 
127
        // TODO use the batching API defined in https://developers.google.com/gmail/api/guides/batch
 
128
        for i := range messages {
 
129
                resp, err := p.requestMessage(messages[i].Id, authData.AccessToken)
 
130
                if err != nil {
 
131
                        return nil, err
 
132
                }
 
133
                messages[i], err = p.parseMessageResponse(resp)
 
134
                if err != nil {
 
135
                        return nil, err
 
136
                }
 
137
        }
 
138
        notif, err := p.createNotifications(messages)
 
139
        if err != nil {
 
140
                return nil, err
 
141
        }
 
142
        return []*plugins.PushMessageBatch{
 
143
                &plugins.PushMessageBatch{
 
144
                        Messages:        notif,
 
145
                        Limit:           individualNotificationsLimit,
 
146
                        OverflowHandler: p.handleOverflow,
 
147
                        Tag:             "dekko",
 
148
                }}, nil
 
149
 
 
150
}
 
151
 
 
152
func (p *GmailPlugin) reported(id string) bool {
 
153
        _, ok := p.reportedIds[id]
 
154
        return ok
 
155
}
 
156
 
 
157
func (p *GmailPlugin) createNotifications(messages []message) ([]*plugins.PushMessage, error) {
 
158
        timestamp := time.Now()
 
159
        pushMsgMap := make(pushes)
 
160
 
 
161
        for _, msg := range messages {
 
162
                hdr := msg.Payload.mapHeaders()
 
163
 
 
164
                from := hdr[hdrFROM]
 
165
                var avatarPath string
 
166
 
 
167
                emailAddress, err := mail.ParseAddress(from)
 
168
                if err != nil {
 
169
                        // If the email address contains non-ascii characters, we get an
 
170
                        // error so we're going to try again, this time mangling the name
 
171
                        // by removing all non-ascii characters. We only care about the email
 
172
                        // address here anyway.
 
173
                        // XXX: We can't check the error message due to [1]: the error
 
174
                        // message is different in go < 1.3 and > 1.5.
 
175
                        // [1] https://github.com/golang/go/issues/12492
 
176
                        mangledAddr := nonAsciiChars.ReplaceAllString(from, "")
 
177
                        mangledEmail, mangledParseError := mail.ParseAddress(mangledAddr)
 
178
                        if mangledParseError == nil {
 
179
                                emailAddress = mangledEmail
 
180
                        }
 
181
                } else if emailAddress.Name != "" {
 
182
                        // We only want the Name if the first ParseAddress
 
183
                        // call was successful. I.e. we do not want the name
 
184
                        // from a mangled email address.
 
185
                        from = emailAddress.Name
 
186
                }
 
187
 
 
188
                if emailAddress != nil {
 
189
                        avatarPath = qtcontact.GetAvatar(emailAddress.Address)
 
190
                        // If icon path starts with a path separator, assume local file path,
 
191
                        // encode it and prepend file scheme defined in RFC 1738.
 
192
                        if strings.HasPrefix(avatarPath, string(os.PathSeparator)) {
 
193
                                avatarPath = url.QueryEscape(avatarPath)
 
194
                                avatarPath = "file://" + avatarPath
 
195
                        }
 
196
                }
 
197
 
 
198
                msgStamp := hdr.getTimestamp()
 
199
 
 
200
                if _, ok := pushMsgMap[msg.ThreadId]; ok {
 
201
                        // TRANSLATORS: the %s is an appended "from" corresponding to an specific email thread
 
202
                        pushMsgMap[msg.ThreadId].Notification.Card.Summary += fmt.Sprintf(gettext.Gettext(", %s"), from)
 
203
                } else if timestamp.Sub(msgStamp) < timeDelta {
 
204
                        // TRANSLATORS: the %s is the "from" header corresponding to a specific email
 
205
                        summary := fmt.Sprintf(gettext.Gettext("%s"), from)
 
206
                        // TRANSLATORS: the first %s refers to the email "subject", the second %s refers "from"
 
207
                        body := fmt.Sprintf(gettext.Gettext("%s\n%s"), hdr[hdrSUBJECT], msg.Snippet)
 
208
                        // fmt with label personal and threadId
 
209
                        action := fmt.Sprintf(dekkoDispatchUrl, p.accountId, "INBOX", msg.Id)
 
210
                        epoch := hdr.getEpoch()
 
211
                        pushMsgMap[msg.ThreadId] = plugins.NewStandardPushMessage(summary, body, action, avatarPath, epoch)
 
212
                } else {
 
213
                        log.Print("gmail plugin ", p.accountId, ": skipping message id ", msg.Id, " with date ", msgStamp, " older than ", timeDelta)
 
214
                }
 
215
        }
 
216
        pushMsg := make([]*plugins.PushMessage, 0, len(pushMsgMap))
 
217
        for _, v := range pushMsgMap {
 
218
                pushMsg = append(pushMsg, v)
 
219
        }
 
220
        return pushMsg, nil
 
221
 
 
222
}
 
223
func (p *GmailPlugin) handleOverflow(pushMsg []*plugins.PushMessage) *plugins.PushMessage {
 
224
        // TODO it would probably be better to grab the estimate that google returns in the message list.
 
225
        approxUnreadMessages := len(pushMsg)
 
226
 
 
227
        // TRANSLATORS: the %d refers to the number of new email messages.
 
228
        summary := fmt.Sprintf(gettext.Gettext("You have %d new messages"), approxUnreadMessages)
 
229
 
 
230
        body := ""
 
231
 
 
232
        // fmt with label personal and no threadId
 
233
        action := fmt.Sprintf(dekkoDispatchUrl, p.accountId, "INBOX")
 
234
        epoch := time.Now().Unix()
 
235
 
 
236
        return plugins.NewStandardPushMessage(summary, body, action, "", epoch)
 
237
}
 
238
 
 
239
func (p *GmailPlugin) parseMessageListResponse(resp *http.Response) ([]message, error) {
 
240
        defer resp.Body.Close()
 
241
        decoder := json.NewDecoder(resp.Body)
 
242
 
 
243
        if resp.StatusCode != http.StatusOK {
 
244
                var errResp errorResp
 
245
                if err := decoder.Decode(&errResp); err != nil {
 
246
                        return nil, err
 
247
                }
 
248
                if errResp.Err.Code == 401 {
 
249
                        return nil, plugins.ErrTokenExpired
 
250
                }
 
251
                return nil, &errResp
 
252
        }
 
253
 
 
254
        var messages messageList
 
255
        if err := decoder.Decode(&messages); err != nil {
 
256
                return nil, err
 
257
        }
 
258
 
 
259
        filteredMsg := p.messageListFilter(messages.Messages)
 
260
 
 
261
        return filteredMsg, nil
 
262
}
 
263
 
 
264
// messageListFilter returns a subset of unread messages where the subset
 
265
// depends on not being in reportedIds. Before returning, reportedIds is
 
266
// updated with the new list of unread messages.
 
267
func (p *GmailPlugin) messageListFilter(messages []message) []message {
 
268
        sort.Sort(byId(messages))
 
269
        var reportMsg []message
 
270
        var ids = make(reportedIdMap)
 
271
 
 
272
        for _, msg := range messages {
 
273
                if !p.reported(msg.Id) {
 
274
                        reportMsg = append(reportMsg, msg)
 
275
                }
 
276
                ids[msg.Id] = time.Now()
 
277
        }
 
278
        p.reportedIds = ids
 
279
        p.reportedIds.persist(p.accountId)
 
280
        return reportMsg
 
281
}
 
282
 
 
283
func (p *GmailPlugin) parseMessageResponse(resp *http.Response) (message, error) {
 
284
        defer resp.Body.Close()
 
285
        decoder := json.NewDecoder(resp.Body)
 
286
 
 
287
        if resp.StatusCode != http.StatusOK {
 
288
                var errResp errorResp
 
289
                if err := decoder.Decode(&errResp); err != nil {
 
290
                        return message{}, err
 
291
                }
 
292
                return message{}, &errResp
 
293
        }
 
294
 
 
295
        var msg message
 
296
        if err := decoder.Decode(&msg); err != nil {
 
297
                return message{}, err
 
298
        }
 
299
 
 
300
        return msg, nil
 
301
}
 
302
 
 
303
func (p *GmailPlugin) requestMessage(id, accessToken string) (*http.Response, error) {
 
304
        u, err := baseUrl.Parse("messages/" + id)
 
305
        if err != nil {
 
306
                return nil, err
 
307
        }
 
308
 
 
309
        query := u.Query()
 
310
        // only request specific fields
 
311
        query.Add("fields", "snippet,threadId,id,payload/headers")
 
312
        // get the full message to get From and Subject from headers
 
313
        query.Add("format", "full")
 
314
        u.RawQuery = query.Encode()
 
315
 
 
316
        req, err := http.NewRequest("GET", u.String(), nil)
 
317
        if err != nil {
 
318
                return nil, err
 
319
        }
 
320
        req.Header.Set("Authorization", "Bearer "+accessToken)
 
321
 
 
322
        return http.DefaultClient.Do(req)
 
323
}
 
324
 
 
325
func (p *GmailPlugin) requestMessageList(accessToken string) (*http.Response, error) {
 
326
        u, err := baseUrl.Parse("messages")
 
327
        if err != nil {
 
328
                return nil, err
 
329
        }
 
330
 
 
331
        query := u.Query()
 
332
 
 
333
        // get all unread inbox emails received after
 
334
        // the last time we checked. If this is the first
 
335
        // time we check, get unread emails after timeDelta
 
336
        query.Add("q", fmt.Sprintf("is:unread in:inbox newer_than:%s", relativeTimeDelta))
 
337
        u.RawQuery = query.Encode()
 
338
 
 
339
        req, err := http.NewRequest("GET", u.String(), nil)
 
340
        if err != nil {
 
341
                return nil, err
 
342
        }
 
343
        req.Header.Set("Authorization", "Bearer "+accessToken)
 
344
 
 
345
        return http.DefaultClient.Do(req)
 
346
}