##// END OF EJS Templates
py3: decode payload of notify email...
Denis Laxalde -
r43637:33506cb4 stable
parent child Browse files
Show More
@@ -1,573 +1,573 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 It is also used for the domain part of the ``Message-Id`` when using
86 86 ``notify.messageidseed``.
87 87
88 88 notify.messageidseed
89 89 Create deterministic ``Message-Id`` headers for the mails based on the seed
90 90 and the revision identifier of the first commit in the changeset.
91 91
92 92 notify.style
93 93 Style file to use when formatting emails.
94 94
95 95 notify.template
96 96 Template to use when formatting emails.
97 97
98 98 notify.incoming
99 99 Template to use when run as an incoming hook, overriding ``notify.template``.
100 100
101 101 notify.outgoing
102 102 Template to use when run as an outgoing hook, overriding ``notify.template``.
103 103
104 104 notify.changegroup
105 105 Template to use when running as a changegroup hook, overriding
106 106 ``notify.template``.
107 107
108 108 notify.maxdiff
109 109 Maximum number of diff lines to include in notification email. Set to 0
110 110 to disable the diff, or -1 to include all of it. Default: 300.
111 111
112 112 notify.maxdiffstat
113 113 Maximum number of diffstat lines to include in notification email. Set to -1
114 114 to include all of it. Default: -1.
115 115
116 116 notify.maxsubject
117 117 Maximum number of characters in email's subject line. Default: 67.
118 118
119 119 notify.diffstat
120 120 Set to True to include a diffstat before diff content. Default: True.
121 121
122 122 notify.showfunc
123 123 If set, override ``diff.showfunc`` for the diff content. Default: None.
124 124
125 125 notify.merge
126 126 If True, send notifications for merge changesets. Default: True.
127 127
128 128 notify.mbox
129 129 If set, append mails to this mbox file instead of sending. Default: None.
130 130
131 131 notify.fromauthor
132 132 If set, use the committer of the first changeset in a changegroup for
133 133 the "From" field of the notification mail. If not set, take the user
134 134 from the pushing repo. Default: False.
135 135
136 136 If set, the following entries will also be used to customize the
137 137 notifications:
138 138
139 139 email.from
140 140 Email ``From`` address to use if none can be found in the generated
141 141 email content.
142 142
143 143 web.baseurl
144 144 Root repository URL to combine with repository paths when making
145 145 references. See also ``notify.strip``.
146 146
147 147 '''
148 148 from __future__ import absolute_import
149 149
150 150 import email.errors as emailerrors
151 151 import email.utils as emailutils
152 152 import fnmatch
153 153 import hashlib
154 154 import socket
155 155 import time
156 156
157 157 from mercurial.i18n import _
158 158 from mercurial import (
159 159 encoding,
160 160 error,
161 161 logcmdutil,
162 162 mail,
163 163 patch,
164 164 pycompat,
165 165 registrar,
166 166 util,
167 167 )
168 168 from mercurial.utils import (
169 169 dateutil,
170 170 stringutil,
171 171 )
172 172
173 173 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
174 174 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
175 175 # be specifying the version(s) of Mercurial they are tested with, or
176 176 # leave the attribute unspecified.
177 177 testedwith = b'ships-with-hg-core'
178 178
179 179 configtable = {}
180 180 configitem = registrar.configitem(configtable)
181 181
182 182 configitem(
183 183 b'notify', b'changegroup', default=None,
184 184 )
185 185 configitem(
186 186 b'notify', b'config', default=None,
187 187 )
188 188 configitem(
189 189 b'notify', b'diffstat', default=True,
190 190 )
191 191 configitem(
192 192 b'notify', b'domain', default=None,
193 193 )
194 194 configitem(
195 195 b'notify', b'messageidseed', default=None,
196 196 )
197 197 configitem(
198 198 b'notify', b'fromauthor', default=None,
199 199 )
200 200 configitem(
201 201 b'notify', b'incoming', default=None,
202 202 )
203 203 configitem(
204 204 b'notify', b'maxdiff', default=300,
205 205 )
206 206 configitem(
207 207 b'notify', b'maxdiffstat', default=-1,
208 208 )
209 209 configitem(
210 210 b'notify', b'maxsubject', default=67,
211 211 )
212 212 configitem(
213 213 b'notify', b'mbox', default=None,
214 214 )
215 215 configitem(
216 216 b'notify', b'merge', default=True,
217 217 )
218 218 configitem(
219 219 b'notify', b'outgoing', default=None,
220 220 )
221 221 configitem(
222 222 b'notify', b'sources', default=b'serve',
223 223 )
224 224 configitem(
225 225 b'notify', b'showfunc', default=None,
226 226 )
227 227 configitem(
228 228 b'notify', b'strip', default=0,
229 229 )
230 230 configitem(
231 231 b'notify', b'style', default=None,
232 232 )
233 233 configitem(
234 234 b'notify', b'template', default=None,
235 235 )
236 236 configitem(
237 237 b'notify', b'test', default=True,
238 238 )
239 239
240 240 # template for single changeset can include email headers.
241 241 single_template = b'''
242 242 Subject: changeset in {webroot}: {desc|firstline|strip}
243 243 From: {author}
244 244
245 245 changeset {node|short} in {root}
246 246 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
247 247 description:
248 248 \t{desc|tabindent|strip}
249 249 '''.lstrip()
250 250
251 251 # template for multiple changesets should not contain email headers,
252 252 # because only first set of headers will be used and result will look
253 253 # strange.
254 254 multiple_template = b'''
255 255 changeset {node|short} in {root}
256 256 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
257 257 summary: {desc|firstline}
258 258 '''
259 259
260 260 deftemplates = {
261 261 b'changegroup': multiple_template,
262 262 }
263 263
264 264
265 265 class notifier(object):
266 266 '''email notification class.'''
267 267
268 268 def __init__(self, ui, repo, hooktype):
269 269 self.ui = ui
270 270 cfg = self.ui.config(b'notify', b'config')
271 271 if cfg:
272 272 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
273 273 self.repo = repo
274 274 self.stripcount = int(self.ui.config(b'notify', b'strip'))
275 275 self.root = self.strip(self.repo.root)
276 276 self.domain = self.ui.config(b'notify', b'domain')
277 277 self.mbox = self.ui.config(b'notify', b'mbox')
278 278 self.test = self.ui.configbool(b'notify', b'test')
279 279 self.charsets = mail._charsets(self.ui)
280 280 self.subs = self.subscribers()
281 281 self.merge = self.ui.configbool(b'notify', b'merge')
282 282 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
283 283 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
284 284 if self.showfunc is None:
285 285 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
286 286
287 287 mapfile = None
288 288 template = self.ui.config(b'notify', hooktype) or self.ui.config(
289 289 b'notify', b'template'
290 290 )
291 291 if not template:
292 292 mapfile = self.ui.config(b'notify', b'style')
293 293 if not mapfile and not template:
294 294 template = deftemplates.get(hooktype) or single_template
295 295 spec = logcmdutil.templatespec(template, mapfile)
296 296 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
297 297
298 298 def strip(self, path):
299 299 '''strip leading slashes from local path, turn into web-safe path.'''
300 300
301 301 path = util.pconvert(path)
302 302 count = self.stripcount
303 303 while count > 0:
304 304 c = path.find(b'/')
305 305 if c == -1:
306 306 break
307 307 path = path[c + 1 :]
308 308 count -= 1
309 309 return path
310 310
311 311 def fixmail(self, addr):
312 312 '''try to clean up email addresses.'''
313 313
314 314 addr = stringutil.email(addr.strip())
315 315 if self.domain:
316 316 a = addr.find(b'@localhost')
317 317 if a != -1:
318 318 addr = addr[:a]
319 319 if b'@' not in addr:
320 320 return addr + b'@' + self.domain
321 321 return addr
322 322
323 323 def subscribers(self):
324 324 '''return list of email addresses of subscribers to this repo.'''
325 325 subs = set()
326 326 for user, pats in self.ui.configitems(b'usersubs'):
327 327 for pat in pats.split(b','):
328 328 if b'#' in pat:
329 329 pat, revs = pat.split(b'#', 1)
330 330 else:
331 331 revs = None
332 332 if fnmatch.fnmatch(self.repo.root, pat.strip()):
333 333 subs.add((self.fixmail(user), revs))
334 334 for pat, users in self.ui.configitems(b'reposubs'):
335 335 if b'#' in pat:
336 336 pat, revs = pat.split(b'#', 1)
337 337 else:
338 338 revs = None
339 339 if fnmatch.fnmatch(self.repo.root, pat):
340 340 for user in users.split(b','):
341 341 subs.add((self.fixmail(user), revs))
342 342 return [
343 343 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
344 344 for s, r in sorted(subs)
345 345 ]
346 346
347 347 def node(self, ctx, **props):
348 348 '''format one changeset, unless it is a suppressed merge.'''
349 349 if not self.merge and len(ctx.parents()) > 1:
350 350 return False
351 351 self.t.show(
352 352 ctx,
353 353 changes=ctx.changeset(),
354 354 baseurl=self.ui.config(b'web', b'baseurl'),
355 355 root=self.repo.root,
356 356 webroot=self.root,
357 357 **props
358 358 )
359 359 return True
360 360
361 361 def skipsource(self, source):
362 362 '''true if incoming changes from this source should be skipped.'''
363 363 ok_sources = self.ui.config(b'notify', b'sources').split()
364 364 return source not in ok_sources
365 365
366 366 def send(self, ctx, count, data):
367 367 '''send message.'''
368 368
369 369 # Select subscribers by revset
370 370 subs = set()
371 371 for sub, spec in self.subs:
372 372 if spec is None:
373 373 subs.add(sub)
374 374 continue
375 375 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
376 376 if len(revs):
377 377 subs.add(sub)
378 378 continue
379 379 if len(subs) == 0:
380 380 self.ui.debug(
381 381 b'notify: no subscribers to selected repo and revset\n'
382 382 )
383 383 return
384 384
385 385 try:
386 386 msg = mail.parsebytes(data)
387 387 except emailerrors.MessageParseError as inst:
388 388 raise error.Abort(inst)
389 389
390 390 # store sender and subject
391 391 sender = msg[r'From']
392 392 subject = msg[r'Subject']
393 393 if sender is not None:
394 394 sender = mail.headdecode(sender)
395 395 if subject is not None:
396 396 subject = mail.headdecode(subject)
397 397 del msg[r'From'], msg[r'Subject']
398 398
399 399 if not msg.is_multipart():
400 400 # create fresh mime message from scratch
401 401 # (multipart templates must take care of this themselves)
402 402 headers = msg.items()
403 payload = msg.get_payload()
403 payload = msg.get_payload(decode=pycompat.ispy3)
404 404 # for notification prefer readability over data precision
405 405 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
406 406 # reinstate custom headers
407 407 for k, v in headers:
408 408 msg[k] = v
409 409
410 410 msg[r'Date'] = encoding.strfromlocal(
411 411 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
412 412 )
413 413
414 414 # try to make subject line exist and be useful
415 415 if not subject:
416 416 if count > 1:
417 417 subject = _(b'%s: %d new changesets') % (self.root, count)
418 418 else:
419 419 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
420 420 subject = b'%s: %s' % (self.root, s)
421 421 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
422 422 if maxsubject:
423 423 subject = stringutil.ellipsis(subject, maxsubject)
424 424 msg[r'Subject'] = encoding.strfromlocal(
425 425 mail.headencode(self.ui, subject, self.charsets, self.test)
426 426 )
427 427
428 428 # try to make message have proper sender
429 429 if not sender:
430 430 sender = self.ui.config(b'email', b'from') or self.ui.username()
431 431 if b'@' not in sender or b'@localhost' in sender:
432 432 sender = self.fixmail(sender)
433 433 msg[r'From'] = encoding.strfromlocal(
434 434 mail.addressencode(self.ui, sender, self.charsets, self.test)
435 435 )
436 436
437 437 msg[r'X-Hg-Notification'] = r'changeset %s' % ctx
438 438 if not msg[r'Message-Id']:
439 439 msg[r'Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
440 440 msg[r'To'] = encoding.strfromlocal(b', '.join(sorted(subs)))
441 441
442 442 msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
443 443 if self.test:
444 444 self.ui.write(msgtext)
445 445 if not msgtext.endswith(b'\n'):
446 446 self.ui.write(b'\n')
447 447 else:
448 448 self.ui.status(
449 449 _(b'notify: sending %d subscribers %d changes\n')
450 450 % (len(subs), count)
451 451 )
452 452 mail.sendmail(
453 453 self.ui,
454 454 emailutils.parseaddr(msg[r'From'])[1],
455 455 subs,
456 456 msgtext,
457 457 mbox=self.mbox,
458 458 )
459 459
460 460 def diff(self, ctx, ref=None):
461 461
462 462 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
463 463 prev = ctx.p1().node()
464 464 if ref:
465 465 ref = ref.node()
466 466 else:
467 467 ref = ctx.node()
468 468 diffopts = patch.diffallopts(self.ui)
469 469 diffopts.showfunc = self.showfunc
470 470 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
471 471 difflines = b''.join(chunks).splitlines()
472 472
473 473 if self.ui.configbool(b'notify', b'diffstat'):
474 474 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
475 475 s = patch.diffstat(difflines)
476 476 # s may be nil, don't include the header if it is
477 477 if s:
478 478 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
479 479 s = s.split(b"\n")
480 480 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
481 481 self.ui.write(msg % (len(s) - 2, maxdiffstat))
482 482 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
483 483 else:
484 484 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
485 485
486 486 if maxdiff == 0:
487 487 return
488 488 elif maxdiff > 0 and len(difflines) > maxdiff:
489 489 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
490 490 self.ui.write(msg % (len(difflines), maxdiff))
491 491 difflines = difflines[:maxdiff]
492 492 elif difflines:
493 493 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
494 494
495 495 self.ui.write(b"\n".join(difflines))
496 496
497 497
498 498 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
499 499 '''send email notifications to interested subscribers.
500 500
501 501 if used as changegroup hook, send one email for all changesets in
502 502 changegroup. else send one email per changeset.'''
503 503
504 504 n = notifier(ui, repo, hooktype)
505 505 ctx = repo.unfiltered()[node]
506 506
507 507 if not n.subs:
508 508 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
509 509 return
510 510 if n.skipsource(source):
511 511 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
512 512 return
513 513
514 514 ui.pushbuffer()
515 515 data = b''
516 516 count = 0
517 517 author = b''
518 518 if hooktype == b'changegroup' or hooktype == b'outgoing':
519 519 for rev in repo.changelog.revs(start=ctx.rev()):
520 520 if n.node(repo[rev]):
521 521 count += 1
522 522 if not author:
523 523 author = repo[rev].user()
524 524 else:
525 525 data += ui.popbuffer()
526 526 ui.note(
527 527 _(b'notify: suppressing notification for merge %d:%s\n')
528 528 % (rev, repo[rev].hex()[:12])
529 529 )
530 530 ui.pushbuffer()
531 531 if count:
532 532 n.diff(ctx, repo[b'tip'])
533 533 elif ctx.rev() in repo:
534 534 if not n.node(ctx):
535 535 ui.popbuffer()
536 536 ui.note(
537 537 _(b'notify: suppressing notification for merge %d:%s\n')
538 538 % (ctx.rev(), ctx.hex()[:12])
539 539 )
540 540 return
541 541 count += 1
542 542 n.diff(ctx)
543 543 if not author:
544 544 author = ctx.user()
545 545
546 546 data += ui.popbuffer()
547 547 fromauthor = ui.config(b'notify', b'fromauthor')
548 548 if author and fromauthor:
549 549 data = b'\n'.join([b'From: %s' % author, data])
550 550
551 551 if count:
552 552 n.send(ctx, count, data)
553 553
554 554
555 555 def messageid(ctx, domain, messageidseed):
556 556 if domain and messageidseed:
557 557 host = domain
558 558 else:
559 559 host = encoding.strtolocal(socket.getfqdn())
560 560 if messageidseed:
561 561 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
562 562 messageid = b'<hg.%s@%s>' % (
563 563 pycompat.sysbytes(messagehash.hexdigest()[:64]),
564 564 host,
565 565 )
566 566 else:
567 567 messageid = b'<hg.%s.%d.%d@%s>' % (
568 568 ctx,
569 569 int(time.time()),
570 570 hash(ctx.repo().root),
571 571 host,
572 572 )
573 573 return encoding.strfromlocal(messageid)
General Comments 0
You need to be logged in to leave comments. Login now