##// END OF EJS Templates
notify: a ton of encoding dancing to deal with the email module...
Augie Fackler -
r40340:f6ef89cf default
parent child Browse files
Show More
@@ -1,511 +1,515 b''
1 1 # notify.py - email notifications for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 '''hooks for sending email push notifications
9 9
10 10 This extension implements hooks to send email notifications when
11 11 changesets are sent from or received by the local repository.
12 12
13 13 First, enable the extension as explained in :hg:`help extensions`, and
14 14 register the hook you want to run. ``incoming`` and ``changegroup`` hooks
15 15 are run when changesets are received, while ``outgoing`` hooks are for
16 16 changesets sent to another repository::
17 17
18 18 [hooks]
19 19 # one email for each incoming changeset
20 20 incoming.notify = python:hgext.notify.hook
21 21 # one email for all incoming changesets
22 22 changegroup.notify = python:hgext.notify.hook
23 23
24 24 # one email for all outgoing changesets
25 25 outgoing.notify = python:hgext.notify.hook
26 26
27 27 This registers the hooks. To enable notification, subscribers must
28 28 be assigned to repositories. The ``[usersubs]`` section maps multiple
29 29 repositories to a given recipient. The ``[reposubs]`` section maps
30 30 multiple recipients to a single repository::
31 31
32 32 [usersubs]
33 33 # key is subscriber email, value is a comma-separated list of repo patterns
34 34 user@host = pattern
35 35
36 36 [reposubs]
37 37 # key is repo pattern, value is a comma-separated list of subscriber emails
38 38 pattern = user@host
39 39
40 40 A ``pattern`` is a ``glob`` matching the absolute path to a repository,
41 41 optionally combined with a revset expression. A revset expression, if
42 42 present, is separated from the glob by a hash. Example::
43 43
44 44 [reposubs]
45 45 */widgets#branch(release) = qa-team@example.com
46 46
47 47 This sends to ``qa-team@example.com`` whenever a changeset on the ``release``
48 48 branch triggers a notification in any repository ending in ``widgets``.
49 49
50 50 In order to place them under direct user management, ``[usersubs]`` and
51 51 ``[reposubs]`` sections may be placed in a separate ``hgrc`` file and
52 52 incorporated by reference::
53 53
54 54 [notify]
55 55 config = /path/to/subscriptionsfile
56 56
57 57 Notifications will not be sent until the ``notify.test`` value is set
58 58 to ``False``; see below.
59 59
60 60 Notifications content can be tweaked with the following configuration entries:
61 61
62 62 notify.test
63 63 If ``True``, print messages to stdout instead of sending them. Default: True.
64 64
65 65 notify.sources
66 66 Space-separated list of change sources. Notifications are activated only
67 67 when a changeset's source is in this list. Sources may be:
68 68
69 69 :``serve``: changesets received via http or ssh
70 70 :``pull``: changesets received via ``hg pull``
71 71 :``unbundle``: changesets received via ``hg unbundle``
72 72 :``push``: changesets sent or received via ``hg push``
73 73 :``bundle``: changesets sent via ``hg unbundle``
74 74
75 75 Default: serve.
76 76
77 77 notify.strip
78 78 Number of leading slashes to strip from url paths. By default, notifications
79 79 reference repositories with their absolute path. ``notify.strip`` lets you
80 80 turn them into relative paths. For example, ``notify.strip=3`` will change
81 81 ``/long/path/repository`` into ``repository``. Default: 0.
82 82
83 83 notify.domain
84 84 Default email domain for sender or recipients with no explicit domain.
85 85
86 86 notify.style
87 87 Style file to use when formatting emails.
88 88
89 89 notify.template
90 90 Template to use when formatting emails.
91 91
92 92 notify.incoming
93 93 Template to use when run as an incoming hook, overriding ``notify.template``.
94 94
95 95 notify.outgoing
96 96 Template to use when run as an outgoing hook, overriding ``notify.template``.
97 97
98 98 notify.changegroup
99 99 Template to use when running as a changegroup hook, overriding
100 100 ``notify.template``.
101 101
102 102 notify.maxdiff
103 103 Maximum number of diff lines to include in notification email. Set to 0
104 104 to disable the diff, or -1 to include all of it. Default: 300.
105 105
106 106 notify.maxdiffstat
107 107 Maximum number of diffstat lines to include in notification email. Set to -1
108 108 to include all of it. Default: -1.
109 109
110 110 notify.maxsubject
111 111 Maximum number of characters in email's subject line. Default: 67.
112 112
113 113 notify.diffstat
114 114 Set to True to include a diffstat before diff content. Default: True.
115 115
116 116 notify.showfunc
117 117 If set, override ``diff.showfunc`` for the diff content. Default: None.
118 118
119 119 notify.merge
120 120 If True, send notifications for merge changesets. Default: True.
121 121
122 122 notify.mbox
123 123 If set, append mails to this mbox file instead of sending. Default: None.
124 124
125 125 notify.fromauthor
126 126 If set, use the committer of the first changeset in a changegroup for
127 127 the "From" field of the notification mail. If not set, take the user
128 128 from the pushing repo. Default: False.
129 129
130 130 If set, the following entries will also be used to customize the
131 131 notifications:
132 132
133 133 email.from
134 134 Email ``From`` address to use if none can be found in the generated
135 135 email content.
136 136
137 137 web.baseurl
138 138 Root repository URL to combine with repository paths when making
139 139 references. See also ``notify.strip``.
140 140
141 141 '''
142 142 from __future__ import absolute_import
143 143
144 144 import email.errors as emailerrors
145 145 import email.parser as emailparser
146 146 import fnmatch
147 147 import socket
148 148 import time
149 149
150 150 from mercurial.i18n import _
151 151 from mercurial import (
152 encoding,
152 153 error,
153 154 logcmdutil,
154 155 mail,
155 156 patch,
156 157 registrar,
157 158 util,
158 159 )
159 160 from mercurial.utils import (
160 161 dateutil,
161 162 stringutil,
162 163 )
163 164
164 165 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
165 166 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
166 167 # be specifying the version(s) of Mercurial they are tested with, or
167 168 # leave the attribute unspecified.
168 169 testedwith = 'ships-with-hg-core'
169 170
170 171 configtable = {}
171 172 configitem = registrar.configitem(configtable)
172 173
173 174 configitem('notify', 'changegroup',
174 175 default=None,
175 176 )
176 177 configitem('notify', 'config',
177 178 default=None,
178 179 )
179 180 configitem('notify', 'diffstat',
180 181 default=True,
181 182 )
182 183 configitem('notify', 'domain',
183 184 default=None,
184 185 )
185 186 configitem('notify', 'fromauthor',
186 187 default=None,
187 188 )
188 189 configitem('notify', 'incoming',
189 190 default=None,
190 191 )
191 192 configitem('notify', 'maxdiff',
192 193 default=300,
193 194 )
194 195 configitem('notify', 'maxdiffstat',
195 196 default=-1,
196 197 )
197 198 configitem('notify', 'maxsubject',
198 199 default=67,
199 200 )
200 201 configitem('notify', 'mbox',
201 202 default=None,
202 203 )
203 204 configitem('notify', 'merge',
204 205 default=True,
205 206 )
206 207 configitem('notify', 'outgoing',
207 208 default=None,
208 209 )
209 210 configitem('notify', 'sources',
210 211 default='serve',
211 212 )
212 213 configitem('notify', 'showfunc',
213 214 default=None,
214 215 )
215 216 configitem('notify', 'strip',
216 217 default=0,
217 218 )
218 219 configitem('notify', 'style',
219 220 default=None,
220 221 )
221 222 configitem('notify', 'template',
222 223 default=None,
223 224 )
224 225 configitem('notify', 'test',
225 226 default=True,
226 227 )
227 228
228 229 # template for single changeset can include email headers.
229 230 single_template = b'''
230 231 Subject: changeset in {webroot}: {desc|firstline|strip}
231 232 From: {author}
232 233
233 234 changeset {node|short} in {root}
234 235 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
235 236 description:
236 237 \t{desc|tabindent|strip}
237 238 '''.lstrip()
238 239
239 240 # template for multiple changesets should not contain email headers,
240 241 # because only first set of headers will be used and result will look
241 242 # strange.
242 243 multiple_template = b'''
243 244 changeset {node|short} in {root}
244 245 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
245 246 summary: {desc|firstline}
246 247 '''
247 248
248 249 deftemplates = {
249 250 'changegroup': multiple_template,
250 251 }
251 252
252 253 class notifier(object):
253 254 '''email notification class.'''
254 255
255 256 def __init__(self, ui, repo, hooktype):
256 257 self.ui = ui
257 258 cfg = self.ui.config('notify', 'config')
258 259 if cfg:
259 260 self.ui.readconfig(cfg, sections=['usersubs', 'reposubs'])
260 261 self.repo = repo
261 262 self.stripcount = int(self.ui.config('notify', 'strip'))
262 263 self.root = self.strip(self.repo.root)
263 264 self.domain = self.ui.config('notify', 'domain')
264 265 self.mbox = self.ui.config('notify', 'mbox')
265 266 self.test = self.ui.configbool('notify', 'test')
266 267 self.charsets = mail._charsets(self.ui)
267 268 self.subs = self.subscribers()
268 269 self.merge = self.ui.configbool('notify', 'merge')
269 270 self.showfunc = self.ui.configbool('notify', 'showfunc')
270 271 if self.showfunc is None:
271 272 self.showfunc = self.ui.configbool('diff', 'showfunc')
272 273
273 274 mapfile = None
274 275 template = (self.ui.config('notify', hooktype) or
275 276 self.ui.config('notify', 'template'))
276 277 if not template:
277 278 mapfile = self.ui.config('notify', 'style')
278 279 if not mapfile and not template:
279 280 template = deftemplates.get(hooktype) or single_template
280 281 spec = logcmdutil.templatespec(template, mapfile)
281 282 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
282 283
283 284 def strip(self, path):
284 285 '''strip leading slashes from local path, turn into web-safe path.'''
285 286
286 287 path = util.pconvert(path)
287 288 count = self.stripcount
288 289 while count > 0:
289 290 c = path.find('/')
290 291 if c == -1:
291 292 break
292 293 path = path[c + 1:]
293 294 count -= 1
294 295 return path
295 296
296 297 def fixmail(self, addr):
297 298 '''try to clean up email addresses.'''
298 299
299 300 addr = stringutil.email(addr.strip())
300 301 if self.domain:
301 302 a = addr.find('@localhost')
302 303 if a != -1:
303 304 addr = addr[:a]
304 305 if '@' not in addr:
305 306 return addr + '@' + self.domain
306 307 return addr
307 308
308 309 def subscribers(self):
309 310 '''return list of email addresses of subscribers to this repo.'''
310 311 subs = set()
311 312 for user, pats in self.ui.configitems('usersubs'):
312 313 for pat in pats.split(','):
313 314 if '#' in pat:
314 315 pat, revs = pat.split('#', 1)
315 316 else:
316 317 revs = None
317 318 if fnmatch.fnmatch(self.repo.root, pat.strip()):
318 319 subs.add((self.fixmail(user), revs))
319 320 for pat, users in self.ui.configitems('reposubs'):
320 321 if '#' in pat:
321 322 pat, revs = pat.split('#', 1)
322 323 else:
323 324 revs = None
324 325 if fnmatch.fnmatch(self.repo.root, pat):
325 326 for user in users.split(','):
326 327 subs.add((self.fixmail(user), revs))
327 328 return [(mail.addressencode(self.ui, s, self.charsets, self.test), r)
328 329 for s, r in sorted(subs)]
329 330
330 331 def node(self, ctx, **props):
331 332 '''format one changeset, unless it is a suppressed merge.'''
332 333 if not self.merge and len(ctx.parents()) > 1:
333 334 return False
334 335 self.t.show(ctx, changes=ctx.changeset(),
335 336 baseurl=self.ui.config('web', 'baseurl'),
336 337 root=self.repo.root, webroot=self.root, **props)
337 338 return True
338 339
339 340 def skipsource(self, source):
340 341 '''true if incoming changes from this source should be skipped.'''
341 342 ok_sources = self.ui.config('notify', 'sources').split()
342 343 return source not in ok_sources
343 344
344 345 def send(self, ctx, count, data):
345 346 '''send message.'''
346 347
347 348 # Select subscribers by revset
348 349 subs = set()
349 350 for sub, spec in self.subs:
350 351 if spec is None:
351 352 subs.add(sub)
352 353 continue
353 354 revs = self.repo.revs('%r and %d:', spec, ctx.rev())
354 355 if len(revs):
355 356 subs.add(sub)
356 357 continue
357 358 if len(subs) == 0:
358 359 self.ui.debug('notify: no subscribers to selected repo '
359 360 'and revset\n')
360 361 return
361 362
362 363 p = emailparser.Parser()
363 364 try:
364 msg = p.parsestr(data)
365 msg = p.parsestr(encoding.strfromlocal(data))
365 366 except emailerrors.MessageParseError as inst:
366 367 raise error.Abort(inst)
367 368
368 369 # store sender and subject
369 sender, subject = msg['From'], msg['Subject']
370 del msg['From'], msg['Subject']
370 sender = encoding.strtolocal(msg[r'From'])
371 subject = encoding.strtolocal(msg[r'Subject'])
372 del msg[r'From'], msg[r'Subject']
371 373
372 374 if not msg.is_multipart():
373 375 # create fresh mime message from scratch
374 376 # (multipart templates must take care of this themselves)
375 377 headers = msg.items()
376 378 payload = msg.get_payload()
377 379 # for notification prefer readability over data precision
378 380 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
379 381 # reinstate custom headers
380 382 for k, v in headers:
381 383 msg[k] = v
382 384
383 msg['Date'] = dateutil.datestr(format="%a, %d %b %Y %H:%M:%S %1%2")
385 msg[r'Date'] = encoding.strfromlocal(
386 dateutil.datestr(format="%a, %d %b %Y %H:%M:%S %1%2"))
384 387
385 388 # try to make subject line exist and be useful
386 389 if not subject:
387 390 if count > 1:
388 391 subject = _('%s: %d new changesets') % (self.root, count)
389 392 else:
390 393 s = ctx.description().lstrip().split('\n', 1)[0].rstrip()
391 394 subject = '%s: %s' % (self.root, s)
392 395 maxsubject = int(self.ui.config('notify', 'maxsubject'))
393 396 if maxsubject:
394 397 subject = stringutil.ellipsis(subject, maxsubject)
395 msg['Subject'] = mail.headencode(self.ui, subject,
396 self.charsets, self.test)
398 msg[r'Subject'] = encoding.strfromlocal(
399 mail.headencode(self.ui, subject, self.charsets, self.test))
397 400
398 401 # try to make message have proper sender
399 402 if not sender:
400 403 sender = self.ui.config('email', 'from') or self.ui.username()
401 404 if '@' not in sender or '@localhost' in sender:
402 405 sender = self.fixmail(sender)
403 msg['From'] = mail.addressencode(self.ui, sender,
404 self.charsets, self.test)
406 msg[r'From'] = encoding.strfromlocal(
407 mail.addressencode(self.ui, sender, self.charsets, self.test))
405 408
406 msg['X-Hg-Notification'] = 'changeset %s' % ctx
407 if not msg['Message-Id']:
408 msg['Message-Id'] = ('<hg.%s.%s.%s@%s>' %
409 (ctx, int(time.time()),
410 hash(self.repo.root), socket.getfqdn()))
411 msg['To'] = ', '.join(sorted(subs))
409 msg[r'X-Hg-Notification'] = r'changeset %s' % ctx
410 if not msg[r'Message-Id']:
411 msg[r'Message-Id'] = encoding.strfromlocal(
412 '<hg.%s.%d.%d@%s>' % (ctx, int(time.time()),
413 hash(self.repo.root),
414 encoding.strtolocal(socket.getfqdn())))
415 msg[r'To'] = encoding.strfromlocal(', '.join(sorted(subs)))
412 416
413 msgtext = msg.as_string()
417 msgtext = encoding.strtolocal(msg.as_string())
414 418 if self.test:
415 419 self.ui.write(msgtext)
416 420 if not msgtext.endswith('\n'):
417 421 self.ui.write('\n')
418 422 else:
419 423 self.ui.status(_('notify: sending %d subscribers %d changes\n') %
420 424 (len(subs), count))
421 mail.sendmail(self.ui, stringutil.email(msg['From']),
425 mail.sendmail(self.ui, stringutil.email(msg[r'From']),
422 426 subs, msgtext, mbox=self.mbox)
423 427
424 428 def diff(self, ctx, ref=None):
425 429
426 430 maxdiff = int(self.ui.config('notify', 'maxdiff'))
427 431 prev = ctx.p1().node()
428 432 if ref:
429 433 ref = ref.node()
430 434 else:
431 435 ref = ctx.node()
432 436 diffopts = patch.diffallopts(self.ui)
433 437 diffopts.showfunc = self.showfunc
434 438 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
435 439 difflines = ''.join(chunks).splitlines()
436 440
437 441 if self.ui.configbool('notify', 'diffstat'):
438 442 maxdiffstat = int(self.ui.config('notify', 'maxdiffstat'))
439 443 s = patch.diffstat(difflines)
440 444 # s may be nil, don't include the header if it is
441 445 if s:
442 446 if maxdiffstat >= 0 and s.count("\n") > maxdiffstat + 1:
443 447 s = s.split("\n")
444 448 msg = _('\ndiffstat (truncated from %d to %d lines):\n\n')
445 449 self.ui.write(msg % (len(s) - 2, maxdiffstat))
446 450 self.ui.write("\n".join(s[:maxdiffstat] + s[-2:]))
447 451 else:
448 452 self.ui.write(_('\ndiffstat:\n\n%s') % s)
449 453
450 454 if maxdiff == 0:
451 455 return
452 456 elif maxdiff > 0 and len(difflines) > maxdiff:
453 457 msg = _('\ndiffs (truncated from %d to %d lines):\n\n')
454 458 self.ui.write(msg % (len(difflines), maxdiff))
455 459 difflines = difflines[:maxdiff]
456 460 elif difflines:
457 461 self.ui.write(_('\ndiffs (%d lines):\n\n') % len(difflines))
458 462
459 463 self.ui.write("\n".join(difflines))
460 464
461 465 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
462 466 '''send email notifications to interested subscribers.
463 467
464 468 if used as changegroup hook, send one email for all changesets in
465 469 changegroup. else send one email per changeset.'''
466 470
467 471 n = notifier(ui, repo, hooktype)
468 472 ctx = repo.unfiltered()[node]
469 473
470 474 if not n.subs:
471 475 ui.debug('notify: no subscribers to repository %s\n' % n.root)
472 476 return
473 477 if n.skipsource(source):
474 478 ui.debug('notify: changes have source "%s" - skipping\n' % source)
475 479 return
476 480
477 481 ui.pushbuffer()
478 482 data = ''
479 483 count = 0
480 484 author = ''
481 485 if hooktype == 'changegroup' or hooktype == 'outgoing':
482 486 for rev in repo.changelog.revs(start=ctx.rev()):
483 487 if n.node(repo[rev]):
484 488 count += 1
485 489 if not author:
486 490 author = repo[rev].user()
487 491 else:
488 492 data += ui.popbuffer()
489 493 ui.note(_('notify: suppressing notification for merge %d:%s\n')
490 494 % (rev, repo[rev].hex()[:12]))
491 495 ui.pushbuffer()
492 496 if count:
493 497 n.diff(ctx, repo['tip'])
494 498 elif ctx.rev() in repo:
495 499 if not n.node(ctx):
496 500 ui.popbuffer()
497 501 ui.note(_('notify: suppressing notification for merge %d:%s\n') %
498 502 (ctx.rev(), ctx.hex()[:12]))
499 503 return
500 504 count += 1
501 505 n.diff(ctx)
502 506 if not author:
503 507 author = ctx.user()
504 508
505 509 data += ui.popbuffer()
506 510 fromauthor = ui.config('notify', 'fromauthor')
507 511 if author and fromauthor:
508 512 data = '\n'.join(['From: %s' % author, data])
509 513
510 514 if count:
511 515 n.send(ctx, count, data)
General Comments 0
You need to be logged in to leave comments. Login now