##// END OF EJS Templates
mail: let addressencode() / addrlistencode() return native strings...
Denis Laxalde -
r43976:14b96072 default
parent child Browse files
Show More
@@ -1,573 +1,573
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.utils as emailutils
151 import email.utils as emailutils
152 import fnmatch
152 import fnmatch
153 import hashlib
153 import hashlib
154 import socket
154 import socket
155 import time
155 import time
156
156
157 from mercurial.i18n import _
157 from mercurial.i18n import _
158 from mercurial import (
158 from mercurial import (
159 encoding,
159 encoding,
160 error,
160 error,
161 logcmdutil,
161 logcmdutil,
162 mail,
162 mail,
163 patch,
163 patch,
164 pycompat,
164 pycompat,
165 registrar,
165 registrar,
166 util,
166 util,
167 )
167 )
168 from mercurial.utils import (
168 from mercurial.utils import (
169 dateutil,
169 dateutil,
170 stringutil,
170 stringutil,
171 )
171 )
172
172
173 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
173 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
174 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
174 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
175 # be specifying the version(s) of Mercurial they are tested with, or
175 # be specifying the version(s) of Mercurial they are tested with, or
176 # leave the attribute unspecified.
176 # leave the attribute unspecified.
177 testedwith = b'ships-with-hg-core'
177 testedwith = b'ships-with-hg-core'
178
178
179 configtable = {}
179 configtable = {}
180 configitem = registrar.configitem(configtable)
180 configitem = registrar.configitem(configtable)
181
181
182 configitem(
182 configitem(
183 b'notify', b'changegroup', default=None,
183 b'notify', b'changegroup', default=None,
184 )
184 )
185 configitem(
185 configitem(
186 b'notify', b'config', default=None,
186 b'notify', b'config', default=None,
187 )
187 )
188 configitem(
188 configitem(
189 b'notify', b'diffstat', default=True,
189 b'notify', b'diffstat', default=True,
190 )
190 )
191 configitem(
191 configitem(
192 b'notify', b'domain', default=None,
192 b'notify', b'domain', default=None,
193 )
193 )
194 configitem(
194 configitem(
195 b'notify', b'messageidseed', default=None,
195 b'notify', b'messageidseed', default=None,
196 )
196 )
197 configitem(
197 configitem(
198 b'notify', b'fromauthor', default=None,
198 b'notify', b'fromauthor', default=None,
199 )
199 )
200 configitem(
200 configitem(
201 b'notify', b'incoming', default=None,
201 b'notify', b'incoming', default=None,
202 )
202 )
203 configitem(
203 configitem(
204 b'notify', b'maxdiff', default=300,
204 b'notify', b'maxdiff', default=300,
205 )
205 )
206 configitem(
206 configitem(
207 b'notify', b'maxdiffstat', default=-1,
207 b'notify', b'maxdiffstat', default=-1,
208 )
208 )
209 configitem(
209 configitem(
210 b'notify', b'maxsubject', default=67,
210 b'notify', b'maxsubject', default=67,
211 )
211 )
212 configitem(
212 configitem(
213 b'notify', b'mbox', default=None,
213 b'notify', b'mbox', default=None,
214 )
214 )
215 configitem(
215 configitem(
216 b'notify', b'merge', default=True,
216 b'notify', b'merge', default=True,
217 )
217 )
218 configitem(
218 configitem(
219 b'notify', b'outgoing', default=None,
219 b'notify', b'outgoing', default=None,
220 )
220 )
221 configitem(
221 configitem(
222 b'notify', b'sources', default=b'serve',
222 b'notify', b'sources', default=b'serve',
223 )
223 )
224 configitem(
224 configitem(
225 b'notify', b'showfunc', default=None,
225 b'notify', b'showfunc', default=None,
226 )
226 )
227 configitem(
227 configitem(
228 b'notify', b'strip', default=0,
228 b'notify', b'strip', default=0,
229 )
229 )
230 configitem(
230 configitem(
231 b'notify', b'style', default=None,
231 b'notify', b'style', default=None,
232 )
232 )
233 configitem(
233 configitem(
234 b'notify', b'template', default=None,
234 b'notify', b'template', default=None,
235 )
235 )
236 configitem(
236 configitem(
237 b'notify', b'test', default=True,
237 b'notify', b'test', default=True,
238 )
238 )
239
239
240 # template for single changeset can include email headers.
240 # template for single changeset can include email headers.
241 single_template = b'''
241 single_template = b'''
242 Subject: changeset in {webroot}: {desc|firstline|strip}
242 Subject: changeset in {webroot}: {desc|firstline|strip}
243 From: {author}
243 From: {author}
244
244
245 changeset {node|short} in {root}
245 changeset {node|short} in {root}
246 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
246 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
247 description:
247 description:
248 \t{desc|tabindent|strip}
248 \t{desc|tabindent|strip}
249 '''.lstrip()
249 '''.lstrip()
250
250
251 # template for multiple changesets should not contain email headers,
251 # template for multiple changesets should not contain email headers,
252 # because only first set of headers will be used and result will look
252 # because only first set of headers will be used and result will look
253 # strange.
253 # strange.
254 multiple_template = b'''
254 multiple_template = b'''
255 changeset {node|short} in {root}
255 changeset {node|short} in {root}
256 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
256 details: {baseurl}{webroot}?cmd=changeset;node={node|short}
257 summary: {desc|firstline}
257 summary: {desc|firstline}
258 '''
258 '''
259
259
260 deftemplates = {
260 deftemplates = {
261 b'changegroup': multiple_template,
261 b'changegroup': multiple_template,
262 }
262 }
263
263
264
264
265 class notifier(object):
265 class notifier(object):
266 '''email notification class.'''
266 '''email notification class.'''
267
267
268 def __init__(self, ui, repo, hooktype):
268 def __init__(self, ui, repo, hooktype):
269 self.ui = ui
269 self.ui = ui
270 cfg = self.ui.config(b'notify', b'config')
270 cfg = self.ui.config(b'notify', b'config')
271 if cfg:
271 if cfg:
272 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
272 self.ui.readconfig(cfg, sections=[b'usersubs', b'reposubs'])
273 self.repo = repo
273 self.repo = repo
274 self.stripcount = int(self.ui.config(b'notify', b'strip'))
274 self.stripcount = int(self.ui.config(b'notify', b'strip'))
275 self.root = self.strip(self.repo.root)
275 self.root = self.strip(self.repo.root)
276 self.domain = self.ui.config(b'notify', b'domain')
276 self.domain = self.ui.config(b'notify', b'domain')
277 self.mbox = self.ui.config(b'notify', b'mbox')
277 self.mbox = self.ui.config(b'notify', b'mbox')
278 self.test = self.ui.configbool(b'notify', b'test')
278 self.test = self.ui.configbool(b'notify', b'test')
279 self.charsets = mail._charsets(self.ui)
279 self.charsets = mail._charsets(self.ui)
280 self.subs = self.subscribers()
280 self.subs = self.subscribers()
281 self.merge = self.ui.configbool(b'notify', b'merge')
281 self.merge = self.ui.configbool(b'notify', b'merge')
282 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
282 self.showfunc = self.ui.configbool(b'notify', b'showfunc')
283 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
283 self.messageidseed = self.ui.config(b'notify', b'messageidseed')
284 if self.showfunc is None:
284 if self.showfunc is None:
285 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
285 self.showfunc = self.ui.configbool(b'diff', b'showfunc')
286
286
287 mapfile = None
287 mapfile = None
288 template = self.ui.config(b'notify', hooktype) or self.ui.config(
288 template = self.ui.config(b'notify', hooktype) or self.ui.config(
289 b'notify', b'template'
289 b'notify', b'template'
290 )
290 )
291 if not template:
291 if not template:
292 mapfile = self.ui.config(b'notify', b'style')
292 mapfile = self.ui.config(b'notify', b'style')
293 if not mapfile and not template:
293 if not mapfile and not template:
294 template = deftemplates.get(hooktype) or single_template
294 template = deftemplates.get(hooktype) or single_template
295 spec = logcmdutil.templatespec(template, mapfile)
295 spec = logcmdutil.templatespec(template, mapfile)
296 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
296 self.t = logcmdutil.changesettemplater(self.ui, self.repo, spec)
297
297
298 def strip(self, path):
298 def strip(self, path):
299 '''strip leading slashes from local path, turn into web-safe path.'''
299 '''strip leading slashes from local path, turn into web-safe path.'''
300
300
301 path = util.pconvert(path)
301 path = util.pconvert(path)
302 count = self.stripcount
302 count = self.stripcount
303 while count > 0:
303 while count > 0:
304 c = path.find(b'/')
304 c = path.find(b'/')
305 if c == -1:
305 if c == -1:
306 break
306 break
307 path = path[c + 1 :]
307 path = path[c + 1 :]
308 count -= 1
308 count -= 1
309 return path
309 return path
310
310
311 def fixmail(self, addr):
311 def fixmail(self, addr):
312 '''try to clean up email addresses.'''
312 '''try to clean up email addresses.'''
313
313
314 addr = stringutil.email(addr.strip())
314 addr = stringutil.email(addr.strip())
315 if self.domain:
315 if self.domain:
316 a = addr.find(b'@localhost')
316 a = addr.find(b'@localhost')
317 if a != -1:
317 if a != -1:
318 addr = addr[:a]
318 addr = addr[:a]
319 if b'@' not in addr:
319 if b'@' not in addr:
320 return addr + b'@' + self.domain
320 return addr + b'@' + self.domain
321 return addr
321 return addr
322
322
323 def subscribers(self):
323 def subscribers(self):
324 '''return list of email addresses of subscribers to this repo.'''
324 '''return list of email addresses of subscribers to this repo.'''
325 subs = set()
325 subs = set()
326 for user, pats in self.ui.configitems(b'usersubs'):
326 for user, pats in self.ui.configitems(b'usersubs'):
327 for pat in pats.split(b','):
327 for pat in pats.split(b','):
328 if b'#' in pat:
328 if b'#' in pat:
329 pat, revs = pat.split(b'#', 1)
329 pat, revs = pat.split(b'#', 1)
330 else:
330 else:
331 revs = None
331 revs = None
332 if fnmatch.fnmatch(self.repo.root, pat.strip()):
332 if fnmatch.fnmatch(self.repo.root, pat.strip()):
333 subs.add((self.fixmail(user), revs))
333 subs.add((self.fixmail(user), revs))
334 for pat, users in self.ui.configitems(b'reposubs'):
334 for pat, users in self.ui.configitems(b'reposubs'):
335 if b'#' in pat:
335 if b'#' in pat:
336 pat, revs = pat.split(b'#', 1)
336 pat, revs = pat.split(b'#', 1)
337 else:
337 else:
338 revs = None
338 revs = None
339 if fnmatch.fnmatch(self.repo.root, pat):
339 if fnmatch.fnmatch(self.repo.root, pat):
340 for user in users.split(b','):
340 for user in users.split(b','):
341 subs.add((self.fixmail(user), revs))
341 subs.add((self.fixmail(user), revs))
342 return [
342 return [
343 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
343 (mail.addressencode(self.ui, s, self.charsets, self.test), r)
344 for s, r in sorted(subs)
344 for s, r in sorted(subs)
345 ]
345 ]
346
346
347 def node(self, ctx, **props):
347 def node(self, ctx, **props):
348 '''format one changeset, unless it is a suppressed merge.'''
348 '''format one changeset, unless it is a suppressed merge.'''
349 if not self.merge and len(ctx.parents()) > 1:
349 if not self.merge and len(ctx.parents()) > 1:
350 return False
350 return False
351 self.t.show(
351 self.t.show(
352 ctx,
352 ctx,
353 changes=ctx.changeset(),
353 changes=ctx.changeset(),
354 baseurl=self.ui.config(b'web', b'baseurl'),
354 baseurl=self.ui.config(b'web', b'baseurl'),
355 root=self.repo.root,
355 root=self.repo.root,
356 webroot=self.root,
356 webroot=self.root,
357 **props
357 **props
358 )
358 )
359 return True
359 return True
360
360
361 def skipsource(self, source):
361 def skipsource(self, source):
362 '''true if incoming changes from this source should be skipped.'''
362 '''true if incoming changes from this source should be skipped.'''
363 ok_sources = self.ui.config(b'notify', b'sources').split()
363 ok_sources = self.ui.config(b'notify', b'sources').split()
364 return source not in ok_sources
364 return source not in ok_sources
365
365
366 def send(self, ctx, count, data):
366 def send(self, ctx, count, data):
367 '''send message.'''
367 '''send message.'''
368
368
369 # Select subscribers by revset
369 # Select subscribers by revset
370 subs = set()
370 subs = set()
371 for sub, spec in self.subs:
371 for sub, spec in self.subs:
372 if spec is None:
372 if spec is None:
373 subs.add(sub)
373 subs.add(sub)
374 continue
374 continue
375 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
375 revs = self.repo.revs(b'%r and %d:', spec, ctx.rev())
376 if len(revs):
376 if len(revs):
377 subs.add(sub)
377 subs.add(sub)
378 continue
378 continue
379 if len(subs) == 0:
379 if len(subs) == 0:
380 self.ui.debug(
380 self.ui.debug(
381 b'notify: no subscribers to selected repo and revset\n'
381 b'notify: no subscribers to selected repo and revset\n'
382 )
382 )
383 return
383 return
384
384
385 try:
385 try:
386 msg = mail.parsebytes(data)
386 msg = mail.parsebytes(data)
387 except emailerrors.MessageParseError as inst:
387 except emailerrors.MessageParseError as inst:
388 raise error.Abort(inst)
388 raise error.Abort(inst)
389
389
390 # store sender and subject
390 # store sender and subject
391 sender = msg['From']
391 sender = msg['From']
392 subject = msg['Subject']
392 subject = msg['Subject']
393 if sender is not None:
393 if sender is not None:
394 sender = mail.headdecode(sender)
394 sender = mail.headdecode(sender)
395 if subject is not None:
395 if subject is not None:
396 subject = mail.headdecode(subject)
396 subject = mail.headdecode(subject)
397 del msg['From'], msg['Subject']
397 del msg['From'], msg['Subject']
398
398
399 if not msg.is_multipart():
399 if not msg.is_multipart():
400 # create fresh mime message from scratch
400 # create fresh mime message from scratch
401 # (multipart templates must take care of this themselves)
401 # (multipart templates must take care of this themselves)
402 headers = msg.items()
402 headers = msg.items()
403 payload = msg.get_payload(decode=pycompat.ispy3)
403 payload = msg.get_payload(decode=pycompat.ispy3)
404 # for notification prefer readability over data precision
404 # for notification prefer readability over data precision
405 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
405 msg = mail.mimeencode(self.ui, payload, self.charsets, self.test)
406 # reinstate custom headers
406 # reinstate custom headers
407 for k, v in headers:
407 for k, v in headers:
408 msg[k] = v
408 msg[k] = v
409
409
410 msg['Date'] = encoding.strfromlocal(
410 msg['Date'] = encoding.strfromlocal(
411 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
411 dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
412 )
412 )
413
413
414 # try to make subject line exist and be useful
414 # try to make subject line exist and be useful
415 if not subject:
415 if not subject:
416 if count > 1:
416 if count > 1:
417 subject = _(b'%s: %d new changesets') % (self.root, count)
417 subject = _(b'%s: %d new changesets') % (self.root, count)
418 else:
418 else:
419 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
419 s = ctx.description().lstrip().split(b'\n', 1)[0].rstrip()
420 subject = b'%s: %s' % (self.root, s)
420 subject = b'%s: %s' % (self.root, s)
421 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
421 maxsubject = int(self.ui.config(b'notify', b'maxsubject'))
422 if maxsubject:
422 if maxsubject:
423 subject = stringutil.ellipsis(subject, maxsubject)
423 subject = stringutil.ellipsis(subject, maxsubject)
424 msg['Subject'] = mail.headencode(
424 msg['Subject'] = mail.headencode(
425 self.ui, subject, self.charsets, self.test
425 self.ui, subject, self.charsets, self.test
426 )
426 )
427
427
428 # try to make message have proper sender
428 # try to make message have proper sender
429 if not sender:
429 if not sender:
430 sender = self.ui.config(b'email', b'from') or self.ui.username()
430 sender = self.ui.config(b'email', b'from') or self.ui.username()
431 if b'@' not in sender or b'@localhost' in sender:
431 if b'@' not in sender or b'@localhost' in sender:
432 sender = self.fixmail(sender)
432 sender = self.fixmail(sender)
433 msg['From'] = encoding.strfromlocal(
433 msg['From'] = mail.addressencode(
434 mail.addressencode(self.ui, sender, self.charsets, self.test)
434 self.ui, sender, self.charsets, self.test
435 )
435 )
436
436
437 msg['X-Hg-Notification'] = 'changeset %s' % ctx
437 msg['X-Hg-Notification'] = 'changeset %s' % ctx
438 if not msg['Message-Id']:
438 if not msg['Message-Id']:
439 msg['Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
439 msg['Message-Id'] = messageid(ctx, self.domain, self.messageidseed)
440 msg['To'] = encoding.strfromlocal(b', '.join(sorted(subs)))
440 msg['To'] = ', '.join(sorted(subs))
441
441
442 msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
442 msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
443 if self.test:
443 if self.test:
444 self.ui.write(msgtext)
444 self.ui.write(msgtext)
445 if not msgtext.endswith(b'\n'):
445 if not msgtext.endswith(b'\n'):
446 self.ui.write(b'\n')
446 self.ui.write(b'\n')
447 else:
447 else:
448 self.ui.status(
448 self.ui.status(
449 _(b'notify: sending %d subscribers %d changes\n')
449 _(b'notify: sending %d subscribers %d changes\n')
450 % (len(subs), count)
450 % (len(subs), count)
451 )
451 )
452 mail.sendmail(
452 mail.sendmail(
453 self.ui,
453 self.ui,
454 emailutils.parseaddr(msg['From'])[1],
454 emailutils.parseaddr(msg['From'])[1],
455 subs,
455 subs,
456 msgtext,
456 msgtext,
457 mbox=self.mbox,
457 mbox=self.mbox,
458 )
458 )
459
459
460 def diff(self, ctx, ref=None):
460 def diff(self, ctx, ref=None):
461
461
462 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
462 maxdiff = int(self.ui.config(b'notify', b'maxdiff'))
463 prev = ctx.p1().node()
463 prev = ctx.p1().node()
464 if ref:
464 if ref:
465 ref = ref.node()
465 ref = ref.node()
466 else:
466 else:
467 ref = ctx.node()
467 ref = ctx.node()
468 diffopts = patch.diffallopts(self.ui)
468 diffopts = patch.diffallopts(self.ui)
469 diffopts.showfunc = self.showfunc
469 diffopts.showfunc = self.showfunc
470 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
470 chunks = patch.diff(self.repo, prev, ref, opts=diffopts)
471 difflines = b''.join(chunks).splitlines()
471 difflines = b''.join(chunks).splitlines()
472
472
473 if self.ui.configbool(b'notify', b'diffstat'):
473 if self.ui.configbool(b'notify', b'diffstat'):
474 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
474 maxdiffstat = int(self.ui.config(b'notify', b'maxdiffstat'))
475 s = patch.diffstat(difflines)
475 s = patch.diffstat(difflines)
476 # s may be nil, don't include the header if it is
476 # s may be nil, don't include the header if it is
477 if s:
477 if s:
478 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
478 if maxdiffstat >= 0 and s.count(b"\n") > maxdiffstat + 1:
479 s = s.split(b"\n")
479 s = s.split(b"\n")
480 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
480 msg = _(b'\ndiffstat (truncated from %d to %d lines):\n\n')
481 self.ui.write(msg % (len(s) - 2, maxdiffstat))
481 self.ui.write(msg % (len(s) - 2, maxdiffstat))
482 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
482 self.ui.write(b"\n".join(s[:maxdiffstat] + s[-2:]))
483 else:
483 else:
484 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
484 self.ui.write(_(b'\ndiffstat:\n\n%s') % s)
485
485
486 if maxdiff == 0:
486 if maxdiff == 0:
487 return
487 return
488 elif maxdiff > 0 and len(difflines) > maxdiff:
488 elif maxdiff > 0 and len(difflines) > maxdiff:
489 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
489 msg = _(b'\ndiffs (truncated from %d to %d lines):\n\n')
490 self.ui.write(msg % (len(difflines), maxdiff))
490 self.ui.write(msg % (len(difflines), maxdiff))
491 difflines = difflines[:maxdiff]
491 difflines = difflines[:maxdiff]
492 elif difflines:
492 elif difflines:
493 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
493 self.ui.write(_(b'\ndiffs (%d lines):\n\n') % len(difflines))
494
494
495 self.ui.write(b"\n".join(difflines))
495 self.ui.write(b"\n".join(difflines))
496
496
497
497
498 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
498 def hook(ui, repo, hooktype, node=None, source=None, **kwargs):
499 '''send email notifications to interested subscribers.
499 '''send email notifications to interested subscribers.
500
500
501 if used as changegroup hook, send one email for all changesets in
501 if used as changegroup hook, send one email for all changesets in
502 changegroup. else send one email per changeset.'''
502 changegroup. else send one email per changeset.'''
503
503
504 n = notifier(ui, repo, hooktype)
504 n = notifier(ui, repo, hooktype)
505 ctx = repo.unfiltered()[node]
505 ctx = repo.unfiltered()[node]
506
506
507 if not n.subs:
507 if not n.subs:
508 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
508 ui.debug(b'notify: no subscribers to repository %s\n' % n.root)
509 return
509 return
510 if n.skipsource(source):
510 if n.skipsource(source):
511 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
511 ui.debug(b'notify: changes have source "%s" - skipping\n' % source)
512 return
512 return
513
513
514 ui.pushbuffer()
514 ui.pushbuffer()
515 data = b''
515 data = b''
516 count = 0
516 count = 0
517 author = b''
517 author = b''
518 if hooktype == b'changegroup' or hooktype == b'outgoing':
518 if hooktype == b'changegroup' or hooktype == b'outgoing':
519 for rev in repo.changelog.revs(start=ctx.rev()):
519 for rev in repo.changelog.revs(start=ctx.rev()):
520 if n.node(repo[rev]):
520 if n.node(repo[rev]):
521 count += 1
521 count += 1
522 if not author:
522 if not author:
523 author = repo[rev].user()
523 author = repo[rev].user()
524 else:
524 else:
525 data += ui.popbuffer()
525 data += ui.popbuffer()
526 ui.note(
526 ui.note(
527 _(b'notify: suppressing notification for merge %d:%s\n')
527 _(b'notify: suppressing notification for merge %d:%s\n')
528 % (rev, repo[rev].hex()[:12])
528 % (rev, repo[rev].hex()[:12])
529 )
529 )
530 ui.pushbuffer()
530 ui.pushbuffer()
531 if count:
531 if count:
532 n.diff(ctx, repo[b'tip'])
532 n.diff(ctx, repo[b'tip'])
533 elif ctx.rev() in repo:
533 elif ctx.rev() in repo:
534 if not n.node(ctx):
534 if not n.node(ctx):
535 ui.popbuffer()
535 ui.popbuffer()
536 ui.note(
536 ui.note(
537 _(b'notify: suppressing notification for merge %d:%s\n')
537 _(b'notify: suppressing notification for merge %d:%s\n')
538 % (ctx.rev(), ctx.hex()[:12])
538 % (ctx.rev(), ctx.hex()[:12])
539 )
539 )
540 return
540 return
541 count += 1
541 count += 1
542 n.diff(ctx)
542 n.diff(ctx)
543 if not author:
543 if not author:
544 author = ctx.user()
544 author = ctx.user()
545
545
546 data += ui.popbuffer()
546 data += ui.popbuffer()
547 fromauthor = ui.config(b'notify', b'fromauthor')
547 fromauthor = ui.config(b'notify', b'fromauthor')
548 if author and fromauthor:
548 if author and fromauthor:
549 data = b'\n'.join([b'From: %s' % author, data])
549 data = b'\n'.join([b'From: %s' % author, data])
550
550
551 if count:
551 if count:
552 n.send(ctx, count, data)
552 n.send(ctx, count, data)
553
553
554
554
555 def messageid(ctx, domain, messageidseed):
555 def messageid(ctx, domain, messageidseed):
556 if domain and messageidseed:
556 if domain and messageidseed:
557 host = domain
557 host = domain
558 else:
558 else:
559 host = encoding.strtolocal(socket.getfqdn())
559 host = encoding.strtolocal(socket.getfqdn())
560 if messageidseed:
560 if messageidseed:
561 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
561 messagehash = hashlib.sha512(ctx.hex() + messageidseed)
562 messageid = b'<hg.%s@%s>' % (
562 messageid = b'<hg.%s@%s>' % (
563 pycompat.sysbytes(messagehash.hexdigest()[:64]),
563 pycompat.sysbytes(messagehash.hexdigest()[:64]),
564 host,
564 host,
565 )
565 )
566 else:
566 else:
567 messageid = b'<hg.%s.%d.%d@%s>' % (
567 messageid = b'<hg.%s.%d.%d@%s>' % (
568 ctx,
568 ctx,
569 int(time.time()),
569 int(time.time()),
570 hash(ctx.repo().root),
570 hash(ctx.repo().root),
571 host,
571 host,
572 )
572 )
573 return encoding.strfromlocal(messageid)
573 return encoding.strfromlocal(messageid)
@@ -1,998 +1,997
1 # patchbomb.py - sending Mercurial changesets as patch emails
1 # patchbomb.py - sending Mercurial changesets as patch emails
2 #
2 #
3 # Copyright 2005-2009 Matt Mackall <mpm@selenic.com> and others
3 # Copyright 2005-2009 Matt Mackall <mpm@selenic.com> and others
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 '''command to send changesets as (a series of) patch emails
8 '''command to send changesets as (a series of) patch emails
9
9
10 The series is started off with a "[PATCH 0 of N]" introduction, which
10 The series is started off with a "[PATCH 0 of N]" introduction, which
11 describes the series as a whole.
11 describes the series as a whole.
12
12
13 Each patch email has a Subject line of "[PATCH M of N] ...", using the
13 Each patch email has a Subject line of "[PATCH M of N] ...", using the
14 first line of the changeset description as the subject text. The
14 first line of the changeset description as the subject text. The
15 message contains two or three body parts:
15 message contains two or three body parts:
16
16
17 - The changeset description.
17 - The changeset description.
18 - [Optional] The result of running diffstat on the patch.
18 - [Optional] The result of running diffstat on the patch.
19 - The patch itself, as generated by :hg:`export`.
19 - The patch itself, as generated by :hg:`export`.
20
20
21 Each message refers to the first in the series using the In-Reply-To
21 Each message refers to the first in the series using the In-Reply-To
22 and References headers, so they will show up as a sequence in threaded
22 and References headers, so they will show up as a sequence in threaded
23 mail and news readers, and in mail archives.
23 mail and news readers, and in mail archives.
24
24
25 To configure other defaults, add a section like this to your
25 To configure other defaults, add a section like this to your
26 configuration file::
26 configuration file::
27
27
28 [email]
28 [email]
29 from = My Name <my@email>
29 from = My Name <my@email>
30 to = recipient1, recipient2, ...
30 to = recipient1, recipient2, ...
31 cc = cc1, cc2, ...
31 cc = cc1, cc2, ...
32 bcc = bcc1, bcc2, ...
32 bcc = bcc1, bcc2, ...
33 reply-to = address1, address2, ...
33 reply-to = address1, address2, ...
34
34
35 Use ``[patchbomb]`` as configuration section name if you need to
35 Use ``[patchbomb]`` as configuration section name if you need to
36 override global ``[email]`` address settings.
36 override global ``[email]`` address settings.
37
37
38 Then you can use the :hg:`email` command to mail a series of
38 Then you can use the :hg:`email` command to mail a series of
39 changesets as a patchbomb.
39 changesets as a patchbomb.
40
40
41 You can also either configure the method option in the email section
41 You can also either configure the method option in the email section
42 to be a sendmail compatible mailer or fill out the [smtp] section so
42 to be a sendmail compatible mailer or fill out the [smtp] section so
43 that the patchbomb extension can automatically send patchbombs
43 that the patchbomb extension can automatically send patchbombs
44 directly from the commandline. See the [email] and [smtp] sections in
44 directly from the commandline. See the [email] and [smtp] sections in
45 hgrc(5) for details.
45 hgrc(5) for details.
46
46
47 By default, :hg:`email` will prompt for a ``To`` or ``CC`` header if
47 By default, :hg:`email` will prompt for a ``To`` or ``CC`` header if
48 you do not supply one via configuration or the command line. You can
48 you do not supply one via configuration or the command line. You can
49 override this to never prompt by configuring an empty value::
49 override this to never prompt by configuring an empty value::
50
50
51 [email]
51 [email]
52 cc =
52 cc =
53
53
54 You can control the default inclusion of an introduction message with the
54 You can control the default inclusion of an introduction message with the
55 ``patchbomb.intro`` configuration option. The configuration is always
55 ``patchbomb.intro`` configuration option. The configuration is always
56 overwritten by command line flags like --intro and --desc::
56 overwritten by command line flags like --intro and --desc::
57
57
58 [patchbomb]
58 [patchbomb]
59 intro=auto # include introduction message if more than 1 patch (default)
59 intro=auto # include introduction message if more than 1 patch (default)
60 intro=never # never include an introduction message
60 intro=never # never include an introduction message
61 intro=always # always include an introduction message
61 intro=always # always include an introduction message
62
62
63 You can specify a template for flags to be added in subject prefixes. Flags
63 You can specify a template for flags to be added in subject prefixes. Flags
64 specified by --flag option are exported as ``{flags}`` keyword::
64 specified by --flag option are exported as ``{flags}`` keyword::
65
65
66 [patchbomb]
66 [patchbomb]
67 flagtemplate = "{separate(' ',
67 flagtemplate = "{separate(' ',
68 ifeq(branch, 'default', '', branch|upper),
68 ifeq(branch, 'default', '', branch|upper),
69 flags)}"
69 flags)}"
70
70
71 You can set patchbomb to always ask for confirmation by setting
71 You can set patchbomb to always ask for confirmation by setting
72 ``patchbomb.confirm`` to true.
72 ``patchbomb.confirm`` to true.
73 '''
73 '''
74 from __future__ import absolute_import
74 from __future__ import absolute_import
75
75
76 import email.encoders as emailencoders
76 import email.encoders as emailencoders
77 import email.mime.base as emimebase
77 import email.mime.base as emimebase
78 import email.mime.multipart as emimemultipart
78 import email.mime.multipart as emimemultipart
79 import email.utils as eutil
79 import email.utils as eutil
80 import errno
80 import errno
81 import os
81 import os
82 import socket
82 import socket
83
83
84 from mercurial.i18n import _
84 from mercurial.i18n import _
85 from mercurial.pycompat import open
85 from mercurial.pycompat import open
86 from mercurial import (
86 from mercurial import (
87 cmdutil,
87 cmdutil,
88 commands,
88 commands,
89 encoding,
89 encoding,
90 error,
90 error,
91 formatter,
91 formatter,
92 hg,
92 hg,
93 mail,
93 mail,
94 node as nodemod,
94 node as nodemod,
95 patch,
95 patch,
96 pycompat,
96 pycompat,
97 registrar,
97 registrar,
98 scmutil,
98 scmutil,
99 templater,
99 templater,
100 util,
100 util,
101 )
101 )
102 from mercurial.utils import dateutil
102 from mercurial.utils import dateutil
103
103
104 stringio = util.stringio
104 stringio = util.stringio
105
105
106 cmdtable = {}
106 cmdtable = {}
107 command = registrar.command(cmdtable)
107 command = registrar.command(cmdtable)
108
108
109 configtable = {}
109 configtable = {}
110 configitem = registrar.configitem(configtable)
110 configitem = registrar.configitem(configtable)
111
111
112 configitem(
112 configitem(
113 b'patchbomb', b'bundletype', default=None,
113 b'patchbomb', b'bundletype', default=None,
114 )
114 )
115 configitem(
115 configitem(
116 b'patchbomb', b'bcc', default=None,
116 b'patchbomb', b'bcc', default=None,
117 )
117 )
118 configitem(
118 configitem(
119 b'patchbomb', b'cc', default=None,
119 b'patchbomb', b'cc', default=None,
120 )
120 )
121 configitem(
121 configitem(
122 b'patchbomb', b'confirm', default=False,
122 b'patchbomb', b'confirm', default=False,
123 )
123 )
124 configitem(
124 configitem(
125 b'patchbomb', b'flagtemplate', default=None,
125 b'patchbomb', b'flagtemplate', default=None,
126 )
126 )
127 configitem(
127 configitem(
128 b'patchbomb', b'from', default=None,
128 b'patchbomb', b'from', default=None,
129 )
129 )
130 configitem(
130 configitem(
131 b'patchbomb', b'intro', default=b'auto',
131 b'patchbomb', b'intro', default=b'auto',
132 )
132 )
133 configitem(
133 configitem(
134 b'patchbomb', b'publicurl', default=None,
134 b'patchbomb', b'publicurl', default=None,
135 )
135 )
136 configitem(
136 configitem(
137 b'patchbomb', b'reply-to', default=None,
137 b'patchbomb', b'reply-to', default=None,
138 )
138 )
139 configitem(
139 configitem(
140 b'patchbomb', b'to', default=None,
140 b'patchbomb', b'to', default=None,
141 )
141 )
142
142
143 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
143 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
144 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
144 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
145 # be specifying the version(s) of Mercurial they are tested with, or
145 # be specifying the version(s) of Mercurial they are tested with, or
146 # leave the attribute unspecified.
146 # leave the attribute unspecified.
147 testedwith = b'ships-with-hg-core'
147 testedwith = b'ships-with-hg-core'
148
148
149
149
150 def _addpullheader(seq, ctx):
150 def _addpullheader(seq, ctx):
151 """Add a header pointing to a public URL where the changeset is available
151 """Add a header pointing to a public URL where the changeset is available
152 """
152 """
153 repo = ctx.repo()
153 repo = ctx.repo()
154 # experimental config: patchbomb.publicurl
154 # experimental config: patchbomb.publicurl
155 # waiting for some logic that check that the changeset are available on the
155 # waiting for some logic that check that the changeset are available on the
156 # destination before patchbombing anything.
156 # destination before patchbombing anything.
157 publicurl = repo.ui.config(b'patchbomb', b'publicurl')
157 publicurl = repo.ui.config(b'patchbomb', b'publicurl')
158 if publicurl:
158 if publicurl:
159 return b'Available At %s\n# hg pull %s -r %s' % (
159 return b'Available At %s\n# hg pull %s -r %s' % (
160 publicurl,
160 publicurl,
161 publicurl,
161 publicurl,
162 ctx,
162 ctx,
163 )
163 )
164 return None
164 return None
165
165
166
166
167 def uisetup(ui):
167 def uisetup(ui):
168 cmdutil.extraexport.append(b'pullurl')
168 cmdutil.extraexport.append(b'pullurl')
169 cmdutil.extraexportmap[b'pullurl'] = _addpullheader
169 cmdutil.extraexportmap[b'pullurl'] = _addpullheader
170
170
171
171
172 def reposetup(ui, repo):
172 def reposetup(ui, repo):
173 if not repo.local():
173 if not repo.local():
174 return
174 return
175 repo._wlockfreeprefix.add(b'last-email.txt')
175 repo._wlockfreeprefix.add(b'last-email.txt')
176
176
177
177
178 def prompt(ui, prompt, default=None, rest=b':'):
178 def prompt(ui, prompt, default=None, rest=b':'):
179 if default:
179 if default:
180 prompt += b' [%s]' % default
180 prompt += b' [%s]' % default
181 return ui.prompt(prompt + rest, default)
181 return ui.prompt(prompt + rest, default)
182
182
183
183
184 def introwanted(ui, opts, number):
184 def introwanted(ui, opts, number):
185 '''is an introductory message apparently wanted?'''
185 '''is an introductory message apparently wanted?'''
186 introconfig = ui.config(b'patchbomb', b'intro')
186 introconfig = ui.config(b'patchbomb', b'intro')
187 if opts.get(b'intro') or opts.get(b'desc'):
187 if opts.get(b'intro') or opts.get(b'desc'):
188 intro = True
188 intro = True
189 elif introconfig == b'always':
189 elif introconfig == b'always':
190 intro = True
190 intro = True
191 elif introconfig == b'never':
191 elif introconfig == b'never':
192 intro = False
192 intro = False
193 elif introconfig == b'auto':
193 elif introconfig == b'auto':
194 intro = number > 1
194 intro = number > 1
195 else:
195 else:
196 ui.write_err(
196 ui.write_err(
197 _(b'warning: invalid patchbomb.intro value "%s"\n') % introconfig
197 _(b'warning: invalid patchbomb.intro value "%s"\n') % introconfig
198 )
198 )
199 ui.write_err(_(b'(should be one of always, never, auto)\n'))
199 ui.write_err(_(b'(should be one of always, never, auto)\n'))
200 intro = number > 1
200 intro = number > 1
201 return intro
201 return intro
202
202
203
203
204 def _formatflags(ui, repo, rev, flags):
204 def _formatflags(ui, repo, rev, flags):
205 """build flag string optionally by template"""
205 """build flag string optionally by template"""
206 tmpl = ui.config(b'patchbomb', b'flagtemplate')
206 tmpl = ui.config(b'patchbomb', b'flagtemplate')
207 if not tmpl:
207 if not tmpl:
208 return b' '.join(flags)
208 return b' '.join(flags)
209 out = util.stringio()
209 out = util.stringio()
210 spec = formatter.templatespec(b'', templater.unquotestring(tmpl), None)
210 spec = formatter.templatespec(b'', templater.unquotestring(tmpl), None)
211 with formatter.templateformatter(ui, out, b'patchbombflag', {}, spec) as fm:
211 with formatter.templateformatter(ui, out, b'patchbombflag', {}, spec) as fm:
212 fm.startitem()
212 fm.startitem()
213 fm.context(ctx=repo[rev])
213 fm.context(ctx=repo[rev])
214 fm.write(b'flags', b'%s', fm.formatlist(flags, name=b'flag'))
214 fm.write(b'flags', b'%s', fm.formatlist(flags, name=b'flag'))
215 return out.getvalue()
215 return out.getvalue()
216
216
217
217
218 def _formatprefix(ui, repo, rev, flags, idx, total, numbered):
218 def _formatprefix(ui, repo, rev, flags, idx, total, numbered):
219 """build prefix to patch subject"""
219 """build prefix to patch subject"""
220 flag = _formatflags(ui, repo, rev, flags)
220 flag = _formatflags(ui, repo, rev, flags)
221 if flag:
221 if flag:
222 flag = b' ' + flag
222 flag = b' ' + flag
223
223
224 if not numbered:
224 if not numbered:
225 return b'[PATCH%s]' % flag
225 return b'[PATCH%s]' % flag
226 else:
226 else:
227 tlen = len(b"%d" % total)
227 tlen = len(b"%d" % total)
228 return b'[PATCH %0*d of %d%s]' % (tlen, idx, total, flag)
228 return b'[PATCH %0*d of %d%s]' % (tlen, idx, total, flag)
229
229
230
230
231 def makepatch(
231 def makepatch(
232 ui,
232 ui,
233 repo,
233 repo,
234 rev,
234 rev,
235 patchlines,
235 patchlines,
236 opts,
236 opts,
237 _charsets,
237 _charsets,
238 idx,
238 idx,
239 total,
239 total,
240 numbered,
240 numbered,
241 patchname=None,
241 patchname=None,
242 ):
242 ):
243
243
244 desc = []
244 desc = []
245 node = None
245 node = None
246 body = b''
246 body = b''
247
247
248 for line in patchlines:
248 for line in patchlines:
249 if line.startswith(b'#'):
249 if line.startswith(b'#'):
250 if line.startswith(b'# Node ID'):
250 if line.startswith(b'# Node ID'):
251 node = line.split()[-1]
251 node = line.split()[-1]
252 continue
252 continue
253 if line.startswith(b'diff -r') or line.startswith(b'diff --git'):
253 if line.startswith(b'diff -r') or line.startswith(b'diff --git'):
254 break
254 break
255 desc.append(line)
255 desc.append(line)
256
256
257 if not patchname and not node:
257 if not patchname and not node:
258 raise ValueError
258 raise ValueError
259
259
260 if opts.get(b'attach') and not opts.get(b'body'):
260 if opts.get(b'attach') and not opts.get(b'body'):
261 body = (
261 body = (
262 b'\n'.join(desc[1:]).strip()
262 b'\n'.join(desc[1:]).strip()
263 or b'Patch subject is complete summary.'
263 or b'Patch subject is complete summary.'
264 )
264 )
265 body += b'\n\n\n'
265 body += b'\n\n\n'
266
266
267 if opts.get(b'plain'):
267 if opts.get(b'plain'):
268 while patchlines and patchlines[0].startswith(b'# '):
268 while patchlines and patchlines[0].startswith(b'# '):
269 patchlines.pop(0)
269 patchlines.pop(0)
270 if patchlines:
270 if patchlines:
271 patchlines.pop(0)
271 patchlines.pop(0)
272 while patchlines and not patchlines[0].strip():
272 while patchlines and not patchlines[0].strip():
273 patchlines.pop(0)
273 patchlines.pop(0)
274
274
275 ds = patch.diffstat(patchlines)
275 ds = patch.diffstat(patchlines)
276 if opts.get(b'diffstat'):
276 if opts.get(b'diffstat'):
277 body += ds + b'\n\n'
277 body += ds + b'\n\n'
278
278
279 addattachment = opts.get(b'attach') or opts.get(b'inline')
279 addattachment = opts.get(b'attach') or opts.get(b'inline')
280 if not addattachment or opts.get(b'body'):
280 if not addattachment or opts.get(b'body'):
281 body += b'\n'.join(patchlines)
281 body += b'\n'.join(patchlines)
282
282
283 if addattachment:
283 if addattachment:
284 msg = emimemultipart.MIMEMultipart()
284 msg = emimemultipart.MIMEMultipart()
285 if body:
285 if body:
286 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get(b'test')))
286 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get(b'test')))
287 p = mail.mimetextpatch(
287 p = mail.mimetextpatch(
288 b'\n'.join(patchlines), b'x-patch', opts.get(b'test')
288 b'\n'.join(patchlines), b'x-patch', opts.get(b'test')
289 )
289 )
290 binnode = nodemod.bin(node)
290 binnode = nodemod.bin(node)
291 # if node is mq patch, it will have the patch file's name as a tag
291 # if node is mq patch, it will have the patch file's name as a tag
292 if not patchname:
292 if not patchname:
293 patchtags = [
293 patchtags = [
294 t
294 t
295 for t in repo.nodetags(binnode)
295 for t in repo.nodetags(binnode)
296 if t.endswith(b'.patch') or t.endswith(b'.diff')
296 if t.endswith(b'.patch') or t.endswith(b'.diff')
297 ]
297 ]
298 if patchtags:
298 if patchtags:
299 patchname = patchtags[0]
299 patchname = patchtags[0]
300 elif total > 1:
300 elif total > 1:
301 patchname = cmdutil.makefilename(
301 patchname = cmdutil.makefilename(
302 repo[node], b'%b-%n.patch', seqno=idx, total=total
302 repo[node], b'%b-%n.patch', seqno=idx, total=total
303 )
303 )
304 else:
304 else:
305 patchname = cmdutil.makefilename(repo[node], b'%b.patch')
305 patchname = cmdutil.makefilename(repo[node], b'%b.patch')
306 disposition = r'inline'
306 disposition = r'inline'
307 if opts.get(b'attach'):
307 if opts.get(b'attach'):
308 disposition = r'attachment'
308 disposition = r'attachment'
309 p['Content-Disposition'] = (
309 p['Content-Disposition'] = (
310 disposition + '; filename=' + encoding.strfromlocal(patchname)
310 disposition + '; filename=' + encoding.strfromlocal(patchname)
311 )
311 )
312 msg.attach(p)
312 msg.attach(p)
313 else:
313 else:
314 msg = mail.mimetextpatch(body, display=opts.get(b'test'))
314 msg = mail.mimetextpatch(body, display=opts.get(b'test'))
315
315
316 prefix = _formatprefix(
316 prefix = _formatprefix(
317 ui, repo, rev, opts.get(b'flag'), idx, total, numbered
317 ui, repo, rev, opts.get(b'flag'), idx, total, numbered
318 )
318 )
319 subj = desc[0].strip().rstrip(b'. ')
319 subj = desc[0].strip().rstrip(b'. ')
320 if not numbered:
320 if not numbered:
321 subj = b' '.join([prefix, opts.get(b'subject') or subj])
321 subj = b' '.join([prefix, opts.get(b'subject') or subj])
322 else:
322 else:
323 subj = b' '.join([prefix, subj])
323 subj = b' '.join([prefix, subj])
324 msg[b'Subject'] = mail.headencode(ui, subj, _charsets, opts.get(b'test'))
324 msg[b'Subject'] = mail.headencode(ui, subj, _charsets, opts.get(b'test'))
325 msg[b'X-Mercurial-Node'] = node
325 msg[b'X-Mercurial-Node'] = node
326 msg[b'X-Mercurial-Series-Index'] = b'%i' % idx
326 msg[b'X-Mercurial-Series-Index'] = b'%i' % idx
327 msg[b'X-Mercurial-Series-Total'] = b'%i' % total
327 msg[b'X-Mercurial-Series-Total'] = b'%i' % total
328 return msg, subj, ds
328 return msg, subj, ds
329
329
330
330
331 def _getpatches(repo, revs, **opts):
331 def _getpatches(repo, revs, **opts):
332 """return a list of patches for a list of revisions
332 """return a list of patches for a list of revisions
333
333
334 Each patch in the list is itself a list of lines.
334 Each patch in the list is itself a list of lines.
335 """
335 """
336 ui = repo.ui
336 ui = repo.ui
337 prev = repo[b'.'].rev()
337 prev = repo[b'.'].rev()
338 for r in revs:
338 for r in revs:
339 if r == prev and (repo[None].files() or repo[None].deleted()):
339 if r == prev and (repo[None].files() or repo[None].deleted()):
340 ui.warn(_(b'warning: working directory has uncommitted changes\n'))
340 ui.warn(_(b'warning: working directory has uncommitted changes\n'))
341 output = stringio()
341 output = stringio()
342 cmdutil.exportfile(
342 cmdutil.exportfile(
343 repo, [r], output, opts=patch.difffeatureopts(ui, opts, git=True)
343 repo, [r], output, opts=patch.difffeatureopts(ui, opts, git=True)
344 )
344 )
345 yield output.getvalue().split(b'\n')
345 yield output.getvalue().split(b'\n')
346
346
347
347
348 def _getbundle(repo, dest, **opts):
348 def _getbundle(repo, dest, **opts):
349 """return a bundle containing changesets missing in "dest"
349 """return a bundle containing changesets missing in "dest"
350
350
351 The `opts` keyword-arguments are the same as the one accepted by the
351 The `opts` keyword-arguments are the same as the one accepted by the
352 `bundle` command.
352 `bundle` command.
353
353
354 The bundle is a returned as a single in-memory binary blob.
354 The bundle is a returned as a single in-memory binary blob.
355 """
355 """
356 ui = repo.ui
356 ui = repo.ui
357 tmpdir = pycompat.mkdtemp(prefix=b'hg-email-bundle-')
357 tmpdir = pycompat.mkdtemp(prefix=b'hg-email-bundle-')
358 tmpfn = os.path.join(tmpdir, b'bundle')
358 tmpfn = os.path.join(tmpdir, b'bundle')
359 btype = ui.config(b'patchbomb', b'bundletype')
359 btype = ui.config(b'patchbomb', b'bundletype')
360 if btype:
360 if btype:
361 opts['type'] = btype
361 opts['type'] = btype
362 try:
362 try:
363 commands.bundle(ui, repo, tmpfn, dest, **opts)
363 commands.bundle(ui, repo, tmpfn, dest, **opts)
364 return util.readfile(tmpfn)
364 return util.readfile(tmpfn)
365 finally:
365 finally:
366 try:
366 try:
367 os.unlink(tmpfn)
367 os.unlink(tmpfn)
368 except OSError:
368 except OSError:
369 pass
369 pass
370 os.rmdir(tmpdir)
370 os.rmdir(tmpdir)
371
371
372
372
373 def _getdescription(repo, defaultbody, sender, **opts):
373 def _getdescription(repo, defaultbody, sender, **opts):
374 """obtain the body of the introduction message and return it
374 """obtain the body of the introduction message and return it
375
375
376 This is also used for the body of email with an attached bundle.
376 This is also used for the body of email with an attached bundle.
377
377
378 The body can be obtained either from the command line option or entered by
378 The body can be obtained either from the command line option or entered by
379 the user through the editor.
379 the user through the editor.
380 """
380 """
381 ui = repo.ui
381 ui = repo.ui
382 if opts.get('desc'):
382 if opts.get('desc'):
383 body = open(opts.get('desc')).read()
383 body = open(opts.get('desc')).read()
384 else:
384 else:
385 ui.write(
385 ui.write(
386 _(b'\nWrite the introductory message for the patch series.\n\n')
386 _(b'\nWrite the introductory message for the patch series.\n\n')
387 )
387 )
388 body = ui.edit(
388 body = ui.edit(
389 defaultbody, sender, repopath=repo.path, action=b'patchbombbody'
389 defaultbody, sender, repopath=repo.path, action=b'patchbombbody'
390 )
390 )
391 # Save series description in case sendmail fails
391 # Save series description in case sendmail fails
392 msgfile = repo.vfs(b'last-email.txt', b'wb')
392 msgfile = repo.vfs(b'last-email.txt', b'wb')
393 msgfile.write(body)
393 msgfile.write(body)
394 msgfile.close()
394 msgfile.close()
395 return body
395 return body
396
396
397
397
398 def _getbundlemsgs(repo, sender, bundle, **opts):
398 def _getbundlemsgs(repo, sender, bundle, **opts):
399 """Get the full email for sending a given bundle
399 """Get the full email for sending a given bundle
400
400
401 This function returns a list of "email" tuples (subject, content, None).
401 This function returns a list of "email" tuples (subject, content, None).
402 The list is always one message long in that case.
402 The list is always one message long in that case.
403 """
403 """
404 ui = repo.ui
404 ui = repo.ui
405 _charsets = mail._charsets(ui)
405 _charsets = mail._charsets(ui)
406 subj = opts.get('subject') or prompt(
406 subj = opts.get('subject') or prompt(
407 ui, b'Subject:', b'A bundle for your repository'
407 ui, b'Subject:', b'A bundle for your repository'
408 )
408 )
409
409
410 body = _getdescription(repo, b'', sender, **opts)
410 body = _getdescription(repo, b'', sender, **opts)
411 msg = emimemultipart.MIMEMultipart()
411 msg = emimemultipart.MIMEMultipart()
412 if body:
412 if body:
413 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
413 msg.attach(mail.mimeencode(ui, body, _charsets, opts.get('test')))
414 datapart = emimebase.MIMEBase('application', 'x-mercurial-bundle')
414 datapart = emimebase.MIMEBase('application', 'x-mercurial-bundle')
415 datapart.set_payload(bundle)
415 datapart.set_payload(bundle)
416 bundlename = b'%s.hg' % opts.get('bundlename', b'bundle')
416 bundlename = b'%s.hg' % opts.get('bundlename', b'bundle')
417 datapart.add_header(
417 datapart.add_header(
418 'Content-Disposition',
418 'Content-Disposition',
419 'attachment',
419 'attachment',
420 filename=encoding.strfromlocal(bundlename),
420 filename=encoding.strfromlocal(bundlename),
421 )
421 )
422 emailencoders.encode_base64(datapart)
422 emailencoders.encode_base64(datapart)
423 msg.attach(datapart)
423 msg.attach(datapart)
424 msg[b'Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
424 msg[b'Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
425 return [(msg, subj, None)]
425 return [(msg, subj, None)]
426
426
427
427
428 def _makeintro(repo, sender, revs, patches, **opts):
428 def _makeintro(repo, sender, revs, patches, **opts):
429 """make an introduction email, asking the user for content if needed
429 """make an introduction email, asking the user for content if needed
430
430
431 email is returned as (subject, body, cumulative-diffstat)"""
431 email is returned as (subject, body, cumulative-diffstat)"""
432 ui = repo.ui
432 ui = repo.ui
433 _charsets = mail._charsets(ui)
433 _charsets = mail._charsets(ui)
434
434
435 # use the last revision which is likely to be a bookmarked head
435 # use the last revision which is likely to be a bookmarked head
436 prefix = _formatprefix(
436 prefix = _formatprefix(
437 ui, repo, revs.last(), opts.get('flag'), 0, len(patches), numbered=True
437 ui, repo, revs.last(), opts.get('flag'), 0, len(patches), numbered=True
438 )
438 )
439 subj = opts.get('subject') or prompt(
439 subj = opts.get('subject') or prompt(
440 ui, b'(optional) Subject: ', rest=prefix, default=b''
440 ui, b'(optional) Subject: ', rest=prefix, default=b''
441 )
441 )
442 if not subj:
442 if not subj:
443 return None # skip intro if the user doesn't bother
443 return None # skip intro if the user doesn't bother
444
444
445 subj = prefix + b' ' + subj
445 subj = prefix + b' ' + subj
446
446
447 body = b''
447 body = b''
448 if opts.get('diffstat'):
448 if opts.get('diffstat'):
449 # generate a cumulative diffstat of the whole patch series
449 # generate a cumulative diffstat of the whole patch series
450 diffstat = patch.diffstat(sum(patches, []))
450 diffstat = patch.diffstat(sum(patches, []))
451 body = b'\n' + diffstat
451 body = b'\n' + diffstat
452 else:
452 else:
453 diffstat = None
453 diffstat = None
454
454
455 body = _getdescription(repo, body, sender, **opts)
455 body = _getdescription(repo, body, sender, **opts)
456 msg = mail.mimeencode(ui, body, _charsets, opts.get('test'))
456 msg = mail.mimeencode(ui, body, _charsets, opts.get('test'))
457 msg[b'Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
457 msg[b'Subject'] = mail.headencode(ui, subj, _charsets, opts.get('test'))
458 return (msg, subj, diffstat)
458 return (msg, subj, diffstat)
459
459
460
460
461 def _getpatchmsgs(repo, sender, revs, patchnames=None, **opts):
461 def _getpatchmsgs(repo, sender, revs, patchnames=None, **opts):
462 """return a list of emails from a list of patches
462 """return a list of emails from a list of patches
463
463
464 This involves introduction message creation if necessary.
464 This involves introduction message creation if necessary.
465
465
466 This function returns a list of "email" tuples (subject, content, None).
466 This function returns a list of "email" tuples (subject, content, None).
467 """
467 """
468 bytesopts = pycompat.byteskwargs(opts)
468 bytesopts = pycompat.byteskwargs(opts)
469 ui = repo.ui
469 ui = repo.ui
470 _charsets = mail._charsets(ui)
470 _charsets = mail._charsets(ui)
471 patches = list(_getpatches(repo, revs, **opts))
471 patches = list(_getpatches(repo, revs, **opts))
472 msgs = []
472 msgs = []
473
473
474 ui.write(_(b'this patch series consists of %d patches.\n\n') % len(patches))
474 ui.write(_(b'this patch series consists of %d patches.\n\n') % len(patches))
475
475
476 # build the intro message, or skip it if the user declines
476 # build the intro message, or skip it if the user declines
477 if introwanted(ui, bytesopts, len(patches)):
477 if introwanted(ui, bytesopts, len(patches)):
478 msg = _makeintro(repo, sender, revs, patches, **opts)
478 msg = _makeintro(repo, sender, revs, patches, **opts)
479 if msg:
479 if msg:
480 msgs.append(msg)
480 msgs.append(msg)
481
481
482 # are we going to send more than one message?
482 # are we going to send more than one message?
483 numbered = len(msgs) + len(patches) > 1
483 numbered = len(msgs) + len(patches) > 1
484
484
485 # now generate the actual patch messages
485 # now generate the actual patch messages
486 name = None
486 name = None
487 assert len(revs) == len(patches)
487 assert len(revs) == len(patches)
488 for i, (r, p) in enumerate(zip(revs, patches)):
488 for i, (r, p) in enumerate(zip(revs, patches)):
489 if patchnames:
489 if patchnames:
490 name = patchnames[i]
490 name = patchnames[i]
491 msg = makepatch(
491 msg = makepatch(
492 ui,
492 ui,
493 repo,
493 repo,
494 r,
494 r,
495 p,
495 p,
496 bytesopts,
496 bytesopts,
497 _charsets,
497 _charsets,
498 i + 1,
498 i + 1,
499 len(patches),
499 len(patches),
500 numbered,
500 numbered,
501 name,
501 name,
502 )
502 )
503 msgs.append(msg)
503 msgs.append(msg)
504
504
505 return msgs
505 return msgs
506
506
507
507
508 def _getoutgoing(repo, dest, revs):
508 def _getoutgoing(repo, dest, revs):
509 '''Return the revisions present locally but not in dest'''
509 '''Return the revisions present locally but not in dest'''
510 ui = repo.ui
510 ui = repo.ui
511 url = ui.expandpath(dest or b'default-push', dest or b'default')
511 url = ui.expandpath(dest or b'default-push', dest or b'default')
512 url = hg.parseurl(url)[0]
512 url = hg.parseurl(url)[0]
513 ui.status(_(b'comparing with %s\n') % util.hidepassword(url))
513 ui.status(_(b'comparing with %s\n') % util.hidepassword(url))
514
514
515 revs = [r for r in revs if r >= 0]
515 revs = [r for r in revs if r >= 0]
516 if not revs:
516 if not revs:
517 revs = [repo.changelog.tiprev()]
517 revs = [repo.changelog.tiprev()]
518 revs = repo.revs(b'outgoing(%s) and ::%ld', dest or b'', revs)
518 revs = repo.revs(b'outgoing(%s) and ::%ld', dest or b'', revs)
519 if not revs:
519 if not revs:
520 ui.status(_(b"no changes found\n"))
520 ui.status(_(b"no changes found\n"))
521 return revs
521 return revs
522
522
523
523
524 def _msgid(node, timestamp):
524 def _msgid(node, timestamp):
525 hostname = encoding.strtolocal(socket.getfqdn())
525 hostname = encoding.strtolocal(socket.getfqdn())
526 hostname = encoding.environ.get(b'HGHOSTNAME', hostname)
526 hostname = encoding.environ.get(b'HGHOSTNAME', hostname)
527 return b'<%s.%d@%s>' % (node, timestamp, hostname)
527 return b'<%s.%d@%s>' % (node, timestamp, hostname)
528
528
529
529
530 emailopts = [
530 emailopts = [
531 (b'', b'body', None, _(b'send patches as inline message text (default)')),
531 (b'', b'body', None, _(b'send patches as inline message text (default)')),
532 (b'a', b'attach', None, _(b'send patches as attachments')),
532 (b'a', b'attach', None, _(b'send patches as attachments')),
533 (b'i', b'inline', None, _(b'send patches as inline attachments')),
533 (b'i', b'inline', None, _(b'send patches as inline attachments')),
534 (
534 (
535 b'',
535 b'',
536 b'bcc',
536 b'bcc',
537 [],
537 [],
538 _(b'email addresses of blind carbon copy recipients'),
538 _(b'email addresses of blind carbon copy recipients'),
539 _(b'EMAIL'),
539 _(b'EMAIL'),
540 ),
540 ),
541 (b'c', b'cc', [], _(b'email addresses of copy recipients'), _(b'EMAIL')),
541 (b'c', b'cc', [], _(b'email addresses of copy recipients'), _(b'EMAIL')),
542 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
542 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
543 (b'd', b'diffstat', None, _(b'add diffstat output to messages')),
543 (b'd', b'diffstat', None, _(b'add diffstat output to messages')),
544 (
544 (
545 b'',
545 b'',
546 b'date',
546 b'date',
547 b'',
547 b'',
548 _(b'use the given date as the sending date'),
548 _(b'use the given date as the sending date'),
549 _(b'DATE'),
549 _(b'DATE'),
550 ),
550 ),
551 (
551 (
552 b'',
552 b'',
553 b'desc',
553 b'desc',
554 b'',
554 b'',
555 _(b'use the given file as the series description'),
555 _(b'use the given file as the series description'),
556 _(b'FILE'),
556 _(b'FILE'),
557 ),
557 ),
558 (b'f', b'from', b'', _(b'email address of sender'), _(b'EMAIL')),
558 (b'f', b'from', b'', _(b'email address of sender'), _(b'EMAIL')),
559 (b'n', b'test', None, _(b'print messages that would be sent')),
559 (b'n', b'test', None, _(b'print messages that would be sent')),
560 (
560 (
561 b'm',
561 b'm',
562 b'mbox',
562 b'mbox',
563 b'',
563 b'',
564 _(b'write messages to mbox file instead of sending them'),
564 _(b'write messages to mbox file instead of sending them'),
565 _(b'FILE'),
565 _(b'FILE'),
566 ),
566 ),
567 (
567 (
568 b'',
568 b'',
569 b'reply-to',
569 b'reply-to',
570 [],
570 [],
571 _(b'email addresses replies should be sent to'),
571 _(b'email addresses replies should be sent to'),
572 _(b'EMAIL'),
572 _(b'EMAIL'),
573 ),
573 ),
574 (
574 (
575 b's',
575 b's',
576 b'subject',
576 b'subject',
577 b'',
577 b'',
578 _(b'subject of first message (intro or single patch)'),
578 _(b'subject of first message (intro or single patch)'),
579 _(b'TEXT'),
579 _(b'TEXT'),
580 ),
580 ),
581 (
581 (
582 b'',
582 b'',
583 b'in-reply-to',
583 b'in-reply-to',
584 b'',
584 b'',
585 _(b'message identifier to reply to'),
585 _(b'message identifier to reply to'),
586 _(b'MSGID'),
586 _(b'MSGID'),
587 ),
587 ),
588 (b'', b'flag', [], _(b'flags to add in subject prefixes'), _(b'FLAG')),
588 (b'', b'flag', [], _(b'flags to add in subject prefixes'), _(b'FLAG')),
589 (b't', b'to', [], _(b'email addresses of recipients'), _(b'EMAIL')),
589 (b't', b'to', [], _(b'email addresses of recipients'), _(b'EMAIL')),
590 ]
590 ]
591
591
592
592
593 @command(
593 @command(
594 b'email',
594 b'email',
595 [
595 [
596 (b'g', b'git', None, _(b'use git extended diff format')),
596 (b'g', b'git', None, _(b'use git extended diff format')),
597 (b'', b'plain', None, _(b'omit hg patch header')),
597 (b'', b'plain', None, _(b'omit hg patch header')),
598 (
598 (
599 b'o',
599 b'o',
600 b'outgoing',
600 b'outgoing',
601 None,
601 None,
602 _(b'send changes not found in the target repository'),
602 _(b'send changes not found in the target repository'),
603 ),
603 ),
604 (
604 (
605 b'b',
605 b'b',
606 b'bundle',
606 b'bundle',
607 None,
607 None,
608 _(b'send changes not in target as a binary bundle'),
608 _(b'send changes not in target as a binary bundle'),
609 ),
609 ),
610 (
610 (
611 b'B',
611 b'B',
612 b'bookmark',
612 b'bookmark',
613 b'',
613 b'',
614 _(b'send changes only reachable by given bookmark'),
614 _(b'send changes only reachable by given bookmark'),
615 _(b'BOOKMARK'),
615 _(b'BOOKMARK'),
616 ),
616 ),
617 (
617 (
618 b'',
618 b'',
619 b'bundlename',
619 b'bundlename',
620 b'bundle',
620 b'bundle',
621 _(b'name of the bundle attachment file'),
621 _(b'name of the bundle attachment file'),
622 _(b'NAME'),
622 _(b'NAME'),
623 ),
623 ),
624 (b'r', b'rev', [], _(b'a revision to send'), _(b'REV')),
624 (b'r', b'rev', [], _(b'a revision to send'), _(b'REV')),
625 (
625 (
626 b'',
626 b'',
627 b'force',
627 b'force',
628 None,
628 None,
629 _(
629 _(
630 b'run even when remote repository is unrelated '
630 b'run even when remote repository is unrelated '
631 b'(with -b/--bundle)'
631 b'(with -b/--bundle)'
632 ),
632 ),
633 ),
633 ),
634 (
634 (
635 b'',
635 b'',
636 b'base',
636 b'base',
637 [],
637 [],
638 _(
638 _(
639 b'a base changeset to specify instead of a destination '
639 b'a base changeset to specify instead of a destination '
640 b'(with -b/--bundle)'
640 b'(with -b/--bundle)'
641 ),
641 ),
642 _(b'REV'),
642 _(b'REV'),
643 ),
643 ),
644 (
644 (
645 b'',
645 b'',
646 b'intro',
646 b'intro',
647 None,
647 None,
648 _(b'send an introduction email for a single patch'),
648 _(b'send an introduction email for a single patch'),
649 ),
649 ),
650 ]
650 ]
651 + emailopts
651 + emailopts
652 + cmdutil.remoteopts,
652 + cmdutil.remoteopts,
653 _(b'hg email [OPTION]... [DEST]...'),
653 _(b'hg email [OPTION]... [DEST]...'),
654 helpcategory=command.CATEGORY_IMPORT_EXPORT,
654 helpcategory=command.CATEGORY_IMPORT_EXPORT,
655 )
655 )
656 def email(ui, repo, *revs, **opts):
656 def email(ui, repo, *revs, **opts):
657 '''send changesets by email
657 '''send changesets by email
658
658
659 By default, diffs are sent in the format generated by
659 By default, diffs are sent in the format generated by
660 :hg:`export`, one per message. The series starts with a "[PATCH 0
660 :hg:`export`, one per message. The series starts with a "[PATCH 0
661 of N]" introduction, which describes the series as a whole.
661 of N]" introduction, which describes the series as a whole.
662
662
663 Each patch email has a Subject line of "[PATCH M of N] ...", using
663 Each patch email has a Subject line of "[PATCH M of N] ...", using
664 the first line of the changeset description as the subject text.
664 the first line of the changeset description as the subject text.
665 The message contains two or three parts. First, the changeset
665 The message contains two or three parts. First, the changeset
666 description.
666 description.
667
667
668 With the -d/--diffstat option, if the diffstat program is
668 With the -d/--diffstat option, if the diffstat program is
669 installed, the result of running diffstat on the patch is inserted.
669 installed, the result of running diffstat on the patch is inserted.
670
670
671 Finally, the patch itself, as generated by :hg:`export`.
671 Finally, the patch itself, as generated by :hg:`export`.
672
672
673 With the -d/--diffstat or --confirm options, you will be presented
673 With the -d/--diffstat or --confirm options, you will be presented
674 with a final summary of all messages and asked for confirmation before
674 with a final summary of all messages and asked for confirmation before
675 the messages are sent.
675 the messages are sent.
676
676
677 By default the patch is included as text in the email body for
677 By default the patch is included as text in the email body for
678 easy reviewing. Using the -a/--attach option will instead create
678 easy reviewing. Using the -a/--attach option will instead create
679 an attachment for the patch. With -i/--inline an inline attachment
679 an attachment for the patch. With -i/--inline an inline attachment
680 will be created. You can include a patch both as text in the email
680 will be created. You can include a patch both as text in the email
681 body and as a regular or an inline attachment by combining the
681 body and as a regular or an inline attachment by combining the
682 -a/--attach or -i/--inline with the --body option.
682 -a/--attach or -i/--inline with the --body option.
683
683
684 With -B/--bookmark changesets reachable by the given bookmark are
684 With -B/--bookmark changesets reachable by the given bookmark are
685 selected.
685 selected.
686
686
687 With -o/--outgoing, emails will be generated for patches not found
687 With -o/--outgoing, emails will be generated for patches not found
688 in the destination repository (or only those which are ancestors
688 in the destination repository (or only those which are ancestors
689 of the specified revisions if any are provided)
689 of the specified revisions if any are provided)
690
690
691 With -b/--bundle, changesets are selected as for --outgoing, but a
691 With -b/--bundle, changesets are selected as for --outgoing, but a
692 single email containing a binary Mercurial bundle as an attachment
692 single email containing a binary Mercurial bundle as an attachment
693 will be sent. Use the ``patchbomb.bundletype`` config option to
693 will be sent. Use the ``patchbomb.bundletype`` config option to
694 control the bundle type as with :hg:`bundle --type`.
694 control the bundle type as with :hg:`bundle --type`.
695
695
696 With -m/--mbox, instead of previewing each patchbomb message in a
696 With -m/--mbox, instead of previewing each patchbomb message in a
697 pager or sending the messages directly, it will create a UNIX
697 pager or sending the messages directly, it will create a UNIX
698 mailbox file with the patch emails. This mailbox file can be
698 mailbox file with the patch emails. This mailbox file can be
699 previewed with any mail user agent which supports UNIX mbox
699 previewed with any mail user agent which supports UNIX mbox
700 files.
700 files.
701
701
702 With -n/--test, all steps will run, but mail will not be sent.
702 With -n/--test, all steps will run, but mail will not be sent.
703 You will be prompted for an email recipient address, a subject and
703 You will be prompted for an email recipient address, a subject and
704 an introductory message describing the patches of your patchbomb.
704 an introductory message describing the patches of your patchbomb.
705 Then when all is done, patchbomb messages are displayed.
705 Then when all is done, patchbomb messages are displayed.
706
706
707 In case email sending fails, you will find a backup of your series
707 In case email sending fails, you will find a backup of your series
708 introductory message in ``.hg/last-email.txt``.
708 introductory message in ``.hg/last-email.txt``.
709
709
710 The default behavior of this command can be customized through
710 The default behavior of this command can be customized through
711 configuration. (See :hg:`help patchbomb` for details)
711 configuration. (See :hg:`help patchbomb` for details)
712
712
713 Examples::
713 Examples::
714
714
715 hg email -r 3000 # send patch 3000 only
715 hg email -r 3000 # send patch 3000 only
716 hg email -r 3000 -r 3001 # send patches 3000 and 3001
716 hg email -r 3000 -r 3001 # send patches 3000 and 3001
717 hg email -r 3000:3005 # send patches 3000 through 3005
717 hg email -r 3000:3005 # send patches 3000 through 3005
718 hg email 3000 # send patch 3000 (deprecated)
718 hg email 3000 # send patch 3000 (deprecated)
719
719
720 hg email -o # send all patches not in default
720 hg email -o # send all patches not in default
721 hg email -o DEST # send all patches not in DEST
721 hg email -o DEST # send all patches not in DEST
722 hg email -o -r 3000 # send all ancestors of 3000 not in default
722 hg email -o -r 3000 # send all ancestors of 3000 not in default
723 hg email -o -r 3000 DEST # send all ancestors of 3000 not in DEST
723 hg email -o -r 3000 DEST # send all ancestors of 3000 not in DEST
724
724
725 hg email -B feature # send all ancestors of feature bookmark
725 hg email -B feature # send all ancestors of feature bookmark
726
726
727 hg email -b # send bundle of all patches not in default
727 hg email -b # send bundle of all patches not in default
728 hg email -b DEST # send bundle of all patches not in DEST
728 hg email -b DEST # send bundle of all patches not in DEST
729 hg email -b -r 3000 # bundle of all ancestors of 3000 not in default
729 hg email -b -r 3000 # bundle of all ancestors of 3000 not in default
730 hg email -b -r 3000 DEST # bundle of all ancestors of 3000 not in DEST
730 hg email -b -r 3000 DEST # bundle of all ancestors of 3000 not in DEST
731
731
732 hg email -o -m mbox && # generate an mbox file...
732 hg email -o -m mbox && # generate an mbox file...
733 mutt -R -f mbox # ... and view it with mutt
733 mutt -R -f mbox # ... and view it with mutt
734 hg email -o -m mbox && # generate an mbox file ...
734 hg email -o -m mbox && # generate an mbox file ...
735 formail -s sendmail \\ # ... and use formail to send from the mbox
735 formail -s sendmail \\ # ... and use formail to send from the mbox
736 -bm -t < mbox # ... using sendmail
736 -bm -t < mbox # ... using sendmail
737
737
738 Before using this command, you will need to enable email in your
738 Before using this command, you will need to enable email in your
739 hgrc. See the [email] section in hgrc(5) for details.
739 hgrc. See the [email] section in hgrc(5) for details.
740 '''
740 '''
741 opts = pycompat.byteskwargs(opts)
741 opts = pycompat.byteskwargs(opts)
742
742
743 _charsets = mail._charsets(ui)
743 _charsets = mail._charsets(ui)
744
744
745 bundle = opts.get(b'bundle')
745 bundle = opts.get(b'bundle')
746 date = opts.get(b'date')
746 date = opts.get(b'date')
747 mbox = opts.get(b'mbox')
747 mbox = opts.get(b'mbox')
748 outgoing = opts.get(b'outgoing')
748 outgoing = opts.get(b'outgoing')
749 rev = opts.get(b'rev')
749 rev = opts.get(b'rev')
750 bookmark = opts.get(b'bookmark')
750 bookmark = opts.get(b'bookmark')
751
751
752 if not (opts.get(b'test') or mbox):
752 if not (opts.get(b'test') or mbox):
753 # really sending
753 # really sending
754 mail.validateconfig(ui)
754 mail.validateconfig(ui)
755
755
756 if not (revs or rev or outgoing or bundle or bookmark):
756 if not (revs or rev or outgoing or bundle or bookmark):
757 raise error.Abort(
757 raise error.Abort(
758 _(b'specify at least one changeset with -B, -r or -o')
758 _(b'specify at least one changeset with -B, -r or -o')
759 )
759 )
760
760
761 if outgoing and bundle:
761 if outgoing and bundle:
762 raise error.Abort(
762 raise error.Abort(
763 _(
763 _(
764 b"--outgoing mode always on with --bundle;"
764 b"--outgoing mode always on with --bundle;"
765 b" do not re-specify --outgoing"
765 b" do not re-specify --outgoing"
766 )
766 )
767 )
767 )
768 if rev and bookmark:
768 if rev and bookmark:
769 raise error.Abort(_(b"-r and -B are mutually exclusive"))
769 raise error.Abort(_(b"-r and -B are mutually exclusive"))
770
770
771 if outgoing or bundle:
771 if outgoing or bundle:
772 if len(revs) > 1:
772 if len(revs) > 1:
773 raise error.Abort(_(b"too many destinations"))
773 raise error.Abort(_(b"too many destinations"))
774 if revs:
774 if revs:
775 dest = revs[0]
775 dest = revs[0]
776 else:
776 else:
777 dest = None
777 dest = None
778 revs = []
778 revs = []
779
779
780 if rev:
780 if rev:
781 if revs:
781 if revs:
782 raise error.Abort(_(b'use only one form to specify the revision'))
782 raise error.Abort(_(b'use only one form to specify the revision'))
783 revs = rev
783 revs = rev
784 elif bookmark:
784 elif bookmark:
785 if bookmark not in repo._bookmarks:
785 if bookmark not in repo._bookmarks:
786 raise error.Abort(_(b"bookmark '%s' not found") % bookmark)
786 raise error.Abort(_(b"bookmark '%s' not found") % bookmark)
787 revs = scmutil.bookmarkrevs(repo, bookmark)
787 revs = scmutil.bookmarkrevs(repo, bookmark)
788
788
789 revs = scmutil.revrange(repo, revs)
789 revs = scmutil.revrange(repo, revs)
790 if outgoing:
790 if outgoing:
791 revs = _getoutgoing(repo, dest, revs)
791 revs = _getoutgoing(repo, dest, revs)
792 if bundle:
792 if bundle:
793 opts[b'revs'] = [b"%d" % r for r in revs]
793 opts[b'revs'] = [b"%d" % r for r in revs]
794
794
795 # check if revision exist on the public destination
795 # check if revision exist on the public destination
796 publicurl = repo.ui.config(b'patchbomb', b'publicurl')
796 publicurl = repo.ui.config(b'patchbomb', b'publicurl')
797 if publicurl:
797 if publicurl:
798 repo.ui.debug(b'checking that revision exist in the public repo\n')
798 repo.ui.debug(b'checking that revision exist in the public repo\n')
799 try:
799 try:
800 publicpeer = hg.peer(repo, {}, publicurl)
800 publicpeer = hg.peer(repo, {}, publicurl)
801 except error.RepoError:
801 except error.RepoError:
802 repo.ui.write_err(
802 repo.ui.write_err(
803 _(b'unable to access public repo: %s\n') % publicurl
803 _(b'unable to access public repo: %s\n') % publicurl
804 )
804 )
805 raise
805 raise
806 if not publicpeer.capable(b'known'):
806 if not publicpeer.capable(b'known'):
807 repo.ui.debug(b'skipping existence checks: public repo too old\n')
807 repo.ui.debug(b'skipping existence checks: public repo too old\n')
808 else:
808 else:
809 out = [repo[r] for r in revs]
809 out = [repo[r] for r in revs]
810 known = publicpeer.known(h.node() for h in out)
810 known = publicpeer.known(h.node() for h in out)
811 missing = []
811 missing = []
812 for idx, h in enumerate(out):
812 for idx, h in enumerate(out):
813 if not known[idx]:
813 if not known[idx]:
814 missing.append(h)
814 missing.append(h)
815 if missing:
815 if missing:
816 if len(missing) > 1:
816 if len(missing) > 1:
817 msg = _(b'public "%s" is missing %s and %i others')
817 msg = _(b'public "%s" is missing %s and %i others')
818 msg %= (publicurl, missing[0], len(missing) - 1)
818 msg %= (publicurl, missing[0], len(missing) - 1)
819 else:
819 else:
820 msg = _(b'public url %s is missing %s')
820 msg = _(b'public url %s is missing %s')
821 msg %= (publicurl, missing[0])
821 msg %= (publicurl, missing[0])
822 missingrevs = [ctx.rev() for ctx in missing]
822 missingrevs = [ctx.rev() for ctx in missing]
823 revhint = b' '.join(
823 revhint = b' '.join(
824 b'-r %s' % h for h in repo.set(b'heads(%ld)', missingrevs)
824 b'-r %s' % h for h in repo.set(b'heads(%ld)', missingrevs)
825 )
825 )
826 hint = _(b"use 'hg push %s %s'") % (publicurl, revhint)
826 hint = _(b"use 'hg push %s %s'") % (publicurl, revhint)
827 raise error.Abort(msg, hint=hint)
827 raise error.Abort(msg, hint=hint)
828
828
829 # start
829 # start
830 if date:
830 if date:
831 start_time = dateutil.parsedate(date)
831 start_time = dateutil.parsedate(date)
832 else:
832 else:
833 start_time = dateutil.makedate()
833 start_time = dateutil.makedate()
834
834
835 def genmsgid(id):
835 def genmsgid(id):
836 return _msgid(id[:20], int(start_time[0]))
836 return _msgid(id[:20], int(start_time[0]))
837
837
838 # deprecated config: patchbomb.from
838 # deprecated config: patchbomb.from
839 sender = (
839 sender = (
840 opts.get(b'from')
840 opts.get(b'from')
841 or ui.config(b'email', b'from')
841 or ui.config(b'email', b'from')
842 or ui.config(b'patchbomb', b'from')
842 or ui.config(b'patchbomb', b'from')
843 or prompt(ui, b'From', ui.username())
843 or prompt(ui, b'From', ui.username())
844 )
844 )
845
845
846 if bundle:
846 if bundle:
847 stropts = pycompat.strkwargs(opts)
847 stropts = pycompat.strkwargs(opts)
848 bundledata = _getbundle(repo, dest, **stropts)
848 bundledata = _getbundle(repo, dest, **stropts)
849 bundleopts = stropts.copy()
849 bundleopts = stropts.copy()
850 bundleopts.pop('bundle', None) # already processed
850 bundleopts.pop('bundle', None) # already processed
851 msgs = _getbundlemsgs(repo, sender, bundledata, **bundleopts)
851 msgs = _getbundlemsgs(repo, sender, bundledata, **bundleopts)
852 else:
852 else:
853 msgs = _getpatchmsgs(repo, sender, revs, **pycompat.strkwargs(opts))
853 msgs = _getpatchmsgs(repo, sender, revs, **pycompat.strkwargs(opts))
854
854
855 showaddrs = []
855 showaddrs = []
856
856
857 def getaddrs(header, ask=False, default=None):
857 def getaddrs(header, ask=False, default=None):
858 configkey = header.lower()
858 configkey = header.lower()
859 opt = header.replace(b'-', b'_').lower()
859 opt = header.replace(b'-', b'_').lower()
860 addrs = opts.get(opt)
860 addrs = opts.get(opt)
861 if addrs:
861 if addrs:
862 showaddrs.append(b'%s: %s' % (header, b', '.join(addrs)))
862 showaddrs.append(b'%s: %s' % (header, b', '.join(addrs)))
863 return mail.addrlistencode(ui, addrs, _charsets, opts.get(b'test'))
863 return mail.addrlistencode(ui, addrs, _charsets, opts.get(b'test'))
864
864
865 # not on the command line: fallback to config and then maybe ask
865 # not on the command line: fallback to config and then maybe ask
866 addr = ui.config(b'email', configkey) or ui.config(
866 addr = ui.config(b'email', configkey) or ui.config(
867 b'patchbomb', configkey
867 b'patchbomb', configkey
868 )
868 )
869 if not addr:
869 if not addr:
870 specified = ui.hasconfig(b'email', configkey) or ui.hasconfig(
870 specified = ui.hasconfig(b'email', configkey) or ui.hasconfig(
871 b'patchbomb', configkey
871 b'patchbomb', configkey
872 )
872 )
873 if not specified and ask:
873 if not specified and ask:
874 addr = prompt(ui, header, default=default)
874 addr = prompt(ui, header, default=default)
875 if addr:
875 if addr:
876 showaddrs.append(b'%s: %s' % (header, addr))
876 showaddrs.append(b'%s: %s' % (header, addr))
877 return mail.addrlistencode(ui, [addr], _charsets, opts.get(b'test'))
877 return mail.addrlistencode(ui, [addr], _charsets, opts.get(b'test'))
878 elif default:
878 elif default:
879 return mail.addrlistencode(
879 return mail.addrlistencode(
880 ui, [default], _charsets, opts.get(b'test')
880 ui, [default], _charsets, opts.get(b'test')
881 )
881 )
882 return []
882 return []
883
883
884 to = getaddrs(b'To', ask=True)
884 to = getaddrs(b'To', ask=True)
885 if not to:
885 if not to:
886 # we can get here in non-interactive mode
886 # we can get here in non-interactive mode
887 raise error.Abort(_(b'no recipient addresses provided'))
887 raise error.Abort(_(b'no recipient addresses provided'))
888 cc = getaddrs(b'Cc', ask=True, default=b'')
888 cc = getaddrs(b'Cc', ask=True, default=b'')
889 bcc = getaddrs(b'Bcc')
889 bcc = getaddrs(b'Bcc')
890 replyto = getaddrs(b'Reply-To')
890 replyto = getaddrs(b'Reply-To')
891
891
892 confirm = ui.configbool(b'patchbomb', b'confirm')
892 confirm = ui.configbool(b'patchbomb', b'confirm')
893 confirm |= bool(opts.get(b'diffstat') or opts.get(b'confirm'))
893 confirm |= bool(opts.get(b'diffstat') or opts.get(b'confirm'))
894
894
895 if confirm:
895 if confirm:
896 ui.write(_(b'\nFinal summary:\n\n'), label=b'patchbomb.finalsummary')
896 ui.write(_(b'\nFinal summary:\n\n'), label=b'patchbomb.finalsummary')
897 ui.write((b'From: %s\n' % sender), label=b'patchbomb.from')
897 ui.write((b'From: %s\n' % sender), label=b'patchbomb.from')
898 for addr in showaddrs:
898 for addr in showaddrs:
899 ui.write(b'%s\n' % addr, label=b'patchbomb.to')
899 ui.write(b'%s\n' % addr, label=b'patchbomb.to')
900 for m, subj, ds in msgs:
900 for m, subj, ds in msgs:
901 ui.write((b'Subject: %s\n' % subj), label=b'patchbomb.subject')
901 ui.write((b'Subject: %s\n' % subj), label=b'patchbomb.subject')
902 if ds:
902 if ds:
903 ui.write(ds, label=b'patchbomb.diffstats')
903 ui.write(ds, label=b'patchbomb.diffstats')
904 ui.write(b'\n')
904 ui.write(b'\n')
905 if ui.promptchoice(
905 if ui.promptchoice(
906 _(b'are you sure you want to send (yn)?$$ &Yes $$ &No')
906 _(b'are you sure you want to send (yn)?$$ &Yes $$ &No')
907 ):
907 ):
908 raise error.Abort(_(b'patchbomb canceled'))
908 raise error.Abort(_(b'patchbomb canceled'))
909
909
910 ui.write(b'\n')
910 ui.write(b'\n')
911
911
912 parent = opts.get(b'in_reply_to') or None
912 parent = opts.get(b'in_reply_to') or None
913 # angle brackets may be omitted, they're not semantically part of the msg-id
913 # angle brackets may be omitted, they're not semantically part of the msg-id
914 if parent is not None:
914 if parent is not None:
915 if not parent.startswith(b'<'):
915 if not parent.startswith(b'<'):
916 parent = b'<' + parent
916 parent = b'<' + parent
917 if not parent.endswith(b'>'):
917 if not parent.endswith(b'>'):
918 parent += b'>'
918 parent += b'>'
919
919
920 sender_addr = eutil.parseaddr(encoding.strfromlocal(sender))[1]
920 sender_addr = eutil.parseaddr(encoding.strfromlocal(sender))[1]
921 sender = mail.addressencode(ui, sender, _charsets, opts.get(b'test'))
921 sender = mail.addressencode(ui, sender, _charsets, opts.get(b'test'))
922 sendmail = None
922 sendmail = None
923 firstpatch = None
923 firstpatch = None
924 progress = ui.makeprogress(
924 progress = ui.makeprogress(
925 _(b'sending'), unit=_(b'emails'), total=len(msgs)
925 _(b'sending'), unit=_(b'emails'), total=len(msgs)
926 )
926 )
927 for i, (m, subj, ds) in enumerate(msgs):
927 for i, (m, subj, ds) in enumerate(msgs):
928 try:
928 try:
929 m[b'Message-Id'] = genmsgid(m[b'X-Mercurial-Node'])
929 m[b'Message-Id'] = genmsgid(m[b'X-Mercurial-Node'])
930 if not firstpatch:
930 if not firstpatch:
931 firstpatch = m[b'Message-Id']
931 firstpatch = m[b'Message-Id']
932 m[b'X-Mercurial-Series-Id'] = firstpatch
932 m[b'X-Mercurial-Series-Id'] = firstpatch
933 except TypeError:
933 except TypeError:
934 m[b'Message-Id'] = genmsgid(b'patchbomb')
934 m[b'Message-Id'] = genmsgid(b'patchbomb')
935 if parent:
935 if parent:
936 m[b'In-Reply-To'] = parent
936 m[b'In-Reply-To'] = parent
937 m[b'References'] = parent
937 m[b'References'] = parent
938 if not parent or b'X-Mercurial-Node' not in m:
938 if not parent or b'X-Mercurial-Node' not in m:
939 parent = m[b'Message-Id']
939 parent = m[b'Message-Id']
940
940
941 m[b'User-Agent'] = b'Mercurial-patchbomb/%s' % util.version()
941 m[b'User-Agent'] = b'Mercurial-patchbomb/%s' % util.version()
942 m[b'Date'] = eutil.formatdate(start_time[0], localtime=True)
942 m[b'Date'] = eutil.formatdate(start_time[0], localtime=True)
943
943
944 start_time = (start_time[0] + 1, start_time[1])
944 start_time = (start_time[0] + 1, start_time[1])
945 m[b'From'] = sender
945 m[b'From'] = sender
946 m[b'To'] = b', '.join(to)
946 m[b'To'] = ', '.join(to)
947 if cc:
947 if cc:
948 m[b'Cc'] = b', '.join(cc)
948 m[b'Cc'] = ', '.join(cc)
949 if bcc:
949 if bcc:
950 m[b'Bcc'] = b', '.join(bcc)
950 m[b'Bcc'] = ', '.join(bcc)
951 if replyto:
951 if replyto:
952 m[b'Reply-To'] = b', '.join(replyto)
952 m[b'Reply-To'] = ', '.join(replyto)
953 # Fix up all headers to be native strings.
953 # Fix up all headers to be native strings.
954 # TODO(durin42): this should probably be cleaned up above in the future.
954 # TODO(durin42): this should probably be cleaned up above in the future.
955 if pycompat.ispy3:
955 if pycompat.ispy3:
956 for hdr, val in list(m.items()):
956 for hdr, val in list(m.items()):
957 change = False
957 change = False
958 if isinstance(hdr, bytes):
958 if isinstance(hdr, bytes):
959 del m[hdr]
959 del m[hdr]
960 hdr = pycompat.strurl(hdr)
960 hdr = pycompat.strurl(hdr)
961 change = True
961 change = True
962 if isinstance(val, bytes):
962 if isinstance(val, bytes):
963 # header value should be ASCII since it's encoded by
963 # header value should be ASCII since it's encoded by
964 # mail.headencode(), but -n/--test disables it and raw
964 # mail.headencode(), but -n/--test disables it and raw
965 # value of platform encoding is stored.
965 # value of platform encoding is stored.
966 val = encoding.strfromlocal(val)
966 val = encoding.strfromlocal(val)
967 if not change:
967 if not change:
968 # prevent duplicate headers
968 # prevent duplicate headers
969 del m[hdr]
969 del m[hdr]
970 change = True
970 change = True
971 if change:
971 if change:
972 m[hdr] = val
972 m[hdr] = val
973 if opts.get(b'test'):
973 if opts.get(b'test'):
974 ui.status(_(b'displaying '), subj, b' ...\n')
974 ui.status(_(b'displaying '), subj, b' ...\n')
975 ui.pager(b'email')
975 ui.pager(b'email')
976 generator = mail.Generator(ui, mangle_from_=False)
976 generator = mail.Generator(ui, mangle_from_=False)
977 try:
977 try:
978 generator.flatten(m, 0)
978 generator.flatten(m, 0)
979 ui.write(b'\n')
979 ui.write(b'\n')
980 except IOError as inst:
980 except IOError as inst:
981 if inst.errno != errno.EPIPE:
981 if inst.errno != errno.EPIPE:
982 raise
982 raise
983 else:
983 else:
984 if not sendmail:
984 if not sendmail:
985 sendmail = mail.connect(ui, mbox=mbox)
985 sendmail = mail.connect(ui, mbox=mbox)
986 ui.status(_(b'sending '), subj, b' ...\n')
986 ui.status(_(b'sending '), subj, b' ...\n')
987 progress.update(i, item=subj)
987 progress.update(i, item=subj)
988 if not mbox:
988 if not mbox:
989 # Exim does not remove the Bcc field
989 # Exim does not remove the Bcc field
990 del m[b'Bcc']
990 del m[b'Bcc']
991 fp = stringio()
991 fp = stringio()
992 generator = mail.Generator(fp, mangle_from_=False)
992 generator = mail.Generator(fp, mangle_from_=False)
993 generator.flatten(m, 0)
993 generator.flatten(m, 0)
994 alldests = to + bcc + cc
994 alldests = to + bcc + cc
995 alldests = [encoding.strfromlocal(d) for d in alldests]
996 sendmail(sender_addr, alldests, fp.getvalue())
995 sendmail(sender_addr, alldests, fp.getvalue())
997
996
998 progress.complete()
997 progress.complete()
@@ -1,485 +1,483
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('rb')
97 self.file = new_socket.makefile('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('%a %b %d %H:%M:%S %Y', time.localtime())
204 date = time.strftime('%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 # Experimentally charset is okay as a bytes even if the type
282 # Experimentally charset is okay as a bytes even if the type
283 # stubs disagree.
283 # stubs disagree.
284 cs = email.charset.Charset(charset) # pytype: disable=wrong-arg-types
284 cs = email.charset.Charset(charset) # pytype: disable=wrong-arg-types
285 msg = email.message.Message()
285 msg = email.message.Message()
286 msg.set_type(pycompat.sysstr(b'text/' + subtype))
286 msg.set_type(pycompat.sysstr(b'text/' + subtype))
287
287
288 for line in body.splitlines():
288 for line in body.splitlines():
289 if len(line) > 950:
289 if len(line) > 950:
290 cs.body_encoding = email.charset.QP
290 cs.body_encoding = email.charset.QP
291 break
291 break
292
292
293 # On Python 2, this simply assigns a value. Python 3 inspects
293 # On Python 2, this simply assigns a value. Python 3 inspects
294 # body and does different things depending on whether it has
294 # body and does different things depending on whether it has
295 # encode() or decode() attributes. We can get the old behavior
295 # encode() or decode() attributes. We can get the old behavior
296 # if we pass a str and charset is None and we call set_charset().
296 # if we pass a str and charset is None and we call set_charset().
297 # But we may get into trouble later due to Python attempting to
297 # But we may get into trouble later due to Python attempting to
298 # encode/decode using the registered charset (or attempting to
298 # encode/decode using the registered charset (or attempting to
299 # use ascii in the absence of a charset).
299 # use ascii in the absence of a charset).
300 msg.set_payload(body, cs)
300 msg.set_payload(body, cs)
301
301
302 return msg
302 return msg
303
303
304
304
305 def _charsets(ui):
305 def _charsets(ui):
306 '''Obtains charsets to send mail parts not containing patches.'''
306 '''Obtains charsets to send mail parts not containing patches.'''
307 charsets = [cs.lower() for cs in ui.configlist(b'email', b'charsets')]
307 charsets = [cs.lower() for cs in ui.configlist(b'email', b'charsets')]
308 fallbacks = [
308 fallbacks = [
309 encoding.fallbackencoding.lower(),
309 encoding.fallbackencoding.lower(),
310 encoding.encoding.lower(),
310 encoding.encoding.lower(),
311 b'utf-8',
311 b'utf-8',
312 ]
312 ]
313 for cs in fallbacks: # find unique charsets while keeping order
313 for cs in fallbacks: # find unique charsets while keeping order
314 if cs not in charsets:
314 if cs not in charsets:
315 charsets.append(cs)
315 charsets.append(cs)
316 return [cs for cs in charsets if not cs.endswith(b'ascii')]
316 return [cs for cs in charsets if not cs.endswith(b'ascii')]
317
317
318
318
319 def _encode(ui, s, charsets):
319 def _encode(ui, s, charsets):
320 '''Returns (converted) string, charset tuple.
320 '''Returns (converted) string, charset tuple.
321 Finds out best charset by cycling through sendcharsets in descending
321 Finds out best charset by cycling through sendcharsets in descending
322 order. Tries both encoding and fallbackencoding for input. Only as
322 order. Tries both encoding and fallbackencoding for input. Only as
323 last resort send as is in fake ascii.
323 last resort send as is in fake ascii.
324 Caveat: Do not use for mail parts containing patches!'''
324 Caveat: Do not use for mail parts containing patches!'''
325 sendcharsets = charsets or _charsets(ui)
325 sendcharsets = charsets or _charsets(ui)
326 if not isinstance(s, bytes):
326 if not isinstance(s, bytes):
327 # We have unicode data, which we need to try and encode to
327 # We have unicode data, which we need to try and encode to
328 # some reasonable-ish encoding. Try the encodings the user
328 # some reasonable-ish encoding. Try the encodings the user
329 # wants, and fall back to garbage-in-ascii.
329 # wants, and fall back to garbage-in-ascii.
330 for ocs in sendcharsets:
330 for ocs in sendcharsets:
331 try:
331 try:
332 return s.encode(pycompat.sysstr(ocs)), ocs
332 return s.encode(pycompat.sysstr(ocs)), ocs
333 except UnicodeEncodeError:
333 except UnicodeEncodeError:
334 pass
334 pass
335 except LookupError:
335 except LookupError:
336 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
336 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
337 else:
337 else:
338 # Everything failed, ascii-armor what we've got and send it.
338 # Everything failed, ascii-armor what we've got and send it.
339 return s.encode('ascii', 'backslashreplace')
339 return s.encode('ascii', 'backslashreplace')
340 # We have a bytes of unknown encoding. We'll try and guess a valid
340 # We have a bytes of unknown encoding. We'll try and guess a valid
341 # encoding, falling back to pretending we had ascii even though we
341 # encoding, falling back to pretending we had ascii even though we
342 # know that's wrong.
342 # know that's wrong.
343 try:
343 try:
344 s.decode('ascii')
344 s.decode('ascii')
345 except UnicodeDecodeError:
345 except UnicodeDecodeError:
346 for ics in (encoding.encoding, encoding.fallbackencoding):
346 for ics in (encoding.encoding, encoding.fallbackencoding):
347 ics = pycompat.sysstr(ics)
347 ics = pycompat.sysstr(ics)
348 try:
348 try:
349 u = s.decode(ics)
349 u = s.decode(ics)
350 except UnicodeDecodeError:
350 except UnicodeDecodeError:
351 continue
351 continue
352 for ocs in sendcharsets:
352 for ocs in sendcharsets:
353 try:
353 try:
354 return u.encode(pycompat.sysstr(ocs)), ocs
354 return u.encode(pycompat.sysstr(ocs)), ocs
355 except UnicodeEncodeError:
355 except UnicodeEncodeError:
356 pass
356 pass
357 except LookupError:
357 except LookupError:
358 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
358 ui.warn(_(b'ignoring invalid sendcharset: %s\n') % ocs)
359 # if ascii, or all conversion attempts fail, send (broken) ascii
359 # if ascii, or all conversion attempts fail, send (broken) ascii
360 return s, b'us-ascii'
360 return s, b'us-ascii'
361
361
362
362
363 def headencode(ui, s, charsets=None, display=False):
363 def headencode(ui, s, charsets=None, display=False):
364 '''Returns RFC-2047 compliant header from given string.'''
364 '''Returns RFC-2047 compliant header from given string.'''
365 if not display:
365 if not display:
366 # split into words?
366 # split into words?
367 s, cs = _encode(ui, s, charsets)
367 s, cs = _encode(ui, s, charsets)
368 return email.header.Header(s, cs).encode()
368 return email.header.Header(s, cs).encode()
369 return encoding.strfromlocal(s)
369 return encoding.strfromlocal(s)
370
370
371
371
372 def _addressencode(ui, name, addr, charsets=None):
372 def _addressencode(ui, name, addr, charsets=None):
373 assert isinstance(addr, bytes)
373 assert isinstance(addr, bytes)
374 name = headencode(ui, name, charsets)
374 name = headencode(ui, name, charsets)
375 try:
375 try:
376 acc, dom = addr.split(b'@')
376 acc, dom = addr.split(b'@')
377 acc.decode('ascii')
377 acc.decode('ascii')
378 dom = dom.decode(pycompat.sysstr(encoding.encoding)).encode('idna')
378 dom = dom.decode(pycompat.sysstr(encoding.encoding)).encode('idna')
379 addr = b'%s@%s' % (acc, dom)
379 addr = b'%s@%s' % (acc, dom)
380 except UnicodeDecodeError:
380 except UnicodeDecodeError:
381 raise error.Abort(_(b'invalid email address: %s') % addr)
381 raise error.Abort(_(b'invalid email address: %s') % addr)
382 except ValueError:
382 except ValueError:
383 try:
383 try:
384 # too strict?
384 # too strict?
385 addr.decode('ascii')
385 addr.decode('ascii')
386 except UnicodeDecodeError:
386 except UnicodeDecodeError:
387 raise error.Abort(_(b'invalid local address: %s') % addr)
387 raise error.Abort(_(b'invalid local address: %s') % addr)
388 return pycompat.bytesurl(
388 return email.utils.formataddr((name, encoding.strfromlocal(addr)))
389 email.utils.formataddr((name, encoding.strfromlocal(addr)))
390 )
391
389
392
390
393 def addressencode(ui, address, charsets=None, display=False):
391 def addressencode(ui, address, charsets=None, display=False):
394 '''Turns address into RFC-2047 compliant header.'''
392 '''Turns address into RFC-2047 compliant header.'''
395 if display or not address:
393 if display or not address:
396 return address or b''
394 return encoding.strfromlocal(address or b'')
397 name, addr = email.utils.parseaddr(encoding.strfromlocal(address))
395 name, addr = email.utils.parseaddr(encoding.strfromlocal(address))
398 return _addressencode(ui, name, encoding.strtolocal(addr), charsets)
396 return _addressencode(ui, name, encoding.strtolocal(addr), charsets)
399
397
400
398
401 def addrlistencode(ui, addrs, charsets=None, display=False):
399 def addrlistencode(ui, addrs, charsets=None, display=False):
402 '''Turns a list of addresses into a list of RFC-2047 compliant headers.
400 '''Turns a list of addresses into a list of RFC-2047 compliant headers.
403 A single element of input list may contain multiple addresses, but output
401 A single element of input list may contain multiple addresses, but output
404 always has one address per item'''
402 always has one address per item'''
405 for a in addrs:
403 for a in addrs:
406 assert isinstance(a, bytes), '%r unexpectedly not a bytestr' % a
404 assert isinstance(a, bytes), '%r unexpectedly not a bytestr' % a
407 if display:
405 if display:
408 return [a.strip() for a in addrs if a.strip()]
406 return [encoding.strfromlocal(a.strip()) for a in addrs if a.strip()]
409
407
410 result = []
408 result = []
411 for name, addr in email.utils.getaddresses(
409 for name, addr in email.utils.getaddresses(
412 [encoding.strfromlocal(a) for a in addrs]
410 [encoding.strfromlocal(a) for a in addrs]
413 ):
411 ):
414 if name or addr:
412 if name or addr:
415 r = _addressencode(ui, name, encoding.strtolocal(addr), charsets)
413 r = _addressencode(ui, name, encoding.strtolocal(addr), charsets)
416 result.append(r)
414 result.append(r)
417 return result
415 return result
418
416
419
417
420 def mimeencode(ui, s, charsets=None, display=False):
418 def mimeencode(ui, s, charsets=None, display=False):
421 '''creates mime text object, encodes it if needed, and sets
419 '''creates mime text object, encodes it if needed, and sets
422 charset and transfer-encoding accordingly.'''
420 charset and transfer-encoding accordingly.'''
423 cs = b'us-ascii'
421 cs = b'us-ascii'
424 if not display:
422 if not display:
425 s, cs = _encode(ui, s, charsets)
423 s, cs = _encode(ui, s, charsets)
426 return mimetextqp(s, b'plain', cs)
424 return mimetextqp(s, b'plain', cs)
427
425
428
426
429 if pycompat.ispy3:
427 if pycompat.ispy3:
430
428
431 Generator = email.generator.BytesGenerator
429 Generator = email.generator.BytesGenerator
432
430
433 def parse(fp):
431 def parse(fp):
434 ep = email.parser.Parser()
432 ep = email.parser.Parser()
435 # disable the "universal newlines" mode, which isn't binary safe.
433 # disable the "universal newlines" mode, which isn't binary safe.
436 # I have no idea if ascii/surrogateescape is correct, but that's
434 # I have no idea if ascii/surrogateescape is correct, but that's
437 # what the standard Python email parser does.
435 # what the standard Python email parser does.
438 fp = io.TextIOWrapper(
436 fp = io.TextIOWrapper(
439 fp, encoding='ascii', errors='surrogateescape', newline=chr(10)
437 fp, encoding='ascii', errors='surrogateescape', newline=chr(10)
440 )
438 )
441 try:
439 try:
442 return ep.parse(fp)
440 return ep.parse(fp)
443 finally:
441 finally:
444 fp.detach()
442 fp.detach()
445
443
446 def parsebytes(data):
444 def parsebytes(data):
447 ep = email.parser.BytesParser()
445 ep = email.parser.BytesParser()
448 return ep.parsebytes(data)
446 return ep.parsebytes(data)
449
447
450
448
451 else:
449 else:
452
450
453 Generator = email.generator.Generator
451 Generator = email.generator.Generator
454
452
455 def parse(fp):
453 def parse(fp):
456 ep = email.parser.Parser()
454 ep = email.parser.Parser()
457 return ep.parse(fp)
455 return ep.parse(fp)
458
456
459 def parsebytes(data):
457 def parsebytes(data):
460 ep = email.parser.Parser()
458 ep = email.parser.Parser()
461 return ep.parsestr(data)
459 return ep.parsestr(data)
462
460
463
461
464 def headdecode(s):
462 def headdecode(s):
465 '''Decodes RFC-2047 header'''
463 '''Decodes RFC-2047 header'''
466 uparts = []
464 uparts = []
467 for part, charset in email.header.decode_header(s):
465 for part, charset in email.header.decode_header(s):
468 if charset is not None:
466 if charset is not None:
469 try:
467 try:
470 uparts.append(part.decode(charset))
468 uparts.append(part.decode(charset))
471 continue
469 continue
472 except (UnicodeDecodeError, LookupError):
470 except (UnicodeDecodeError, LookupError):
473 pass
471 pass
474 # On Python 3, decode_header() may return either bytes or unicode
472 # On Python 3, decode_header() may return either bytes or unicode
475 # depending on whether the header has =?<charset>? or not
473 # depending on whether the header has =?<charset>? or not
476 if isinstance(part, type(u'')):
474 if isinstance(part, type(u'')):
477 uparts.append(part)
475 uparts.append(part)
478 continue
476 continue
479 try:
477 try:
480 uparts.append(part.decode('UTF-8'))
478 uparts.append(part.decode('UTF-8'))
481 continue
479 continue
482 except UnicodeDecodeError:
480 except UnicodeDecodeError:
483 pass
481 pass
484 uparts.append(part.decode('ISO-8859-1'))
482 uparts.append(part.decode('ISO-8859-1'))
485 return encoding.unitolocal(u' '.join(uparts))
483 return encoding.unitolocal(u' '.join(uparts))
General Comments 0
You need to be logged in to leave comments. Login now