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