##// END OF EJS Templates
py3: use a BytesParser in notify extension...
Denis Laxalde -
r43634:ef81de93 stable
parent child Browse files
Show More
@@ -1,574 +1,572 b''
1 # notify.py - email notifications for mercurial
1 # notify.py - email notifications for mercurial
2 #
2 #
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 '''hooks for sending email push notifications
8 '''hooks for sending email push notifications
9
9
10 This extension implements hooks to send email notifications when
10 This extension implements hooks to send email notifications when
11 changesets are sent from or received by the local repository.
11 changesets are sent from or received by the local repository.
12
12
13 First, enable the extension as explained in :hg:`help extensions`, and
13 First, enable the extension as explained in :hg:`help extensions`, and
14 register the hook you want to run. ``incoming`` and ``changegroup`` hooks
14 register the hook you want to run. ``incoming`` and ``changegroup`` hooks
15 are run when changesets are received, while ``outgoing`` hooks are for
15 are run when changesets are received, while ``outgoing`` hooks are for
16 changesets sent to another repository::
16 changesets sent to another repository::
17
17
18 [hooks]
18 [hooks]
19 # one email for each incoming changeset
19 # one email for each incoming changeset
20 incoming.notify = python:hgext.notify.hook
20 incoming.notify = python:hgext.notify.hook
21 # one email for all incoming changesets
21 # one email for all incoming changesets
22 changegroup.notify = python:hgext.notify.hook
22 changegroup.notify = python:hgext.notify.hook
23
23
24 # one email for all outgoing changesets
24 # one email for all outgoing changesets
25 outgoing.notify = python:hgext.notify.hook
25 outgoing.notify = python:hgext.notify.hook
26
26
27 This registers the hooks. To enable notification, subscribers must
27 This registers the hooks. To enable notification, subscribers must
28 be assigned to repositories. The ``[usersubs]`` section maps multiple
28 be assigned to repositories. The ``[usersubs]`` section maps multiple
29 repositories to a given recipient. The ``[reposubs]`` section maps
29 repositories to a given recipient. The ``[reposubs]`` section maps
30 multiple recipients to a single repository::
30 multiple recipients to a single repository::
31
31
32 [usersubs]
32 [usersubs]
33 # key is subscriber email, value is a comma-separated list of repo patterns
33 # key is subscriber email, value is a comma-separated list of repo patterns
34 user@host = pattern
34 user@host = pattern
35
35
36 [reposubs]
36 [reposubs]
37 # key is repo pattern, value is a comma-separated list of subscriber emails
37 # key is repo pattern, value is a comma-separated list of subscriber emails
38 pattern = user@host
38 pattern = user@host
39
39
40 A ``pattern`` is a ``glob`` matching the absolute path to a repository,
40 A ``pattern`` is a ``glob`` matching the absolute path to a repository,
41 optionally combined with a revset expression. A revset expression, if
41 optionally combined with a revset expression. A revset expression, if
42 present, is separated from the glob by a hash. Example::
42 present, is separated from the glob by a hash. Example::
43
43
44 [reposubs]
44 [reposubs]
45 */widgets#branch(release) = qa-team@example.com
45 */widgets#branch(release) = qa-team@example.com
46
46
47 This sends to ``qa-team@example.com`` whenever a changeset on the ``release``
47 This sends to ``qa-team@example.com`` whenever a changeset on the ``release``
48 branch triggers a notification in any repository ending in ``widgets``.
48 branch triggers a notification in any repository ending in ``widgets``.
49
49
50 In order to place them under direct user management, ``[usersubs]`` and
50 In order to place them under direct user management, ``[usersubs]`` and
51 ``[reposubs]`` sections may be placed in a separate ``hgrc`` file and
51 ``[reposubs]`` sections may be placed in a separate ``hgrc`` file and
52 incorporated by reference::
52 incorporated by reference::
53
53
54 [notify]
54 [notify]
55 config = /path/to/subscriptionsfile
55 config = /path/to/subscriptionsfile
56
56
57 Notifications will not be sent until the ``notify.test`` value is set
57 Notifications will not be sent until the ``notify.test`` value is set
58 to ``False``; see below.
58 to ``False``; see below.
59
59
60 Notifications content can be tweaked with the following configuration entries:
60 Notifications content can be tweaked with the following configuration entries:
61
61
62 notify.test
62 notify.test
63 If ``True``, print messages to stdout instead of sending them. Default: True.
63 If ``True``, print messages to stdout instead of sending them. Default: True.
64
64
65 notify.sources
65 notify.sources
66 Space-separated list of change sources. Notifications are activated only
66 Space-separated list of change sources. Notifications are activated only
67 when a changeset's source is in this list. Sources may be:
67 when a changeset's source is in this list. Sources may be:
68
68
69 :``serve``: changesets received via http or ssh
69 :``serve``: changesets received via http or ssh
70 :``pull``: changesets received via ``hg pull``
70 :``pull``: changesets received via ``hg pull``
71 :``unbundle``: changesets received via ``hg unbundle``
71 :``unbundle``: changesets received via ``hg unbundle``
72 :``push``: changesets sent or received via ``hg push``
72 :``push``: changesets sent or received via ``hg push``
73 :``bundle``: changesets sent via ``hg unbundle``
73 :``bundle``: changesets sent via ``hg unbundle``
74
74
75 Default: serve.
75 Default: serve.
76
76
77 notify.strip
77 notify.strip
78 Number of leading slashes to strip from url paths. By default, notifications
78 Number of leading slashes to strip from url paths. By default, notifications
79 reference repositories with their absolute path. ``notify.strip`` lets you
79 reference repositories with their absolute path. ``notify.strip`` lets you
80 turn them into relative paths. For example, ``notify.strip=3`` will change
80 turn them into relative paths. For example, ``notify.strip=3`` will change
81 ``/long/path/repository`` into ``repository``. Default: 0.
81 ``/long/path/repository`` into ``repository``. Default: 0.
82
82
83 notify.domain
83 notify.domain
84 Default email domain for sender or recipients with no explicit domain.
84 Default email domain for sender or recipients with no explicit domain.
85 It is also used for the domain part of the ``Message-Id`` when using
85 It is also used for the domain part of the ``Message-Id`` when using
86 ``notify.messageidseed``.
86 ``notify.messageidseed``.
87
87
88 notify.messageidseed
88 notify.messageidseed
89 Create deterministic ``Message-Id`` headers for the mails based on the seed
89 Create deterministic ``Message-Id`` headers for the mails based on the seed
90 and the revision identifier of the first commit in the changeset.
90 and the revision identifier of the first commit in the changeset.
91
91
92 notify.style
92 notify.style
93 Style file to use when formatting emails.
93 Style file to use when formatting emails.
94
94
95 notify.template
95 notify.template
96 Template to use when formatting emails.
96 Template to use when formatting emails.
97
97
98 notify.incoming
98 notify.incoming
99 Template to use when run as an incoming hook, overriding ``notify.template``.
99 Template to use when run as an incoming hook, overriding ``notify.template``.
100
100
101 notify.outgoing
101 notify.outgoing
102 Template to use when run as an outgoing hook, overriding ``notify.template``.
102 Template to use when run as an outgoing hook, overriding ``notify.template``.
103
103
104 notify.changegroup
104 notify.changegroup
105 Template to use when running as a changegroup hook, overriding
105 Template to use when running as a changegroup hook, overriding
106 ``notify.template``.
106 ``notify.template``.
107
107
108 notify.maxdiff
108 notify.maxdiff
109 Maximum number of diff lines to include in notification email. Set to 0
109 Maximum number of diff lines to include in notification email. Set to 0
110 to disable the diff, or -1 to include all of it. Default: 300.
110 to disable the diff, or -1 to include all of it. Default: 300.
111
111
112 notify.maxdiffstat
112 notify.maxdiffstat
113 Maximum number of diffstat lines to include in notification email. Set to -1
113 Maximum number of diffstat lines to include in notification email. Set to -1
114 to include all of it. Default: -1.
114 to include all of it. Default: -1.
115
115
116 notify.maxsubject
116 notify.maxsubject
117 Maximum number of characters in email's subject line. Default: 67.
117 Maximum number of characters in email's subject line. Default: 67.
118
118
119 notify.diffstat
119 notify.diffstat
120 Set to True to include a diffstat before diff content. Default: True.
120 Set to True to include a diffstat before diff content. Default: True.
121
121
122 notify.showfunc
122 notify.showfunc
123 If set, override ``diff.showfunc`` for the diff content. Default: None.
123 If set, override ``diff.showfunc`` for the diff content. Default: None.
124
124
125 notify.merge
125 notify.merge
126 If True, send notifications for merge changesets. Default: True.
126 If True, send notifications for merge changesets. Default: True.
127
127
128 notify.mbox
128 notify.mbox
129 If set, append mails to this mbox file instead of sending. Default: None.
129 If set, append mails to this mbox file instead of sending. Default: None.
130
130
131 notify.fromauthor
131 notify.fromauthor
132 If set, use the committer of the first changeset in a changegroup for
132 If set, use the committer of the first changeset in a changegroup for
133 the "From" field of the notification mail. If not set, take the user
133 the "From" field of the notification mail. If not set, take the user
134 from the pushing repo. Default: False.
134 from the pushing repo. Default: False.
135
135
136 If set, the following entries will also be used to customize the
136 If set, the following entries will also be used to customize the
137 notifications:
137 notifications:
138
138
139 email.from
139 email.from
140 Email ``From`` address to use if none can be found in the generated
140 Email ``From`` address to use if none can be found in the generated
141 email content.
141 email content.
142
142
143 web.baseurl
143 web.baseurl
144 Root repository URL to combine with repository paths when making
144 Root repository URL to combine with repository paths when making
145 references. See also ``notify.strip``.
145 references. See also ``notify.strip``.
146
146
147 '''
147 '''
148 from __future__ import absolute_import
148 from __future__ import absolute_import
149
149
150 import email.errors as emailerrors
150 import email.errors as emailerrors
151 import email.parser as emailparser
152 import fnmatch
151 import fnmatch
153 import hashlib
152 import hashlib
154 import socket
153 import socket
155 import time
154 import time
156
155
157 from mercurial.i18n import _
156 from mercurial.i18n import _
158 from mercurial import (
157 from mercurial import (
159 encoding,
158 encoding,
160 error,
159 error,
161 logcmdutil,
160 logcmdutil,
162 mail,
161 mail,
163 patch,
162 patch,
164 pycompat,
163 pycompat,
165 registrar,
164 registrar,
166 util,
165 util,
167 )
166 )
168 from mercurial.utils import (
167 from mercurial.utils import (
169 dateutil,
168 dateutil,
170 stringutil,
169 stringutil,
171 )
170 )
172
171
173 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
172 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
174 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
173 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
175 # be specifying the version(s) of Mercurial they are tested with, or
174 # be specifying the version(s) of Mercurial they are tested with, or
176 # leave the attribute unspecified.
175 # leave the attribute unspecified.
177 testedwith = b'ships-with-hg-core'
176 testedwith = b'ships-with-hg-core'
178
177
179 configtable = {}
178 configtable = {}
180 configitem = registrar.configitem(configtable)
179 configitem = registrar.configitem(configtable)
181
180
182 configitem(
181 configitem(
183 b'notify', b'changegroup', default=None,
182 b'notify', b'changegroup', default=None,
184 )
183 )
185 configitem(
184 configitem(
186 b'notify', b'config', default=None,
185 b'notify', b'config', default=None,
187 )
186 )
188 configitem(
187 configitem(
189 b'notify', b'diffstat', default=True,
188 b'notify', b'diffstat', default=True,
190 )
189 )
191 configitem(
190 configitem(
192 b'notify', b'domain', default=None,
191 b'notify', b'domain', default=None,
193 )
192 )
194 configitem(
193 configitem(
195 b'notify', b'messageidseed', default=None,
194 b'notify', b'messageidseed', default=None,
196 )
195 )
197 configitem(
196 configitem(
198 b'notify', b'fromauthor', default=None,
197 b'notify', b'fromauthor', default=None,
199 )
198 )
200 configitem(
199 configitem(
201 b'notify', b'incoming', default=None,
200 b'notify', b'incoming', default=None,
202 )
201 )
203 configitem(
202 configitem(
204 b'notify', b'maxdiff', default=300,
203 b'notify', b'maxdiff', default=300,
205 )
204 )
206 configitem(
205 configitem(
207 b'notify', b'maxdiffstat', default=-1,
206 b'notify', b'maxdiffstat', default=-1,
208 )
207 )
209 configitem(
208 configitem(
210 b'notify', b'maxsubject', default=67,
209 b'notify', b'maxsubject', default=67,
211 )
210 )
212 configitem(
211 configitem(
213 b'notify', b'mbox', default=None,
212 b'notify', b'mbox', default=None,
214 )
213 )
215 configitem(
214 configitem(
216 b'notify', b'merge', default=True,
215 b'notify', b'merge', default=True,
217 )
216 )
218 configitem(
217 configitem(
219 b'notify', b'outgoing', default=None,
218 b'notify', b'outgoing', default=None,
220 )
219 )
221 configitem(
220 configitem(
222 b'notify', b'sources', default=b'serve',
221 b'notify', b'sources', default=b'serve',
223 )
222 )
224 configitem(
223 configitem(
225 b'notify', b'showfunc', default=None,
224 b'notify', b'showfunc', default=None,
226 )
225 )
227 configitem(
226 configitem(
228 b'notify', b'strip', default=0,
227 b'notify', b'strip', default=0,
229 )
228 )
230 configitem(
229 configitem(
231 b'notify', b'style', default=None,
230 b'notify', b'style', default=None,
232 )
231 )
233 configitem(
232 configitem(
234 b'notify', b'template', default=None,
233 b'notify', b'template', default=None,
235 )
234 )
236 configitem(
235 configitem(
237 b'notify', b'test', default=True,
236 b'notify', b'test', default=True,
238 )
237 )
239
238
240 # template for single changeset can include email headers.
239 # template for single changeset can include email headers.
241 single_template = b'''
240 single_template = b'''
242 Subject: changeset in {webroot}: {desc|firstline|strip}
241 Subject: changeset in {webroot}: {desc|firstline|strip}
243 From: {author}
242 From: {author}
244
243
245 changeset {node|short} in {root}
244 changeset {node|short} in {root}
246 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
245 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
247 description:
246 description:
248 \t{desc|tabindent|strip}
247 \t{desc|tabindent|strip}
249 '''.lstrip()
248 '''.lstrip()
250
249
251 # template for multiple changesets should not contain email headers,
250 # template for multiple changesets should not contain email headers,
252 # because only first set of headers will be used and result will look
251 # because only first set of headers will be used and result will look
253 # strange.
252 # strange.
254 multiple_template = b'''
253 multiple_template = b'''
255 changeset {node|short} in {root}
254 changeset {node|short} in {root}
256 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
255 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
257 summary: {desc|firstline}
256 summary: {desc|firstline}
258 '''
257 '''
259
258
260 deftemplates = {
259 deftemplates = {
261 b'changegroup': multiple_template,
260 b'changegroup': multiple_template,
262 }
261 }
263
262
264
263
265 class notifier(object):
264 class notifier(object):
266 '''email notification class.'''
265 '''email notification class.'''
267
266
268 def __init__(self, ui, repo, hooktype):
267 def __init__(self, ui, repo, hooktype):
269 self.ui = ui
268 self.ui = ui
270 cfg = self.ui.config(b'notify', b'config')
269 cfg = self.ui.config(b'notify', b'config')
271 if cfg:
270 if cfg:
272 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
271 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
273 self.repo = repo
272 self.repo = repo
274 self.stripcount = int(self.ui.config(b'notify', b'strip'))
273 self.stripcount = int(self.ui.config(b'notify', b'strip'))
275 self.root = self.strip(self.repo.root)
274 self.root = self.strip(self.repo.root)
276 self.domain = self.ui.config(b'notify', b'domain')
275 self.domain = self.ui.config(b'notify', b'domain')
277 self.mbox = self.ui.config(b'notify', b'mbox')
276 self.mbox = self.ui.config(b'notify', b'mbox')
278 self.test = self.ui.configbool(b'notify', b'test')
277 self.test = self.ui.configbool(b'notify', b'test')
279 self.charsets = mail._charsets(self.ui)
278 self.charsets = mail._charsets(self.ui)
280 self.subs = self.subscribers()
279 self.subs = self.subscribers()
281 self.merge = self.ui.configbool(b'notify', b'merge')
280 self.merge = self.ui.configbool(b'notify', b'merge')
282 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
281 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
283 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
282 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
284 if self.showfunc is None:
283 if self.showfunc is None:
285 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
284 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
286
285
287 mapfile = None
286 mapfile = None
288 template = self.ui.config(b'notify', hooktype) or self.ui.config(
287 template = self.ui.config(b'notify', hooktype) or self.ui.config(
289 b'notify', b'template'
288 b'notify', b'template'
290 )
289 )
291 if not template:
290 if not template:
292 mapfile = self.ui.config(b'notify', b'style')
291 mapfile = self.ui.config(b'notify', b'style')
293 if not mapfile and not template:
292 if not mapfile and not template:
294 template = deftemplates.get(hooktype) or single_template
293 template = deftemplates.get(hooktype) or single_template
295 spec = logcmdutil.templatespec(template, mapfile)
294 spec = logcmdutil.templatespec(template, mapfile)
296 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
295 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
297
296
298 def strip(self, path):
297 def strip(self, path):
299 '''strip leading slashes from local path, turn into web-safe path.'''
298 '''strip leading slashes from local path, turn into web-safe path.'''
300
299
301 path = util.pconvert(path)
300 path = util.pconvert(path)
302 count = self.stripcount
301 count = self.stripcount
303 while count > 0:
302 while count > 0:
304 c = path.find(b'/')
303 c = path.find(b'/')
305 if c == -1:
304 if c == -1:
306 break
305 break
307 path = path[c + 1 :]
306 path = path[c + 1 :]
308 count -= 1
307 count -= 1
309 return path
308 return path
310
309
311 def fixmail(self, addr):
310 def fixmail(self, addr):
312 '''try to clean up email addresses.'''
311 '''try to clean up email addresses.'''
313
312
314 addr = stringutil.email(addr.strip())
313 addr = stringutil.email(addr.strip())
315 if self.domain:
314 if self.domain:
316 a = addr.find(b'@localhost')
315 a = addr.find(b'@localhost')
317 if a != -1:
316 if a != -1:
318 addr = addr[:a]
317 addr = addr[:a]
319 if b'@' not in addr:
318 if b'@' not in addr:
320 return addr + b'@' + self.domain
319 return addr + b'@' + self.domain
321 return addr
320 return addr
322
321
323 def subscribers(self):
322 def subscribers(self):
324 '''return list of email addresses of subscribers to this repo.'''
323 '''return list of email addresses of subscribers to this repo.'''
325 subs = set()
324 subs = set()
326 for user, pats in self.ui.configitems(b'usersubs'):
325 for user, pats in self.ui.configitems(b'usersubs'):
327 for pat in pats.split(b','):
326 for pat in pats.split(b','):
328 if b'#' in pat:
327 if b'#' in pat:
329 pat, revs = pat.split(b'#', 1)
328 pat, revs = pat.split(b'#', 1)
330 else:
329 else:
331 revs = None
330 revs = None
332 if fnmatch.fnmatch(self.repo.root, pat.strip()):
331 if fnmatch.fnmatch(self.repo.root, pat.strip()):
333 subs.add((self.fixmail(user), revs))
332 subs.add((self.fixmail(user), revs))
334 for pat, users in self.ui.configitems(b'reposubs'):
333 for pat, users in self.ui.configitems(b'reposubs'):
335 if b'#' in pat:
334 if b'#' in pat:
336 pat, revs = pat.split(b'#', 1)
335 pat, revs = pat.split(b'#', 1)
337 else:
336 else:
338 revs = None
337 revs = None
339 if fnmatch.fnmatch(self.repo.root, pat):
338 if fnmatch.fnmatch(self.repo.root, pat):
340 for user in users.split(b','):
339 for user in users.split(b','):
341 subs.add((self.fixmail(user), revs))
340 subs.add((self.fixmail(user), revs))
342 return [
341 return [
343 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
342 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
344 for s, r in sorted(subs)
343 for s, r in sorted(subs)
345 ]
344 ]
346
345
347 def node(self, ctx, **props):
346 def node(self, ctx, **props):
348 '''format one changeset, unless it is a suppressed merge.'''
347 '''format one changeset, unless it is a suppressed merge.'''
349 if not self.merge and len(ctx.parents()) > 1:
348 if not self.merge and len(ctx.parents()) > 1:
350 return False
349 return False
351 self.t.show(
350 self.t.show(
352 ctx,
351 ctx,
353 changes=ctx.changeset(),
352 changes=ctx.changeset(),
354 baseurl=self.ui.config(b'web', b'baseurl'),
353 baseurl=self.ui.config(b'web', b'baseurl'),
355 root=self.repo.root,
354 root=self.repo.root,
356 webroot=self.root,
355 webroot=self.root,
357 **props
356 **props
358 )
357 )
359 return True
358 return True
360
359
361 def skipsource(self, source):
360 def skipsource(self, source):
362 '''true if incoming changes from this source should be skipped.'''
361 '''true if incoming changes from this source should be skipped.'''
363 ok_sources = self.ui.config(b'notify', b'sources').split()
362 ok_sources = self.ui.config(b'notify', b'sources').split()
364 return source not in ok_sources
363 return source not in ok_sources
365
364
366 def send(self, ctx, count, data):
365 def send(self, ctx, count, data):
367 '''send message.'''
366 '''send message.'''
368
367
369 # Select subscribers by revset
368 # Select subscribers by revset
370 subs = set()
369 subs = set()
371 for sub, spec in self.subs:
370 for sub, spec in self.subs:
372 if spec is None:
371 if spec is None:
373 subs.add(sub)
372 subs.add(sub)
374 continue
373 continue
375 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
374 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
376 if len(revs):
375 if len(revs):
377 subs.add(sub)
376 subs.add(sub)
378 continue
377 continue
379 if len(subs) == 0:
378 if len(subs) == 0:
380 self.ui.debug(
379 self.ui.debug(
381 b'notify: no subscribers to selected repo and revset\n'
380 b'notify: no subscribers to selected repo and revset\n'
382 )
381 )
383 return
382 return
384
383
385 p = emailparser.Parser()
386 try:
384 try:
387 msg = p.parsestr(encoding.strfromlocal(data))
385 msg = mail.parsebytes(data)
388 except emailerrors.MessageParseError as inst:
386 except emailerrors.MessageParseError as inst:
389 raise error.Abort(inst)
387 raise error.Abort(inst)
390
388
391 # store sender and subject
389 # store sender and subject
392 sender = msg[r'From']
390 sender = msg[r'From']
393 subject = msg[r'Subject']
391 subject = msg[r'Subject']
394 if sender is not None:
392 if sender is not None:
395 sender = encoding.strtolocal(sender)
393 sender = encoding.strtolocal(sender)
396 if subject is not None:
394 if subject is not None:
397 subject = encoding.strtolocal(subject)
395 subject = encoding.strtolocal(subject)
398 del msg[r'From'], msg[r'Subject']
396 del msg[r'From'], msg[r'Subject']
399
397
400 if not msg.is_multipart():
398 if not msg.is_multipart():
401 # create fresh mime message from scratch
399 # create fresh mime message from scratch
402 # (multipart templates must take care of this themselves)
400 # (multipart templates must take care of this themselves)
403 headers = msg.items()
401 headers = msg.items()
404 payload = msg.get_payload()
402 payload = msg.get_payload()
405 # for notification prefer readability over data precision
403 # for notification prefer readability over data precision
406 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
404 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
407 # reinstate custom headers
405 # reinstate custom headers
408 for k, v in headers:
406 for k, v in headers:
409 msg[k] = v
407 msg[k] = v
410
408
411 msg[r'Date'] = encoding.strfromlocal(
409 msg[r'Date'] = encoding.strfromlocal(
412 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
410 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
413 )
411 )
414
412
415 # try to make subject line exist and be useful
413 # try to make subject line exist and be useful
416 if not subject:
414 if not subject:
417 if count > 1:
415 if count > 1:
418 subject = _(b'%s: %d new changesets') % (self.root, count)
416 subject = _(b'%s: %d new changesets') % (self.root, count)
419 else:
417 else:
420 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
418 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
421 subject = b'%s: %s' % (self.root, s)
419 subject = b'%s: %s' % (self.root, s)
422 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
420 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
423 if maxsubject:
421 if maxsubject:
424 subject = stringutil.ellipsis(subject, maxsubject)
422 subject = stringutil.ellipsis(subject, maxsubject)
425 msg[r'Subject'] = encoding.strfromlocal(
423 msg[r'Subject'] = encoding.strfromlocal(
426 mail.headencode(self.ui, subject, self.charsets, self.test)
424 mail.headencode(self.ui, subject, self.charsets, self.test)
427 )
425 )
428
426
429 # try to make message have proper sender
427 # try to make message have proper sender
430 if not sender:
428 if not sender:
431 sender = self.ui.config(b'email', b'from') or self.ui.username()
429 sender = self.ui.config(b'email', b'from') or self.ui.username()
432 if b'@' not in sender or b'@localhost' in sender:
430 if b'@' not in sender or b'@localhost' in sender:
433 sender = self.fixmail(sender)
431 sender = self.fixmail(sender)
434 msg[r'From'] = encoding.strfromlocal(
432 msg[r'From'] = encoding.strfromlocal(
435 mail.addressencode(self.ui, sender, self.charsets, self.test)
433 mail.addressencode(self.ui, sender, self.charsets, self.test)
436 )
434 )
437
435
438 msg[r'X-Hg-Notification'] = r'changeset %s' % ctx
436 msg[r'X-Hg-Notification'] = r'changeset %s' % ctx
439 if not msg[r'Message-Id']:
437 if not msg[r'Message-Id']:
440 msg[r'Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
438 msg[r'Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
441 msg[r'To'] = encoding.strfromlocal(b', '.join(sorted(subs)))
439 msg[r'To'] = encoding.strfromlocal(b', '.join(sorted(subs)))
442
440
443 msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
441 msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
444 if self.test:
442 if self.test:
445 self.ui.write(msgtext)
443 self.ui.write(msgtext)
446 if not msgtext.endswith(b'\n'):
444 if not msgtext.endswith(b'\n'):
447 self.ui.write(b'\n')
445 self.ui.write(b'\n')
448 else:
446 else:
449 self.ui.status(
447 self.ui.status(
450 _(b'notify: sending %d subscribers %d changes\n')
448 _(b'notify: sending %d subscribers %d changes\n')
451 % (len(subs), count)
449 % (len(subs), count)
452 )
450 )
453 mail.sendmail(
451 mail.sendmail(
454 self.ui,
452 self.ui,
455 stringutil.email(msg[r'From']),
453 stringutil.email(msg[r'From']),
456 subs,
454 subs,
457 msgtext,
455 msgtext,
458 mbox=self.mbox,
456 mbox=self.mbox,
459 )
457 )
460
458
461 def diff(self, ctx, ref=None):
459 def diff(self, ctx, ref=None):
462
460
463 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
461 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
464 prev = ctx.p1().node()
462 prev = ctx.p1().node()
465 if ref:
463 if ref:
466 ref = ref.node()
464 ref = ref.node()
467 else:
465 else:
468 ref = ctx.node()
466 ref = ctx.node()
469 diffopts = patch.diffallopts(self.ui)
467 diffopts = patch.diffallopts(self.ui)
470 diffopts.showfunc = self.showfunc
468 diffopts.showfunc = self.showfunc
471 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
469 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
472 difflines = b''.join(chunks).splitlines()
470 difflines = b''.join(chunks).splitlines()
473
471
474 if self.ui.configbool(b'notify', b'diffstat'):
472 if self.ui.configbool(b'notify', b'diffstat'):
475 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
473 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
476 s = patch.diffstat(difflines)
474 s = patch.diffstat(difflines)
477 # s may be nil, don't include the header if it is
475 # s may be nil, don't include the header if it is
478 if s:
476 if s:
479 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
477 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
480 s = s.split(b"\n")
478 s = s.split(b"\n")
481 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
479 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
482 self.ui.write(msg % (len(s) - 2, maxdiffstat))
480 self.ui.write(msg % (len(s) - 2, maxdiffstat))
483 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
481 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
484 else:
482 else:
485 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
483 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
486
484
487 if maxdiff == 0:
485 if maxdiff == 0:
488 return
486 return
489 elif maxdiff > 0 and len(difflines) > maxdiff:
487 elif maxdiff > 0 and len(difflines) > maxdiff:
490 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
488 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
491 self.ui.write(msg % (len(difflines), maxdiff))
489 self.ui.write(msg % (len(difflines), maxdiff))
492 difflines = difflines[:maxdiff]
490 difflines = difflines[:maxdiff]
493 elif difflines:
491 elif difflines:
494 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
492 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
495
493
496 self.ui.write(b"\n".join(difflines))
494 self.ui.write(b"\n".join(difflines))
497
495
498
496
499 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
497 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
500 '''send email notifications to interested subscribers.
498 '''send email notifications to interested subscribers.
501
499
502 if used as changegroup hook, send one email for all changesets in
500 if used as changegroup hook, send one email for all changesets in
503 changegroup. else send one email per changeset.'''
501 changegroup. else send one email per changeset.'''
504
502
505 n = notifier(ui, repo, hooktype)
503 n = notifier(ui, repo, hooktype)
506 ctx = repo.unfiltered()[node]
504 ctx = repo.unfiltered()[node]
507
505
508 if not n.subs:
506 if not n.subs:
509 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
507 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
510 return
508 return
511 if n.skipsource(source):
509 if n.skipsource(source):
512 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
510 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
513 return
511 return
514
512
515 ui.pushbuffer()
513 ui.pushbuffer()
516 data = b''
514 data = b''
517 count = 0
515 count = 0
518 author = b''
516 author = b''
519 if hooktype == b'changegroup' or hooktype == b'outgoing':
517 if hooktype == b'changegroup' or hooktype == b'outgoing':
520 for rev in repo.changelog.revs(start=ctx.rev()):
518 for rev in repo.changelog.revs(start=ctx.rev()):
521 if n.node(repo[rev]):
519 if n.node(repo[rev]):
522 count += 1
520 count += 1
523 if not author:
521 if not author:
524 author = repo[rev].user()
522 author = repo[rev].user()
525 else:
523 else:
526 data += ui.popbuffer()
524 data += ui.popbuffer()
527 ui.note(
525 ui.note(
528 _(b'notify: suppressing notification for merge %d:%s\n')
526 _(b'notify: suppressing notification for merge %d:%s\n')
529 % (rev, repo[rev].hex()[:12])
527 % (rev, repo[rev].hex()[:12])
530 )
528 )
531 ui.pushbuffer()
529 ui.pushbuffer()
532 if count:
530 if count:
533 n.diff(ctx, repo[b'tip'])
531 n.diff(ctx, repo[b'tip'])
534 elif ctx.rev() in repo:
532 elif ctx.rev() in repo:
535 if not n.node(ctx):
533 if not n.node(ctx):
536 ui.popbuffer()
534 ui.popbuffer()
537 ui.note(
535 ui.note(
538 _(b'notify: suppressing notification for merge %d:%s\n')
536 _(b'notify: suppressing notification for merge %d:%s\n')
539 % (ctx.rev(), ctx.hex()[:12])
537 % (ctx.rev(), ctx.hex()[:12])
540 )
538 )
541 return
539 return
542 count += 1
540 count += 1
543 n.diff(ctx)
541 n.diff(ctx)
544 if not author:
542 if not author:
545 author = ctx.user()
543 author = ctx.user()
546
544
547 data += ui.popbuffer()
545 data += ui.popbuffer()
548 fromauthor = ui.config(b'notify', b'fromauthor')
546 fromauthor = ui.config(b'notify', b'fromauthor')
549 if author and fromauthor:
547 if author and fromauthor:
550 data = b'\n'.join([b'From: %s' % author, data])
548 data = b'\n'.join([b'From: %s' % author, data])
551
549
552 if count:
550 if count:
553 n.send(ctx, count, data)
551 n.send(ctx, count, data)
554
552
555
553
556 def messageid(ctx, domain, messageidseed):
554 def messageid(ctx, domain, messageidseed):
557 if domain and messageidseed:
555 if domain and messageidseed:
558 host = domain
556 host = domain
559 else:
557 else:
560 host = encoding.strtolocal(socket.getfqdn())
558 host = encoding.strtolocal(socket.getfqdn())
561 if messageidseed:
559 if messageidseed:
562 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
560 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
563 messageid = b'<hg.%s@%s>' % (
561 messageid = b'<hg.%s@%s>' % (
564 pycompat.sysbytes(messagehash.hexdigest()[:64]),
562 pycompat.sysbytes(messagehash.hexdigest()[:64]),
565 host,
563 host,
566 )
564 )
567 else:
565 else:
568 messageid = b'<hg.%s.%d.%d@%s>' % (
566 messageid = b'<hg.%s.%d.%d@%s>' % (
569 ctx,
567 ctx,
570 int(time.time()),
568 int(time.time()),
571 hash(ctx.repo().root),
569 hash(ctx.repo().root),
572 host,
570 host,
573 )
571 )
574 return encoding.strfromlocal(messageid)
572 return encoding.strfromlocal(messageid)
@@ -1,474 +1,481 b''
1 # mail.py - mail sending bits for mercurial
1 # mail.py - mail sending bits for mercurial
2 #
2 #
3 # Copyright 2006 Matt Mackall <mpm@selenic.com>
3 # Copyright 2006 Matt Mackall <mpm@selenic.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 from __future__ import absolute_import
8 from __future__ import absolute_import
9
9
10 import email
10 import email
11 import email.charset
11 import email.charset
12 import email.generator
12 import email.generator
13 import email.header
13 import email.header
14 import email.message
14 import email.message
15 import email.parser
15 import email.parser
16 import io
16 import io
17 import os
17 import os
18 import smtplib
18 import smtplib
19 import socket
19 import socket
20 import time
20 import time
21
21
22 from .i18n import _
22 from .i18n import _
23 from .pycompat import (
23 from .pycompat import (
24 getattr,
24 getattr,
25 open,
25 open,
26 )
26 )
27 from . import (
27 from . import (
28 encoding,
28 encoding,
29 error,
29 error,
30 pycompat,
30 pycompat,
31 sslutil,
31 sslutil,
32 util,
32 util,
33 )
33 )
34 from .utils import (
34 from .utils import (
35 procutil,
35 procutil,
36 stringutil,
36 stringutil,
37 )
37 )
38
38
39
39
40 class STARTTLS(smtplib.SMTP):
40 class STARTTLS(smtplib.SMTP):
41 '''Derived class to verify the peer certificate for STARTTLS.
41 '''Derived class to verify the peer certificate for STARTTLS.
42
42
43 This class allows to pass any keyword arguments to SSL socket creation.
43 This class allows to pass any keyword arguments to SSL socket creation.
44 '''
44 '''
45
45
46 def __init__(self, ui, host=None, **kwargs):
46 def __init__(self, ui, host=None, **kwargs):
47 smtplib.SMTP.__init__(self, **kwargs)
47 smtplib.SMTP.__init__(self, **kwargs)
48 self._ui = ui
48 self._ui = ui
49 self._host = host
49 self._host = host
50
50
51 def starttls(self, keyfile=None, certfile=None):
51 def starttls(self, keyfile=None, certfile=None):
52 if not self.has_extn("starttls"):
52 if not self.has_extn("starttls"):
53 msg = b"STARTTLS extension not supported by server"
53 msg = b"STARTTLS extension not supported by server"
54 raise smtplib.SMTPException(msg)
54 raise smtplib.SMTPException(msg)
55 (resp, reply) = self.docmd("STARTTLS")
55 (resp, reply) = self.docmd("STARTTLS")
56 if resp == 220:
56 if resp == 220:
57 self.sock = sslutil.wrapsocket(
57 self.sock = sslutil.wrapsocket(
58 self.sock,
58 self.sock,
59 keyfile,
59 keyfile,
60 certfile,
60 certfile,
61 ui=self._ui,
61 ui=self._ui,
62 serverhostname=self._host,
62 serverhostname=self._host,
63 )
63 )
64 self.file = self.sock.makefile("rb")
64 self.file = self.sock.makefile("rb")
65 self.helo_resp = None
65 self.helo_resp = None
66 self.ehlo_resp = None
66 self.ehlo_resp = None
67 self.esmtp_features = {}
67 self.esmtp_features = {}
68 self.does_esmtp = 0
68 self.does_esmtp = 0
69 return (resp, reply)
69 return (resp, reply)
70
70
71
71
72 class SMTPS(smtplib.SMTP):
72 class SMTPS(smtplib.SMTP):
73 '''Derived class to verify the peer certificate for SMTPS.
73 '''Derived class to verify the peer certificate for SMTPS.
74
74
75 This class allows to pass any keyword arguments to SSL socket creation.
75 This class allows to pass any keyword arguments to SSL socket creation.
76 '''
76 '''
77
77
78 def __init__(self, ui, keyfile=None, certfile=None, host=None, **kwargs):
78 def __init__(self, ui, keyfile=None, certfile=None, host=None, **kwargs):
79 self.keyfile = keyfile
79 self.keyfile = keyfile
80 self.certfile = certfile
80 self.certfile = certfile
81 smtplib.SMTP.__init__(self, **kwargs)
81 smtplib.SMTP.__init__(self, **kwargs)
82 self._host = host
82 self._host = host
83 self.default_port = smtplib.SMTP_SSL_PORT
83 self.default_port = smtplib.SMTP_SSL_PORT
84 self._ui = ui
84 self._ui = ui
85
85
86 def _get_socket(self, host, port, timeout):
86 def _get_socket(self, host, port, timeout):
87 if self.debuglevel > 0:
87 if self.debuglevel > 0:
88 self._ui.debug(b'connect: %r\n' % ((host, port),))
88 self._ui.debug(b'connect: %r\n' % ((host, port),))
89 new_socket = socket.create_connection((host, port), timeout)
89 new_socket = socket.create_connection((host, port), timeout)
90 new_socket = sslutil.wrapsocket(
90 new_socket = sslutil.wrapsocket(
91 new_socket,
91 new_socket,
92 self.keyfile,
92 self.keyfile,
93 self.certfile,
93 self.certfile,
94 ui=self._ui,
94 ui=self._ui,
95 serverhostname=self._host,
95 serverhostname=self._host,
96 )
96 )
97 self.file = new_socket.makefile(r'rb')
97 self.file = new_socket.makefile(r'rb')
98 return new_socket
98 return new_socket
99
99
100
100
101 def _pyhastls():
101 def _pyhastls():
102 """Returns true iff Python has TLS support, false otherwise."""
102 """Returns true iff Python has TLS support, false otherwise."""
103 try:
103 try:
104 import ssl
104 import ssl
105
105
106 getattr(ssl, 'HAS_TLS', False)
106 getattr(ssl, 'HAS_TLS', False)
107 return True
107 return True
108 except ImportError:
108 except ImportError:
109 return False
109 return False
110
110
111
111
112 def _smtp(ui):
112 def _smtp(ui):
113 '''build an smtp connection and return a function to send mail'''
113 '''build an smtp connection and return a function to send mail'''
114 local_hostname = ui.config(b'smtp', b'local_hostname')
114 local_hostname = ui.config(b'smtp', b'local_hostname')
115 tls = ui.config(b'smtp', b'tls')
115 tls = ui.config(b'smtp', b'tls')
116 # backward compatible: when tls = true, we use starttls.
116 # backward compatible: when tls = true, we use starttls.
117 starttls = tls == b'starttls' or stringutil.parsebool(tls)
117 starttls = tls == b'starttls' or stringutil.parsebool(tls)
118 smtps = tls == b'smtps'
118 smtps = tls == b'smtps'
119 if (starttls or smtps) and not _pyhastls():
119 if (starttls or smtps) and not _pyhastls():
120 raise error.Abort(_(b"can't use TLS: Python SSL support not installed"))
120 raise error.Abort(_(b"can't use TLS: Python SSL support not installed"))
121 mailhost = ui.config(b'smtp', b'host')
121 mailhost = ui.config(b'smtp', b'host')
122 if not mailhost:
122 if not mailhost:
123 raise error.Abort(_(b'smtp.host not configured - cannot send mail'))
123 raise error.Abort(_(b'smtp.host not configured - cannot send mail'))
124 if smtps:
124 if smtps:
125 ui.note(_(b'(using smtps)\n'))
125 ui.note(_(b'(using smtps)\n'))
126 s = SMTPS(ui, local_hostname=local_hostname, host=mailhost)
126 s = SMTPS(ui, local_hostname=local_hostname, host=mailhost)
127 elif starttls:
127 elif starttls:
128 s = STARTTLS(ui, local_hostname=local_hostname, host=mailhost)
128 s = STARTTLS(ui, local_hostname=local_hostname, host=mailhost)
129 else:
129 else:
130 s = smtplib.SMTP(local_hostname=local_hostname)
130 s = smtplib.SMTP(local_hostname=local_hostname)
131 if smtps:
131 if smtps:
132 defaultport = 465
132 defaultport = 465
133 else:
133 else:
134 defaultport = 25
134 defaultport = 25
135 mailport = util.getport(ui.config(b'smtp', b'port', defaultport))
135 mailport = util.getport(ui.config(b'smtp', b'port', defaultport))
136 ui.note(_(b'sending mail: smtp host %s, port %d\n') % (mailhost, mailport))
136 ui.note(_(b'sending mail: smtp host %s, port %d\n') % (mailhost, mailport))
137 s.connect(host=mailhost, port=mailport)
137 s.connect(host=mailhost, port=mailport)
138 if starttls:
138 if starttls:
139 ui.note(_(b'(using starttls)\n'))
139 ui.note(_(b'(using starttls)\n'))
140 s.ehlo()
140 s.ehlo()
141 s.starttls()
141 s.starttls()
142 s.ehlo()
142 s.ehlo()
143 if starttls or smtps:
143 if starttls or smtps:
144 ui.note(_(b'(verifying remote certificate)\n'))
144 ui.note(_(b'(verifying remote certificate)\n'))
145 sslutil.validatesocket(s.sock)
145 sslutil.validatesocket(s.sock)
146 username = ui.config(b'smtp', b'username')
146 username = ui.config(b'smtp', b'username')
147 password = ui.config(b'smtp', b'password')
147 password = ui.config(b'smtp', b'password')
148 if username:
148 if username:
149 if password:
149 if password:
150 password = encoding.strfromlocal(password)
150 password = encoding.strfromlocal(password)
151 else:
151 else:
152 password = ui.getpass()
152 password = ui.getpass()
153 if username and password:
153 if username and password:
154 ui.note(_(b'(authenticating to mail server as %s)\n') % username)
154 ui.note(_(b'(authenticating to mail server as %s)\n') % username)
155 username = encoding.strfromlocal(username)
155 username = encoding.strfromlocal(username)
156 try:
156 try:
157 s.login(username, password)
157 s.login(username, password)
158 except smtplib.SMTPException as inst:
158 except smtplib.SMTPException as inst:
159 raise error.Abort(inst)
159 raise error.Abort(inst)
160
160
161 def send(sender, recipients, msg):
161 def send(sender, recipients, msg):
162 try:
162 try:
163 return s.sendmail(sender, recipients, msg)
163 return s.sendmail(sender, recipients, msg)
164 except smtplib.SMTPRecipientsRefused as inst:
164 except smtplib.SMTPRecipientsRefused as inst:
165 recipients = [r[1] for r in inst.recipients.values()]
165 recipients = [r[1] for r in inst.recipients.values()]
166 raise error.Abort(b'\n' + b'\n'.join(recipients))
166 raise error.Abort(b'\n' + b'\n'.join(recipients))
167 except smtplib.SMTPException as inst:
167 except smtplib.SMTPException as inst:
168 raise error.Abort(inst)
168 raise error.Abort(inst)
169
169
170 return send
170 return send
171
171
172
172
173 def _sendmail(ui, sender, recipients, msg):
173 def _sendmail(ui, sender, recipients, msg):
174 '''send mail using sendmail.'''
174 '''send mail using sendmail.'''
175 program = ui.config(b'email', b'method')
175 program = ui.config(b'email', b'method')
176
176
177 def stremail(x):
177 def stremail(x):
178 return procutil.shellquote(stringutil.email(encoding.strtolocal(x)))
178 return procutil.shellquote(stringutil.email(encoding.strtolocal(x)))
179
179
180 cmdline = b'%s -f %s %s' % (
180 cmdline = b'%s -f %s %s' % (
181 program,
181 program,
182 stremail(sender),
182 stremail(sender),
183 b' '.join(map(stremail, recipients)),
183 b' '.join(map(stremail, recipients)),
184 )
184 )
185 ui.note(_(b'sending mail: %s\n') % cmdline)
185 ui.note(_(b'sending mail: %s\n') % cmdline)
186 fp = procutil.popen(cmdline, b'wb')
186 fp = procutil.popen(cmdline, b'wb')
187 fp.write(util.tonativeeol(msg))
187 fp.write(util.tonativeeol(msg))
188 ret = fp.close()
188 ret = fp.close()
189 if ret:
189 if ret:
190 raise error.Abort(
190 raise error.Abort(
191 b'%s %s'
191 b'%s %s'
192 % (
192 % (
193 os.path.basename(program.split(None, 1)[0]),
193 os.path.basename(program.split(None, 1)[0]),
194 procutil.explainexit(ret),
194 procutil.explainexit(ret),
195 )
195 )
196 )
196 )
197
197
198
198
199 def _mbox(mbox, sender, recipients, msg):
199 def _mbox(mbox, sender, recipients, msg):
200 '''write mails to mbox'''
200 '''write mails to mbox'''
201 fp = open(mbox, b'ab+')
201 fp = open(mbox, b'ab+')
202 # Should be time.asctime(), but Windows prints 2-characters day
202 # Should be time.asctime(), but Windows prints 2-characters day
203 # of month instead of one. Make them print the same thing.
203 # of month instead of one. Make them print the same thing.
204 date = time.strftime(r'%a %b %d %H:%M:%S %Y', time.localtime())
204 date = time.strftime(r'%a %b %d %H:%M:%S %Y', time.localtime())
205 fp.write(
205 fp.write(
206 b'From %s %s\n'
206 b'From %s %s\n'
207 % (encoding.strtolocal(sender), encoding.strtolocal(date))
207 % (encoding.strtolocal(sender), encoding.strtolocal(date))
208 )
208 )
209 fp.write(msg)
209 fp.write(msg)
210 fp.write(b'\n\n')
210 fp.write(b'\n\n')
211 fp.close()
211 fp.close()
212
212
213
213
214 def connect(ui, mbox=None):
214 def connect(ui, mbox=None):
215 '''make a mail connection. return a function to send mail.
215 '''make a mail connection. return a function to send mail.
216 call as sendmail(sender, list-of-recipients, msg).'''
216 call as sendmail(sender, list-of-recipients, msg).'''
217 if mbox:
217 if mbox:
218 open(mbox, b'wb').close()
218 open(mbox, b'wb').close()
219 return lambda s, r, m: _mbox(mbox, s, r, m)
219 return lambda s, r, m: _mbox(mbox, s, r, m)
220 if ui.config(b'email', b'method') == b'smtp':
220 if ui.config(b'email', b'method') == b'smtp':
221 return _smtp(ui)
221 return _smtp(ui)
222 return lambda s, r, m: _sendmail(ui, s, r, m)
222 return lambda s, r, m: _sendmail(ui, s, r, m)
223
223
224
224
225 def sendmail(ui, sender, recipients, msg, mbox=None):
225 def sendmail(ui, sender, recipients, msg, mbox=None):
226 send = connect(ui, mbox=mbox)
226 send = connect(ui, mbox=mbox)
227 return send(sender, recipients, msg)
227 return send(sender, recipients, msg)
228
228
229
229
230 def validateconfig(ui):
230 def validateconfig(ui):
231 '''determine if we have enough config data to try sending email.'''
231 '''determine if we have enough config data to try sending email.'''
232 method = ui.config(b'email', b'method')
232 method = ui.config(b'email', b'method')
233 if method == b'smtp':
233 if method == b'smtp':
234 if not ui.config(b'smtp', b'host'):
234 if not ui.config(b'smtp', b'host'):
235 raise error.Abort(
235 raise error.Abort(
236 _(
236 _(
237 b'smtp specified as email transport, '
237 b'smtp specified as email transport, '
238 b'but no smtp host configured'
238 b'but no smtp host configured'
239 )
239 )
240 )
240 )
241 else:
241 else:
242 if not procutil.findexe(method):
242 if not procutil.findexe(method):
243 raise error.Abort(
243 raise error.Abort(
244 _(b'%r specified as email transport, but not in PATH') % method
244 _(b'%r specified as email transport, but not in PATH') % method
245 )
245 )
246
246
247
247
248 def codec2iana(cs):
248 def codec2iana(cs):
249 ''''''
249 ''''''
250 cs = pycompat.sysbytes(email.charset.Charset(cs).input_charset.lower())
250 cs = pycompat.sysbytes(email.charset.Charset(cs).input_charset.lower())
251
251
252 # "latin1" normalizes to "iso8859-1", standard calls for "iso-8859-1"
252 # "latin1" normalizes to "iso8859-1", standard calls for "iso-8859-1"
253 if cs.startswith(b"iso") and not cs.startswith(b"iso-"):
253 if cs.startswith(b"iso") and not cs.startswith(b"iso-"):
254 return b"iso-" + cs[3:]
254 return b"iso-" + cs[3:]
255 return cs
255 return cs
256
256
257
257
258 def mimetextpatch(s, subtype=b'plain', display=False):
258 def mimetextpatch(s, subtype=b'plain', display=False):
259 '''Return MIME message suitable for a patch.
259 '''Return MIME message suitable for a patch.
260 Charset will be detected by first trying to decode as us-ascii, then utf-8,
260 Charset will be detected by first trying to decode as us-ascii, then utf-8,
261 and finally the global encodings. If all those fail, fall back to
261 and finally the global encodings. If all those fail, fall back to
262 ISO-8859-1, an encoding with that allows all byte sequences.
262 ISO-8859-1, an encoding with that allows all byte sequences.
263 Transfer encodings will be used if necessary.'''
263 Transfer encodings will be used if necessary.'''
264
264
265 cs = [b'us-ascii', b'utf-8', encoding.encoding, encoding.fallbackencoding]
265 cs = [b'us-ascii', b'utf-8', encoding.encoding, encoding.fallbackencoding]
266 if display:
266 if display:
267 cs = [b'us-ascii']
267 cs = [b'us-ascii']
268 for charset in cs:
268 for charset in cs:
269 try:
269 try:
270 s.decode(pycompat.sysstr(charset))
270 s.decode(pycompat.sysstr(charset))
271 return mimetextqp(s, subtype, codec2iana(charset))
271 return mimetextqp(s, subtype, codec2iana(charset))
272 except UnicodeDecodeError:
272 except UnicodeDecodeError:
273 pass
273 pass
274
274
275 return mimetextqp(s, subtype, b"iso-8859-1")
275 return mimetextqp(s, subtype, b"iso-8859-1")
276
276
277
277
278 def mimetextqp(body, subtype, charset):
278 def mimetextqp(body, subtype, charset):
279 '''Return MIME message.
279 '''Return MIME message.
280 Quoted-printable transfer encoding will be used if necessary.
280 Quoted-printable transfer encoding will be used if necessary.
281 '''
281 '''
282 cs = email.charset.Charset(charset)
282 cs = email.charset.Charset(charset)
283 msg = email.message.Message()
283 msg = email.message.Message()
284 msg.set_type(pycompat.sysstr(b'text/' + subtype))
284 msg.set_type(pycompat.sysstr(b'text/' + subtype))
285
285
286 for line in body.splitlines():
286 for line in body.splitlines():
287 if len(line) > 950:
287 if len(line) > 950:
288 cs.body_encoding = email.charset.QP
288 cs.body_encoding = email.charset.QP
289 break
289 break
290
290
291 # On Python 2, this simply assigns a value. Python 3 inspects
291 # On Python 2, this simply assigns a value. Python 3 inspects
292 # body and does different things depending on whether it has
292 # body and does different things depending on whether it has
293 # encode() or decode() attributes. We can get the old behavior
293 # encode() or decode() attributes. We can get the old behavior
294 # if we pass a str and charset is None and we call set_charset().
294 # if we pass a str and charset is None and we call set_charset().
295 # But we may get into trouble later due to Python attempting to
295 # But we may get into trouble later due to Python attempting to
296 # encode/decode using the registered charset (or attempting to
296 # encode/decode using the registered charset (or attempting to
297 # use ascii in the absence of a charset).
297 # use ascii in the absence of a charset).
298 msg.set_payload(body, cs)
298 msg.set_payload(body, cs)
299
299
300 return msg
300 return msg
301
301
302
302
303 def _charsets(ui):
303 def _charsets(ui):
304 '''Obtains charsets to send mail parts not containing patches.'''
304 '''Obtains charsets to send mail parts not containing patches.'''
305 charsets = [cs.lower() for cs in ui.configlist(b'email', b'charsets')]
305 charsets = [cs.lower() for cs in ui.configlist(b'email', b'charsets')]
306 fallbacks = [
306 fallbacks = [
307 encoding.fallbackencoding.lower(),
307 encoding.fallbackencoding.lower(),
308 encoding.encoding.lower(),
308 encoding.encoding.lower(),
309 b'utf-8',
309 b'utf-8',
310 ]
310 ]
311 for cs in fallbacks: # find unique charsets while keeping order
311 for cs in fallbacks: # find unique charsets while keeping order
312 if cs not in charsets:
312 if cs not in charsets:
313 charsets.append(cs)
313 charsets.append(cs)
314 return [cs for cs in charsets if not cs.endswith(b'ascii')]
314 return [cs for cs in charsets if not cs.endswith(b'ascii')]
315
315
316
316
317 def _encode(ui, s, charsets):
317 def _encode(ui, s, charsets):
318 '''Returns (converted) string, charset tuple.
318 '''Returns (converted) string, charset tuple.
319 Finds out best charset by cycling through sendcharsets in descending
319 Finds out best charset by cycling through sendcharsets in descending
320 order. Tries both encoding and fallbackencoding for input. Only as
320 order. Tries both encoding and fallbackencoding for input. Only as
321 last resort send as is in fake ascii.
321 last resort send as is in fake ascii.
322 Caveat: Do not use for mail parts containing patches!'''
322 Caveat: Do not use for mail parts containing patches!'''
323 sendcharsets = charsets or _charsets(ui)
323 sendcharsets = charsets or _charsets(ui)
324 if not isinstance(s, bytes):
324 if not isinstance(s, bytes):
325 # We have unicode data, which we need to try and encode to
325 # We have unicode data, which we need to try and encode to
326 # some reasonable-ish encoding. Try the encodings the user
326 # some reasonable-ish encoding. Try the encodings the user
327 # wants, and fall back to garbage-in-ascii.
327 # wants, and fall back to garbage-in-ascii.
328 for ocs in sendcharsets:
328 for ocs in sendcharsets:
329 try:
329 try:
330 return s.encode(pycompat.sysstr(ocs)), ocs
330 return s.encode(pycompat.sysstr(ocs)), ocs
331 except UnicodeEncodeError:
331 except UnicodeEncodeError:
332 pass
332 pass
333 except LookupError:
333 except LookupError:
334 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
334 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
335 else:
335 else:
336 # Everything failed, ascii-armor what we've got and send it.
336 # Everything failed, ascii-armor what we've got and send it.
337 return s.encode('ascii', 'backslashreplace')
337 return s.encode('ascii', 'backslashreplace')
338 # We have a bytes of unknown encoding. We'll try and guess a valid
338 # We have a bytes of unknown encoding. We'll try and guess a valid
339 # encoding, falling back to pretending we had ascii even though we
339 # encoding, falling back to pretending we had ascii even though we
340 # know that's wrong.
340 # know that's wrong.
341 try:
341 try:
342 s.decode('ascii')
342 s.decode('ascii')
343 except UnicodeDecodeError:
343 except UnicodeDecodeError:
344 for ics in (encoding.encoding, encoding.fallbackencoding):
344 for ics in (encoding.encoding, encoding.fallbackencoding):
345 try:
345 try:
346 u = s.decode(ics)
346 u = s.decode(ics)
347 except UnicodeDecodeError:
347 except UnicodeDecodeError:
348 continue
348 continue
349 for ocs in sendcharsets:
349 for ocs in sendcharsets:
350 try:
350 try:
351 return u.encode(pycompat.sysstr(ocs)), ocs
351 return u.encode(pycompat.sysstr(ocs)), ocs
352 except UnicodeEncodeError:
352 except UnicodeEncodeError:
353 pass
353 pass
354 except LookupError:
354 except LookupError:
355 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
355 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
356 # if ascii, or all conversion attempts fail, send (broken) ascii
356 # if ascii, or all conversion attempts fail, send (broken) ascii
357 return s, b'us-ascii'
357 return s, b'us-ascii'
358
358
359
359
360 def headencode(ui, s, charsets=None, display=False):
360 def headencode(ui, s, charsets=None, display=False):
361 '''Returns RFC-2047 compliant header from given string.'''
361 '''Returns RFC-2047 compliant header from given string.'''
362 if not display:
362 if not display:
363 # split into words?
363 # split into words?
364 s, cs = _encode(ui, s, charsets)
364 s, cs = _encode(ui, s, charsets)
365 return encoding.strtolocal(email.header.Header(s, cs).encode())
365 return encoding.strtolocal(email.header.Header(s, cs).encode())
366 return s
366 return s
367
367
368
368
369 def _addressencode(ui, name, addr, charsets=None):
369 def _addressencode(ui, name, addr, charsets=None):
370 assert isinstance(addr, bytes)
370 assert isinstance(addr, bytes)
371 name = encoding.strfromlocal(headencode(ui, name, charsets))
371 name = encoding.strfromlocal(headencode(ui, name, charsets))
372 try:
372 try:
373 acc, dom = addr.split(b'@')
373 acc, dom = addr.split(b'@')
374 acc.decode('ascii')
374 acc.decode('ascii')
375 dom = dom.decode(pycompat.sysstr(encoding.encoding)).encode('idna')
375 dom = dom.decode(pycompat.sysstr(encoding.encoding)).encode('idna')
376 addr = b'%s@%s' % (acc, dom)
376 addr = b'%s@%s' % (acc, dom)
377 except UnicodeDecodeError:
377 except UnicodeDecodeError:
378 raise error.Abort(_(b'invalid email address: %s') % addr)
378 raise error.Abort(_(b'invalid email address: %s') % addr)
379 except ValueError:
379 except ValueError:
380 try:
380 try:
381 # too strict?
381 # too strict?
382 addr.decode('ascii')
382 addr.decode('ascii')
383 except UnicodeDecodeError:
383 except UnicodeDecodeError:
384 raise error.Abort(_(b'invalid local address: %s') % addr)
384 raise error.Abort(_(b'invalid local address: %s') % addr)
385 return pycompat.bytesurl(
385 return pycompat.bytesurl(
386 email.utils.formataddr((name, encoding.strfromlocal(addr)))
386 email.utils.formataddr((name, encoding.strfromlocal(addr)))
387 )
387 )
388
388
389
389
390 def addressencode(ui, address, charsets=None, display=False):
390 def addressencode(ui, address, charsets=None, display=False):
391 '''Turns address into RFC-2047 compliant header.'''
391 '''Turns address into RFC-2047 compliant header.'''
392 if display or not address:
392 if display or not address:
393 return address or b''
393 return address or b''
394 name, addr = email.utils.parseaddr(encoding.strfromlocal(address))
394 name, addr = email.utils.parseaddr(encoding.strfromlocal(address))
395 return _addressencode(ui, name, encoding.strtolocal(addr), charsets)
395 return _addressencode(ui, name, encoding.strtolocal(addr), charsets)
396
396
397
397
398 def addrlistencode(ui, addrs, charsets=None, display=False):
398 def addrlistencode(ui, addrs, charsets=None, display=False):
399 '''Turns a list of addresses into a list of RFC-2047 compliant headers.
399 '''Turns a list of addresses into a list of RFC-2047 compliant headers.
400 A single element of input list may contain multiple addresses, but output
400 A single element of input list may contain multiple addresses, but output
401 always has one address per item'''
401 always has one address per item'''
402 for a in addrs:
402 for a in addrs:
403 assert isinstance(a, bytes), r'%r unexpectedly not a bytestr' % a
403 assert isinstance(a, bytes), r'%r unexpectedly not a bytestr' % a
404 if display:
404 if display:
405 return [a.strip() for a in addrs if a.strip()]
405 return [a.strip() for a in addrs if a.strip()]
406
406
407 result = []
407 result = []
408 for name, addr in email.utils.getaddresses(
408 for name, addr in email.utils.getaddresses(
409 [encoding.strfromlocal(a) for a in addrs]
409 [encoding.strfromlocal(a) for a in addrs]
410 ):
410 ):
411 if name or addr:
411 if name or addr:
412 r = _addressencode(ui, name, encoding.strtolocal(addr), charsets)
412 r = _addressencode(ui, name, encoding.strtolocal(addr), charsets)
413 result.append(r)
413 result.append(r)
414 return result
414 return result
415
415
416
416
417 def mimeencode(ui, s, charsets=None, display=False):
417 def mimeencode(ui, s, charsets=None, display=False):
418 '''creates mime text object, encodes it if needed, and sets
418 '''creates mime text object, encodes it if needed, and sets
419 charset and transfer-encoding accordingly.'''
419 charset and transfer-encoding accordingly.'''
420 cs = b'us-ascii'
420 cs = b'us-ascii'
421 if not display:
421 if not display:
422 s, cs = _encode(ui, s, charsets)
422 s, cs = _encode(ui, s, charsets)
423 return mimetextqp(s, b'plain', cs)
423 return mimetextqp(s, b'plain', cs)
424
424
425
425
426 if pycompat.ispy3:
426 if pycompat.ispy3:
427
427
428 Generator = email.generator.BytesGenerator
428 Generator = email.generator.BytesGenerator
429
429
430 def parse(fp):
430 def parse(fp):
431 ep = email.parser.Parser()
431 ep = email.parser.Parser()
432 # disable the "universal newlines" mode, which isn't binary safe.
432 # disable the "universal newlines" mode, which isn't binary safe.
433 # I have no idea if ascii/surrogateescape is correct, but that's
433 # I have no idea if ascii/surrogateescape is correct, but that's
434 # what the standard Python email parser does.
434 # what the standard Python email parser does.
435 fp = io.TextIOWrapper(
435 fp = io.TextIOWrapper(
436 fp, encoding=r'ascii', errors=r'surrogateescape', newline=chr(10)
436 fp, encoding=r'ascii', errors=r'surrogateescape', newline=chr(10)
437 )
437 )
438 try:
438 try:
439 return ep.parse(fp)
439 return ep.parse(fp)
440 finally:
440 finally:
441 fp.detach()
441 fp.detach()
442
442
443 def parsebytes(data):
444 ep = email.parser.BytesParser()
445 return ep.parsebytes(data)
443
446
444 else:
447 else:
445
448
446 Generator = email.generator.Generator
449 Generator = email.generator.Generator
447
450
448 def parse(fp):
451 def parse(fp):
449 ep = email.parser.Parser()
452 ep = email.parser.Parser()
450 return ep.parse(fp)
453 return ep.parse(fp)
451
454
455 def parsebytes(data):
456 ep = email.parser.Parser()
457 return ep.parsestr(data)
458
452
459
453 def headdecode(s):
460 def headdecode(s):
454 '''Decodes RFC-2047 header'''
461 '''Decodes RFC-2047 header'''
455 uparts = []
462 uparts = []
456 for part, charset in email.header.decode_header(s):
463 for part, charset in email.header.decode_header(s):
457 if charset is not None:
464 if charset is not None:
458 try:
465 try:
459 uparts.append(part.decode(charset))
466 uparts.append(part.decode(charset))
460 continue
467 continue
461 except (UnicodeDecodeError, LookupError):
468 except (UnicodeDecodeError, LookupError):
462 pass
469 pass
463 # On Python 3, decode_header() may return either bytes or unicode
470 # On Python 3, decode_header() may return either bytes or unicode
464 # depending on whether the header has =?<charset>? or not
471 # depending on whether the header has =?<charset>? or not
465 if isinstance(part, type(u'')):
472 if isinstance(part, type(u'')):
466 uparts.append(part)
473 uparts.append(part)
467 continue
474 continue
468 try:
475 try:
469 uparts.append(part.decode('UTF-8'))
476 uparts.append(part.decode('UTF-8'))
470 continue
477 continue
471 except UnicodeDecodeError:
478 except UnicodeDecodeError:
472 pass
479 pass
473 uparts.append(part.decode('ISO-8859-1'))
480 uparts.append(part.decode('ISO-8859-1'))
474 return encoding.unitolocal(u' '.join(uparts))
481 return encoding.unitolocal(u' '.join(uparts))
General Comments 0
You need to be logged in to leave comments. Login now