2
Copyright 2014 Canonical Ltd.
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.
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.
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/>.
33
"launchpad.net/account-polld/accounts"
34
"launchpad.net/account-polld/gettext"
35
"launchpad.net/account-polld/plugins"
36
"launchpad.net/account-polld/qtcontact"
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
45
individualNotificationsLimit = 10
49
type reportedIdMap map[string]time.Time
51
var baseUrl, _ = url.Parse("https://www.googleapis.com/gmail/v1/users/me/")
53
// timeDelta defines how old messages can be to be reported.
54
var timeDelta = time.Duration(time.Hour * 24)
56
// trackDelta defines how old messages can be before removed from tracking
57
var trackDelta = time.Duration(time.Hour * 24 * 7)
59
// relativeTimeDelta is the same as timeDelta
60
var relativeTimeDelta string = "1d"
62
// regexp for identifying non-ascii characters
63
var nonAsciiChars, _ = regexp.Compile("[^\x00-\x7F]")
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
73
func idsFromPersist(accountId uint) (ids reportedIdMap, err error) {
74
err = plugins.FromPersist(pluginName, accountId, &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)
90
func (ids reportedIdMap) persist(accountId uint) (err error) {
91
err = plugins.Persist(pluginName, accountId, ids)
93
log.Print("gmail plugin ", accountId, ": failed to save state: ", err)
98
func New(accountId uint) *GmailPlugin {
99
reportedIds, err := idsFromPersist(accountId)
101
log.Print("gmail plugin ", accountId, ": cannot load previous state from storage: ", err)
103
log.Print("gmail plugin ", accountId, ": last state loaded from storage")
105
return &GmailPlugin{reportedIds: reportedIds, accountId: accountId}
108
func (p *GmailPlugin) ApplicationId() plugins.ApplicationId {
109
return plugins.ApplicationId(APP_ID)
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
118
resp, err := p.requestMessageList(authData.AccessToken)
122
messages, err := p.parseMessageListResponse(resp)
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)
133
messages[i], err = p.parseMessageResponse(resp)
138
notif, err := p.createNotifications(messages)
142
return []*plugins.PushMessageBatch{
143
&plugins.PushMessageBatch{
145
Limit: individualNotificationsLimit,
146
OverflowHandler: p.handleOverflow,
152
func (p *GmailPlugin) reported(id string) bool {
153
_, ok := p.reportedIds[id]
157
func (p *GmailPlugin) createNotifications(messages []message) ([]*plugins.PushMessage, error) {
158
timestamp := time.Now()
159
pushMsgMap := make(pushes)
161
for _, msg := range messages {
162
hdr := msg.Payload.mapHeaders()
165
var avatarPath string
167
emailAddress, err := mail.ParseAddress(from)
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
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
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
198
msgStamp := hdr.getTimestamp()
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)
213
log.Print("gmail plugin ", p.accountId, ": skipping message id ", msg.Id, " with date ", msgStamp, " older than ", timeDelta)
216
pushMsg := make([]*plugins.PushMessage, 0, len(pushMsgMap))
217
for _, v := range pushMsgMap {
218
pushMsg = append(pushMsg, v)
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)
227
// TRANSLATORS: the %d refers to the number of new email messages.
228
summary := fmt.Sprintf(gettext.Gettext("You have %d new messages"), approxUnreadMessages)
232
// fmt with label personal and no threadId
233
action := fmt.Sprintf(dekkoDispatchUrl, p.accountId, "INBOX")
234
epoch := time.Now().Unix()
236
return plugins.NewStandardPushMessage(summary, body, action, "", epoch)
239
func (p *GmailPlugin) parseMessageListResponse(resp *http.Response) ([]message, error) {
240
defer resp.Body.Close()
241
decoder := json.NewDecoder(resp.Body)
243
if resp.StatusCode != http.StatusOK {
244
var errResp errorResp
245
if err := decoder.Decode(&errResp); err != nil {
248
if errResp.Err.Code == 401 {
249
return nil, plugins.ErrTokenExpired
254
var messages messageList
255
if err := decoder.Decode(&messages); err != nil {
259
filteredMsg := p.messageListFilter(messages.Messages)
261
return filteredMsg, nil
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)
272
for _, msg := range messages {
273
if !p.reported(msg.Id) {
274
reportMsg = append(reportMsg, msg)
276
ids[msg.Id] = time.Now()
279
p.reportedIds.persist(p.accountId)
283
func (p *GmailPlugin) parseMessageResponse(resp *http.Response) (message, error) {
284
defer resp.Body.Close()
285
decoder := json.NewDecoder(resp.Body)
287
if resp.StatusCode != http.StatusOK {
288
var errResp errorResp
289
if err := decoder.Decode(&errResp); err != nil {
290
return message{}, err
292
return message{}, &errResp
296
if err := decoder.Decode(&msg); err != nil {
297
return message{}, err
303
func (p *GmailPlugin) requestMessage(id, accessToken string) (*http.Response, error) {
304
u, err := baseUrl.Parse("messages/" + id)
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()
316
req, err := http.NewRequest("GET", u.String(), nil)
320
req.Header.Set("Authorization", "Bearer "+accessToken)
322
return http.DefaultClient.Do(req)
325
func (p *GmailPlugin) requestMessageList(accessToken string) (*http.Response, error) {
326
u, err := baseUrl.Parse("messages")
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()
339
req, err := http.NewRequest("GET", u.String(), nil)
343
req.Header.Set("Authorization", "Bearer "+accessToken)
345
return http.DefaultClient.Do(req)