1
#! /usr/bin/env python2
2
3
-# Copyright (c) 2012,2013 Michael Haggerty
3
+# Copyright (c) 2012-2014 Michael Haggerty and others
4
# Derived from contrib/hooks/post-receive-email, which is
5
# Copyright (c) 2007 Andy Parkins
6
# and also includes contributions by other authors.
49
import os
50
import re
51
import bisect
52
+import socket
53
import subprocess
54
import shlex
55
import optparse
56
import smtplib
57
+import time
58
59
try:
60
from email.utils import make_msgid
61
from email.utils import getaddresses
62
from email.utils import formataddr
63
+ from email.utils import formatdate
64
from email.header import Header
65
except ImportError:
66
# Prior to Python 2.5, the email module used different names:
67
from email.Utils import make_msgid
68
from email.Utils import getaddresses
69
from email.Utils import formataddr
70
+ from email.Utils import formatdate
71
from email.Header import Header
72
73
77
LOGBEGIN = '- Log -----------------------------------------------------------------\n'
78
LOGEND = '-----------------------------------------------------------------------\n'
79
80
+ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
81
82
# It is assumed in many places that the encoding is uniformly UTF-8,
83
# so changing these constants is unsupported. But define them here
100
)
101
102
REFCHANGE_HEADER_TEMPLATE = """\
103
+Date: %(send_date)s
104
To: %(recipients)s
105
Subject: %(subject)s
106
MIME-Version: 1.0
109
Message-ID: %(msgid)s
110
From: %(fromaddr)s
111
Reply-To: %(reply_to)s
112
+X-Git-Host: %(fqdn)s
113
X-Git-Repo: %(repo_shortname)s
114
X-Git-Refname: %(refname)s
115
X-Git-Reftype: %(refname_type)s
228
229
230
REVISION_HEADER_TEMPLATE = """\
231
+Date: %(send_date)s
232
To: %(recipients)s
233
Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
234
MIME-Version: 1.0
238
Reply-To: %(reply_to)s
239
In-Reply-To: %(reply_to_msgid)s
240
References: %(reply_to_msgid)s
241
+X-Git-Host: %(fqdn)s
242
X-Git-Repo: %(repo_shortname)s
243
X-Git-Refname: %(refname)s
244
X-Git-Reftype: %(refname_type)s
272
pass
273
274
275
+# The "git" program (this could be changed to include a full path):
276
+GIT_EXECUTABLE = 'git'
277
+
278
+
279
+# How "git" should be invoked (including global arguments), as a list
280
+# of words. This variable is usually initialized automatically by
281
+# read_git_output() via choose_git_command(), but if a value is set
282
+# here then it will be used unconditionally.
283
+GIT_CMD = None
284
+
285
+
286
+def choose_git_command():
287
+ """Decide how to invoke git, and record the choice in GIT_CMD."""
288
+
289
+ global GIT_CMD
290
+
291
+ if GIT_CMD is None:
292
+ try:
293
+ # Check to see whether the "-c" option is accepted (it was
294
+ # only added in Git 1.7.2). We don't actually use the
295
+ # output of "git --version", though if we needed more
296
+ # specific version information this would be the place to
297
+ # do it.
298
+ cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
299
+ read_output(cmd)
300
+ GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
301
+ except CommandError:
302
+ GIT_CMD = [GIT_EXECUTABLE]
303
+
304
+
305
def read_git_output(args, input=None, keepends=False, **kw):
306
"""Read the output of a Git command."""
307
269
- return read_output(
270
- ['git', '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)] + args,
271
- input=input, keepends=keepends, **kw
272
- )
308
+ if GIT_CMD is None:
309
+ choose_git_command()
310
+
311
+ return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
312
313
314
def read_output(cmd, input=None, keepends=False, **kw):
336
return read_git_output(args, keepends=True, **kw).splitlines(keepends)
337
338
339
+def header_encode(text, header_name=None):
340
+ """Encode and line-wrap the value of an email header field."""
341
+
342
+ try:
343
+ if isinstance(text, str):
344
+ text = text.decode(ENCODING, 'replace')
345
+ return Header(text, header_name=header_name).encode()
346
+ except UnicodeEncodeError:
347
+ return Header(text, header_name=header_name, charset=CHARSET,
348
+ errors='replace').encode()
349
+
350
+
351
+def addr_header_encode(text, header_name=None):
352
+ """Encode and line-wrap the value of an email header field containing
353
+ email addresses."""
354
+
355
+ return Header(
356
+ ', '.join(
357
+ formataddr((header_encode(name), emailaddr))
358
+ for name, emailaddr in getaddresses([text])
359
+ ),
360
+ header_name=header_name
361
+ ).encode()
362
+
363
+
364
class Config(object):
365
def __init__(self, section, git_config=None):
366
"""Represent a section of the git configuration.
642
% (e.args[0], line,)
643
)
644
else:
581
- try:
582
- h = Header(value, header_name=name)
583
- except UnicodeDecodeError:
584
- h = Header(value, header_name=name, charset=CHARSET, errors='replace')
585
- for splitline in ('%s: %s\n' % (name, h.encode(),)).splitlines(True):
645
+ if name.lower() in ADDR_HEADERS:
646
+ value = addr_header_encode(value, name)
647
+ else:
648
+ value = header_encode(value, name)
649
+ for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
650
yield splitline
651
652
def generate_email_header(self):
680
681
raise NotImplementedError()
682
619
- def generate_email(self, push, body_filter=None):
683
+ def generate_email(self, push, body_filter=None, extra_header_values={}):
684
"""Generate an email describing this change.
685
686
Iterate over the lines (including the header lines) of an
687
email describing this change. If body_filter is not None,
688
then use it to filter the lines that are intended for the
625
- email body."""
689
+ email body.
690
+
691
+ The extra_header_values field is received as a dict and not as
692
+ **kwargs, to allow passing other keyword arguments in the
693
+ future (e.g. passing extra values to generate_email_intro()"""
694
627
- for line in self.generate_email_header():
695
+ for line in self.generate_email_header(**extra_header_values):
696
yield line
697
yield '\n'
698
for line in self.generate_email_intro():
748
749
return values
750
683
- def generate_email_header(self):
684
- for line in self.expand_header_lines(REVISION_HEADER_TEMPLATE):
751
+ def generate_email_header(self, **extra_values):
752
+ for line in self.expand_header_lines(
753
+ REVISION_HEADER_TEMPLATE, **extra_values
754
+ ):
755
yield line
756
757
def generate_email_intro(self):
762
"""Show this revision."""
763
764
return read_git_lines(
695
- [
696
- 'log', '-C',
697
- '--stat', '-p', '--cc',
698
- '-1', self.rev.sha1,
699
- ],
765
+ ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
766
keepends=True,
767
)
768
866
self.msgid = make_msgid()
867
self.diffopts = environment.diffopts
868
self.logopts = environment.logopts
869
+ self.commitlogopts = environment.commitlogopts
870
self.showlog = environment.refchange_showlog
871
872
def _compute_values(self):
902
}[self.change_type]
903
return self.expand(template)
904
838
- def generate_email_header(self):
905
+ def generate_email_header(self, **extra_values):
906
+ if 'subject' not in extra_values:
907
+ extra_values['subject'] = self.get_subject()
908
+
909
for line in self.expand_header_lines(
840
- REFCHANGE_HEADER_TEMPLATE, subject=self.get_subject(),
910
+ REFCHANGE_HEADER_TEMPLATE, **extra_values
911
):
912
yield line
913
1343
1344
1345
class SendMailer(Mailer):
1276
- """Send emails using 'sendmail -t'."""
1346
+ """Send emails using 'sendmail -oi -t'."""
1347
1348
SENDMAIL_CANDIDATES = [
1349
'/usr/sbin/sendmail',
1372
if command:
1373
self.command = command[:]
1374
else:
1305
- self.command = [self.find_sendmail(), '-t']
1375
+ self.command = [self.find_sendmail(), '-oi', '-t']
1376
1377
if envelopesender:
1378
self.command.extend(['-f', envelopesender])
1565
'git log' when generating the detailed log for a set of
1566
commits (see refchange_showlog)
1567
1568
+ commitlogopts (list of strings)
1569
+
1570
+ The options that should be passed to 'git log' for each
1571
+ commit mail. The value should be a list of strings
1572
+ representing words to be passed to the command.
1573
+
1574
"""
1575
1576
REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1582
self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1583
self.logopts = []
1584
self.refchange_showlog = False
1585
+ self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1586
1587
self.COMPUTED_KEYS = [
1588
'administrator',
1749
if logopts is not None:
1750
self.logopts = shlex.split(logopts)
1751
1752
+ commitlogopts = config.get('commitlogopts')
1753
+ if commitlogopts is not None:
1754
+ self.commitlogopts = shlex.split(commitlogopts)
1755
+
1756
reply_to = config.get('replyTo')
1757
self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
1758
if (
1910
)
1911
1912
1913
+class FQDNEnvironmentMixin(Environment):
1914
+ """A mixin that sets the host's FQDN to its constructor argument."""
1915
+
1916
+ def __init__(self, fqdn, **kw):
1917
+ super(FQDNEnvironmentMixin, self).__init__(**kw)
1918
+ self.COMPUTED_KEYS += ['fqdn']
1919
+ self.__fqdn = fqdn
1920
+
1921
+ def get_fqdn(self):
1922
+ """Return the fully-qualified domain name for this host.
1923
+
1924
+ Return None if it is unavailable or unwanted."""
1925
+
1926
+ return self.__fqdn
1927
+
1928
+
1929
+class ConfigFQDNEnvironmentMixin(
1930
+ ConfigEnvironmentMixin,
1931
+ FQDNEnvironmentMixin,
1932
+ ):
1933
+ """Read the FQDN from the config."""
1934
+
1935
+ def __init__(self, config, **kw):
1936
+ fqdn = config.get('fqdn')
1937
+ super(ConfigFQDNEnvironmentMixin, self).__init__(
1938
+ config=config,
1939
+ fqdn=fqdn,
1940
+ **kw
1941
+ )
1942
+
1943
+
1944
+class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
1945
+ """Get the FQDN by calling socket.getfqdn()."""
1946
+
1947
+ def __init__(self, **kw):
1948
+ super(ComputeFQDNEnvironmentMixin, self).__init__(
1949
+ fqdn=socket.getfqdn(),
1950
+ **kw
1951
+ )
1952
+
1953
+
1954
class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
1955
"""Deduce pusher_email from pusher by appending an emaildomain."""
1956
1983
# actual *contents* of the change being reported, we only
1984
# choose based on the *type* of the change. Therefore we can
1985
# compute them once and for all:
1986
+ if not (refchange_recipients
1987
+ or announce_recipients
1988
+ or revision_recipients):
1989
+ raise ConfigurationException('No email recipients configured!')
1990
self.__refchange_recipients = refchange_recipients
1991
self.__announce_recipients = announce_recipients
1992
self.__revision_recipients = revision_recipients
2037
retval = config.get_recipients(name)
2038
if retval is not None:
2039
return retval
1914
- if len(names) == 1:
1915
- hint = 'Please set "%s.%s"' % (config.section, name)
2040
else:
1917
- hint = (
1918
- 'Please set one of the following:\n "%s"'
1919
- % ('"\n "'.join('%s.%s' % (config.section, name) for name in names))
1920
- )
1921
-
1922
- raise ConfigurationException(
1923
- 'The list of recipients for %s is not configured.\n%s' % (names[0], hint)
1924
- )
2041
+ return ''
2042
2043
2044
class ProjectdescEnvironmentMixin(Environment):
2073
class GenericEnvironment(
2074
ProjectdescEnvironmentMixin,
2075
ConfigMaxlinesEnvironmentMixin,
2076
+ ComputeFQDNEnvironmentMixin,
2077
ConfigFilterLinesEnvironmentMixin,
2078
ConfigRecipientsEnvironmentMixin,
2079
PusherDomainEnvironmentMixin,
2098
return self.osenv.get('GL_USER', 'unknown user')
2099
2100
2101
+class IncrementalDateTime(object):
2102
+ """Simple wrapper to give incremental date/times.
2103
+
2104
+ Each call will result in a date/time a second later than the
2105
+ previous call. This can be used to falsify email headers, to
2106
+ increase the likelihood that email clients sort the emails
2107
+ correctly."""
2108
+
2109
+ def __init__(self):
2110
+ self.time = time.time()
2111
+
2112
+ def next(self):
2113
+ formatted = formatdate(self.time, True)
2114
+ self.time += 1
2115
+ return formatted
2116
+
2117
+
2118
class GitoliteEnvironment(
2119
ProjectdescEnvironmentMixin,
2120
ConfigMaxlinesEnvironmentMixin,
2121
+ ComputeFQDNEnvironmentMixin,
2122
ConfigFilterLinesEnvironmentMixin,
2123
ConfigRecipientsEnvironmentMixin,
2124
PusherDomainEnvironmentMixin,
2323
# guarantee that one (and only one) email is generated for
2324
# each new commit.
2325
unhandled_sha1s = set(self.get_new_commits())
2326
+ send_date = IncrementalDateTime()
2327
for change in self.changes:
2328
# Check if we've got anyone to send to
2329
if not change.recipients:
2334
)
2335
else:
2336
sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2200
- mailer.send(change.generate_email(self, body_filter), change.recipients)
2337
+ extra_values = {'send_date' : send_date.next()}
2338
+ mailer.send(
2339
+ change.generate_email(self, body_filter, extra_values),
2340
+ change.recipients,
2341
+ )
2342
2343
sha1s = []
2344
for sha1 in reversed(list(self.get_new_commits(change))):
2358
for (num, sha1) in enumerate(sha1s):
2359
rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2360
if rev.recipients:
2220
- mailer.send(rev.generate_email(self, body_filter), rev.recipients)
2361
+ extra_values = {'send_date' : send_date.next()}
2362
+ mailer.send(
2363
+ rev.generate_email(self, body_filter, extra_values),
2364
+ rev.recipients,
2365
+ )
2366
2367
# Consistency check:
2368
if unhandled_sha1s:
2433
environment_mixins = [
2434
ProjectdescEnvironmentMixin,
2435
ConfigMaxlinesEnvironmentMixin,
2436
+ ComputeFQDNEnvironmentMixin,
2437
ConfigFilterLinesEnvironmentMixin,
2438
PusherDomainEnvironmentMixin,
2439
ConfigOptionsEnvironmentMixin,