Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package b4 for openSUSE:Factory checked in at 2023-01-24 19:43:28 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/b4 (Old) and /work/SRC/openSUSE:Factory/.b4.new.32243 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "b4" Tue Jan 24 19:43:28 2023 rev:31 rq:1060560 version:0.12.0 Changes: -------- --- /work/SRC/openSUSE:Factory/b4/b4.changes 2023-01-06 17:06:44.800582678 +0100 +++ /work/SRC/openSUSE:Factory/.b4.new.32243/b4.changes 2023-01-24 20:17:41.379670589 +0100 @@ -1,0 +2,7 @@ +Tue Jan 24 06:21:58 UTC 2023 - Jiri Slaby <jsl...@suse.cz> + +- update to 0.12.0 + * worked around Python's email bugs + * other bugfixes + +------------------------------------------------------------------- Old: ---- b4-0.11.2.tar.gz New: ---- b4-0.12.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ b4.spec ++++++ --- /var/tmp/diff_new_pack.nEcyMv/_old 2023-01-24 20:17:41.823672890 +0100 +++ /var/tmp/diff_new_pack.nEcyMv/_new 2023-01-24 20:17:41.831672932 +0100 @@ -17,7 +17,7 @@ Name: b4 -Version: 0.11.2 +Version: 0.12.0 Release: 0 Summary: Helper scripts for kernel.org patches License: GPL-2.0-or-later ++++++ b4-0.11.2.tar.gz -> b4-0.12.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/.keys/openpgp/tessares.net/matthieu.baerts/default new/b4-0.12.0/.keys/openpgp/tessares.net/matthieu.baerts/default --- old/b4-0.11.2/.keys/openpgp/tessares.net/matthieu.baerts/default 1970-01-01 01:00:00.000000000 +0100 +++ new/b4-0.12.0/.keys/openpgp/tessares.net/matthieu.baerts/default 2023-01-20 16:31:00.000000000 +0100 @@ -0,0 +1,124 @@ +pub rsa4096 2015-08-31 [SC] + E8CB85F76877057A6E27F77AF6B7824F4269A073 +uid Matthieu Baerts <matthieu.bae...@tessares.net> +uid Matthieu Baerts <matth...@baerts.eu> +sub rsa4096 2015-08-31 [E] + 5B1A4BFBA06327FAC85B89272F9C14FD7EDF0E65 +sub ed25519 2021-08-26 [S] + 1B86596F99E77A0D744A4E387C22F0C2F3470A97 + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=CUrO +-----END PGP PUBLIC KEY BLOCK----- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/b4/__init__.py new/b4-0.12.0/b4/__init__.py --- old/b4-0.11.2/b4/__init__.py 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/b4/__init__.py 2023-01-20 16:31:00.000000000 +0100 @@ -12,11 +12,13 @@ import email.policy import email.header import email.generator +import email.quoprimime import tempfile import pathlib import argparse import smtplib import shlex +import textwrap import urllib.parse import datetime @@ -31,13 +33,17 @@ from pathlib import Path from contextlib import contextmanager -from typing import Optional, Tuple, Set, List, BinaryIO, Union, Sequence +from typing import Optional, Tuple, Set, List, BinaryIO, Union, Sequence, Literal from email import charset charset.add_charset('utf-8', None) # Policy we use for saving mail locally emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None) +# Presence of these characters requires quoting of the name in the header +# adapted from email._parseaddr +qspecials = re.compile(r'[()<>@,:;.\"\[\]]') + try: import dkim can_dkim = True @@ -53,7 +59,7 @@ # global setting allowing us to turn off networking can_network = True -__VERSION__ = '0.11.2' +__VERSION__ = '0.12.0' PW_REST_API_VERSION = '1.2' @@ -769,7 +775,7 @@ if stalecache: logger.debug('Stale cache for [v%s] %s', self.revision, self.subject) - save_cache(None, msgid, suffix='fakeam') + clear_cache(msgid, suffix='fakeam') logger.info('Preparing fake-am for v%s: %s', self.revision, self.subject) with git_temp_worktree(gitdir): @@ -848,8 +854,8 @@ def save_cover(self, outfile): # noinspection PyUnresolvedReferences cover_msg = self.patches[0].get_am_message(add_trailers=False) - with open(outfile, 'w') as fh: - fh.write(cover_msg.as_string(policy=emlpolicy)) + with open(outfile, 'wb') as fh: + fh.write(LoreMessage.get_msg_as_bytes(cover_msg, headers='decode')) logger.critical('Cover: %s', outfile) @@ -1395,21 +1401,110 @@ if hdrval is None: return '' - decoded = '' - for hstr, hcs in email.header.decode_header(hdrval): - if hcs is None: - hcs = 'utf-8' - try: - decoded += hstr.decode(hcs, errors='replace') - except LookupError: - # Try as utf-u - decoded += hstr.decode('utf-8', errors='replace') - except (UnicodeDecodeError, AttributeError): - decoded += hstr + if hdrval.find('=?') >= 0: + # Do we have any email addresses in there? + if re.search(r'<\S+@\S+>', hdrval, flags=re.I | re.M): + newaddrs = list() + for addr in email.utils.getaddresses([hdrval]): + if addr[0].find('=?') >= 0: + # Nothing wrong with nested calls, right? + addr = (LoreMessage.clean_header(addr[0]), addr[1]) + # Work around https://github.com/python/cpython/issues/100900 + if re.search(r'[^\w\s]', addr[0]): + newaddrs.append(f'"{addr[0]}" <{addr[1]}>') + else: + newaddrs.append(email.utils.formataddr(addr)) + return ', '.join(newaddrs) + + decoded = '' + for hstr, hcs in email.header.decode_header(hdrval): + if hcs is None: + hcs = 'utf-8' + try: + decoded += hstr.decode(hcs, errors='replace') + except LookupError: + # Try as utf-8 + decoded += hstr.decode('utf-8', errors='replace') + except (UnicodeDecodeError, AttributeError): + decoded += hstr + else: + decoded = hdrval + new_hdrval = re.sub(r'\n?\s+', ' ', decoded) return new_hdrval.strip() @staticmethod + def wrap_header(hdr, width: int = 75, nl: str = '\n', + transform: Literal['encode', 'decode', 'preserve'] = 'preserve') -> bytes: + hname, hval = hdr + if hname.lower() in ('to', 'cc', 'from', 'x-original-from'): + _parts = [f'{hname}: ',] + first = True + for addr in email.utils.getaddresses([hval]): + if transform == 'encode' and not addr[0].isascii(): + addr = (email.quoprimime.header_encode(addr[0].encode(), charset='utf-8'), addr[1]) + qp = format_addrs([addr], clean=False) + elif transform == 'decode': + qp = format_addrs([addr], clean=True) + else: + qp = format_addrs([addr], clean=False) + # See if there is enough room on the existing line + if first: + _parts[-1] += qp + first = False + continue + if len(_parts[-1] + ', ' + qp) > width: + _parts[-1] += ', ' + _parts.append(qp) + continue + _parts[-1] += ', ' + qp + else: + if transform == 'decode' and hval.find('?=') >= 0: + hdata = f'{hname}: ' + LoreMessage.clean_header(hval) + else: + hdata = f'{hname}: {hval}' + if transform != 'encode' or hval.isascii(): + if len(hdata) <= width: + return hdata.encode() + # Use simple textwrap, with a small trick that ensures that long non-breakable + # strings don't show up on the next line from the bare header + hdata = hdata.replace(': ', ':_', 1) + wrapped = textwrap.wrap(hdata, break_long_words=False, break_on_hyphens=False, + subsequent_indent=' ', width=width) + return nl.join(wrapped).replace(':_', ': ', 1).encode() + + qp = f'{hname}: ' + email.quoprimime.header_encode(hval.encode(), charset='utf-8') + # is it longer than width? + if len(qp) <= width: + return qp.encode() + + _parts = list() + while len(qp) > width: + wrapat = width - 2 + if len(_parts): + # Also allow for the ' ' at the front on continuation lines + wrapat -= 1 + # Make sure we don't break on a =XX escape sequence + while '=' in qp[wrapat-2:wrapat]: + wrapat -= 1 + _parts.append(qp[:wrapat] + '?=') + qp = ('=?utf-8?q?' + qp[wrapat:]) + _parts.append(qp) + return f'{nl} '.join(_parts).encode() + + @staticmethod + def get_msg_as_bytes(msg: email.message.Message, nl: str ='\n', + headers: Literal['encode', 'decode', 'preserve'] = 'preserve') -> bytes: + bdata = b'' + for hname, hval in msg.items(): + bdata += LoreMessage.wrap_header((hname, str(hval)), nl=nl, transform=headers) + nl.encode() + bdata += nl.encode() + payload = msg.get_payload(decode=True) + for bline in payload.split(b'\n'): + bdata += re.sub(rb'[\r\n]*$', b'', bline) + nl.encode() + return bdata + + @staticmethod def get_parts_from_header(hstr: str) -> dict: hstr = re.sub(r'\s*', '', hstr) hdata = dict() @@ -2415,7 +2510,7 @@ return cachedir -def get_cache_file(identifier, suffix=None): +def get_cache_file(identifier: str, suffix: Optional[str] = None): cachedir = get_cache_dir() cachefile = hashlib.sha1(identifier.encode()).hexdigest() if suffix: @@ -2423,7 +2518,7 @@ return os.path.join(cachedir, cachefile) -def get_cache(identifier, suffix=None): +def get_cache(identifier: str, suffix: Optional[str] = None) -> Optional[str]: fullpath = get_cache_file(identifier, suffix=suffix) try: with open(fullpath) as fh: @@ -2434,15 +2529,15 @@ return None -def save_cache(contents, identifier, suffix=None, mode='w'): +def clear_cache(identifier: str, suffix: Optional[str] = None) -> None: + fullpath = get_cache_file(identifier, suffix=suffix) + if os.path.exists(fullpath): + os.unlink(fullpath) + logger.debug('Removed cache %s for %s', fullpath, identifier) + + +def save_cache(contents: str, identifier: str, suffix: Optional[str] = None, mode: str = 'w') -> None: fullpath = get_cache_file(identifier, suffix=suffix) - if not contents: - # noinspection PyBroadException - try: - os.unlink(fullpath) - logger.debug('Removed cache %s for %s', fullpath, identifier) - except: - pass try: with open(fullpath, mode) as fh: fh.write(contents) @@ -2815,7 +2910,7 @@ msg.replace_header('From', setfrom) if seriests: - patchts = seriests + counter + patchts = seriests + counter + 1 origdate = msg.get('Date') if origdate: msg.replace_header('Date', email.utils.formatdate(patchts, localtime=True)) @@ -2870,13 +2965,18 @@ def format_addrs(pairs, clean=True): addrs = list() for pair in pairs: - pair = list(pair) if pair[0] == pair[1]: - pair[0] = '' + addrs.append(pair[1]) + continue if clean: # Remove any quoted-printable header junk from the name - pair[0] = LoreMessage.clean_header(pair[0]) - addrs.append(email.utils.formataddr(pair)) # noqa + pair = (LoreMessage.clean_header(pair[0]), pair[1]) + # Work around https://github.com/python/cpython/issues/100900 + if not pair[0].startswith('=?') and not pair[0].startswith('"') and qspecials.search(pair[0]): + quoted = email.utils.quote(pair[0]) + addrs.append(f'"{quoted}" <{pair[1]}>') + continue + addrs.append(email.utils.formataddr(pair)) return ', '.join(addrs) @@ -2966,17 +3066,22 @@ return uids -def save_git_am_mbox(msgs: list, dest: BinaryIO): +def save_git_am_mbox(msgs: list[email.message.Message], dest: BinaryIO): # Git-am has its own understanding of what "mbox" format is that differs from Python's # mboxo implementation. Specifically, it never escapes the ">From " lines found in bodies # unless invoked with --patch-format=mboxrd (this is wrong, because ">From " escapes are also # required in the original mbox "mboxo" format). # So, save in the format that git-am expects - gen = email.generator.BytesGenerator(dest, policy=emlpolicy) for msg in msgs: - msg.set_unixfrom('From git@z Thu Jan 1 00:00:00 1970') - gen.flatten(msg, unixfrom=True) - gen.write('\n') + dest.write(b'From git@z Thu Jan 1 00:00:00 1970\n') + dest.write(LoreMessage.get_msg_as_bytes(msg, headers='decode')) + + +def save_mboxrd_mbox(msgs: list[email.message.Message], dest: BinaryIO, mangle_from: bool = False): + gen = email.generator.BytesGenerator(dest, mangle_from_=mangle_from, policy=emlpolicy) + for msg in msgs: + dest.write(b'From mboxrd@z Thu Jan 1 00:00:00 1970\n') + gen.flatten(msg) def save_maildir(msgs: list, dest): @@ -2991,7 +3096,7 @@ lsubj = LoreSubject(msg.get('subject', '')) slug = '%04d_%s' % (lsubj.counter, re.sub(r'\W+', '_', lsubj.subject).strip('_').lower()) with open(os.path.join(d_tmp, f'{slug}.eml'), 'wb') as mfh: - mfh.write(msg.as_bytes(policy=emlpolicy)) + mfh.write(LoreMessage.get_msg_as_bytes(msg, headers='decode')) os.rename(os.path.join(d_tmp, f'{slug}.eml'), os.path.join(d_new, f'{slug}.eml')) @@ -3211,8 +3316,8 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, None], msgs: Sequence[email.message.Message], fromaddr: Optional[str], destaddrs: Optional[Union[set, list]] = None, patatt_sign: bool = False, dryrun: bool = False, - maxheaderlen: Optional[int] = None, output_dir: Optional[str] = None, - web_endpoint: Optional[str] = None, reflect: bool = False) -> Optional[int]: + output_dir: Optional[str] = None, web_endpoint: Optional[str] = None, + reflect: bool = False) -> Optional[int]: tosend = list() if output_dir is not None: @@ -3222,34 +3327,13 @@ if not msg.get('X-Mailer'): msg.add_header('X-Mailer', f'b4 {__VERSION__}') msg.set_charset('utf-8') - if maxheaderlen is None: - if dryrun: - # Make it fit the terminal window, but no wider than 120 minus visual padding - ts = shutil.get_terminal_size((120, 20)) - maxheaderlen = ts.columns - 8 - if maxheaderlen > 112: - maxheaderlen = 112 - else: - # Use a sane-ish default (we don't need to stick to 80, but - # we need to make sure it's shorter than 255) - maxheaderlen = 120 - - if dryrun and not output_dir: - # Use 8bit-clean policy if we're dumping things to screen - emldata = msg.as_string(policy=emlpolicy, maxheaderlen=maxheaderlen) - bdata = emldata.encode() - else: - # Use SMTP policy if we're actually going to send things out - msg = sevenbitify_headers(msg) - if dryrun: - # Use HTTP policy, to avoid header wrapping - policy = email.policy.HTTP.clone(linesep='\n') - elif web_endpoint: - # Use SMTP policy with LF endings - policy = email.policy.SMTP.clone(linesep='\n') - else: - policy = email.policy.SMTP - bdata = msg.as_bytes(policy=policy) + + if dryrun or web_endpoint: + nl = '\n' + else: + nl = '\r\n' + + bdata = LoreMessage.get_msg_as_bytes(msg, nl=nl, headers='encode') subject = msg.get('Subject', '') ls = LoreSubject(subject) @@ -3479,52 +3563,6 @@ return msgid, msgs - -# When qp-encoding a header, encode just the 8bit content, -# not the entire header. -def qp_smallest(srcstr: str) -> str: - import email.quoprimime - qpstr = '' - toqp = '' - chunks = re.split(r'(\S+)', srcstr) - for pos, chunk in enumerate(chunks): - if not len(toqp): - if chunk.isascii(): - qpstr += chunk - continue - toqp += chunk - continue - # is this chunk whitespace? - if not len(chunk.strip()): - # are all the remaining words after this one ascii? - if pos+1 >= len(chunks) or all(_chunk.isascii() for _chunk in chunks[pos+1:]): - qpstr += email.quoprimime.header_encode(toqp.encode(), charset='utf-8') + chunk - toqp = '' - continue - toqp += chunk - return qpstr - - -def sevenbitify_headers(msg: email.message.Message) -> email.message.Message: - import email.quoprimime - for pos, hdr in enumerate(msg._headers): # noqa - if hdr[1].isascii(): - continue - # Do we have an email address in there? - if re.search(r'<\S+@\S+>', hdr[1], flags=re.I | re.M): - newaddrs = list() - for addr in email.utils.getaddresses([hdr[1]]): - if addr[0].isascii(): - newaddrs.append(addr) - continue - newaddrs.append((qp_smallest(addr[0]), addr[1])) - newval = format_addrs(newaddrs, clean=False) - else: - newval = qp_smallest(hdr[1]) - msg._headers[pos] = (hdr[0], newval) # noqa - return msg - - def git_revparse_obj(gitobj: str) -> str: ecode, out = git_run_command(None, ['rev-parse', gitobj]) if ecode > 0: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/b4/command.py new/b4-0.12.0/b4/command.py --- old/b4-0.11.2/b4/command.py 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/b4/command.py 2023-01-20 16:31:00.000000000 +0100 @@ -138,6 +138,8 @@ cmd_mbox_common_opts(sp_mbox) sp_mbox.add_argument('-f', '--filter-dupes', dest='filterdupes', action='store_true', default=False, help='When adding messages to existing maildir, filter out duplicates') + sp_mbox.add_argument('-r', '--refetch', dest='refetch', metavar='MBOX', default=False, + help='Refetch all messages in specified mbox with their original headers') sp_mbox.set_defaults(func=cmd_mbox) # b4 am @@ -255,23 +257,24 @@ # b4 prep sp_prep = subparsers.add_parser('prep', help='Work on patch series to submit for mailing list review') - spp_g = sp_prep.add_mutually_exclusive_group() - spp_g.add_argument('-c', '--auto-to-cc', action='store_true', default=False, + sp_prep.add_argument('-c', '--auto-to-cc', action='store_true', default=False, help='Automatically populate cover letter trailers with To and Cc addresses') + sp_prep.add_argument('--force-revision', metavar='N', type=int, + help='Force revision to be this number instead') + sp_prep.add_argument('--set-prefixes', metavar='PREFIX', nargs='+', + help='Extra prefixes to add to [PATCH] (e.g.: RFC mydrv)') + + spp_g = sp_prep.add_mutually_exclusive_group() spp_g.add_argument('-p', '--format-patch', metavar='OUTPUT_DIR', help='Output prep-tracked commits as patches') spp_g.add_argument('--edit-cover', action='store_true', default=False, help='Edit the cover letter in your defined $EDITOR (or core.editor)') spp_g.add_argument('--show-revision', action='store_true', default=False, help='Show current series revision number') - spp_g.add_argument('--force-revision', metavar='N', type=int, - help='Force revision to be this number instead') spp_g.add_argument('--compare-to', metavar='vN', help='Display a range-diff to previously sent revision N') spp_g.add_argument('--manual-reroll', dest='reroll', default=None, metavar='COVER_MSGID', help='Mark current revision as sent and reroll (requires cover letter msgid)') - spp_g.add_argument('--set-prefixes', metavar='PREFIX', nargs='+', - help='Extra prefixes to add to [PATCH] (e.g.: RFC mydrv)') spp_g.add_argument('--show-info', action='store_true', default=False, help='Show current series info in a column-parseable format') @@ -310,8 +313,6 @@ help='Send everything to yourself instead of the actual recipients') sp_send.add_argument('--no-trailer-to-cc', action='store_true', default=False, help='Do not add any addresses found in the cover or patch trailers to To: or Cc:') - sp_send.add_argument('--hide-cover-to-cc', action='store_true', default=False, - help='Hide To: and Cc: entries from the cover letter trailers (but still send to them)') sp_send.add_argument('--to', nargs='+', help='Addresses to add to the To: list') sp_send.add_argument('--cc', nargs='+', help='Addresses to add to the Cc: list') sp_send.add_argument('--not-me-too', action='store_true', default=False, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/b4/ez.py new/b4-0.12.0/b4/ez.py --- old/b4-0.11.2/b4/ez.py 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/b4/ez.py 2023-01-20 16:31:00.000000000 +0100 @@ -451,21 +451,22 @@ # create a default cover letter and store it where the strategy indicates cover = ('EDITME: cover title for %s' % seriesname, '', - '# Lines starting with # will be removed from the cover letter. You can use', - '# them to add notes or reminders to yourself.', + '# Lines starting with # will be removed from the cover letter. You can', + '# use them to add notes or reminders to yourself.', '', - 'EDITME: describe the purpose of this series. The information you put here', - 'will be used by the project maintainer to make a decision whether your', - 'patches should be reviewed, and in what priority order. Please be very', - 'detailed and link to any relevant discussions or sites that the maintainer', - 'can review to better understand your proposed changes. If you only have a', - 'single patch in your series, the contents of the cover letter will be', - 'appended to the "under-the-cut" portion of the patch.', + 'EDITME: describe the purpose of this series. The information you put', + 'here will be used by the project maintainer to make a decision whether', + 'your patches should be reviewed, and in what priority order. Please be', + 'very detailed and link to any relevant discussions or sites that the', + 'maintainer can review to better understand your proposed changes. If you', + 'only have a single patch in your series, the contents of the cover', + 'letter will be appended to the "under-the-cut" portion of the patch.', '', '# You can add trailers to the cover letter. Any email addresses found in', - '# these trailers will be added to the addresses specified/generated during', - '# the b4 send stage. You can also run "b4 prep --auto-to-cc" to auto-populate', - '# the To: and Cc: trailers based on the code being modified.', + '# these trailers will be added to the addresses specified/generated', + '# during the b4 send stage. You can also run "b4 prep --auto-to-cc" to', + '# auto-populate the To: and Cc: trailers based on the code being', + '# modified.', '', 'Signed-off-by: %s <%s>' % (usercfg.get('name', ''), usercfg.get('email', '')), '', @@ -480,7 +481,7 @@ if revision is None: revision = 1 prefixes = list() - if cmdargs.set_prefixes and len(prefixes[0].strip()): + if cmdargs.set_prefixes: prefixes = list(cmdargs.set_prefixes) else: config = b4.get_main_config() @@ -686,17 +687,21 @@ corecfg = b4.get_config_from_git(r'core\..*', {'editor': os.environ.get('EDITOR', 'vi')}) editor = corecfg.get('editor') logger.debug('editor=%s', editor) - # We give it a suffix .rst in hopes that editors autoload restructured-text rules - with tempfile.NamedTemporaryFile(suffix='.rst') as temp_cover: - temp_cover.write(cover.encode()) - temp_cover.seek(0) + # Use COMMIT_EDITMSG name in hopes that editors autoload git commit rules + with tempfile.TemporaryDirectory(prefix='b4-') as temp_dir: + temp_fpath = os.path.join(temp_dir, 'COMMIT_EDITMSG') + with open(temp_fpath, 'xb') as temp_cover: + temp_cover.write(cover.encode()) + sp = shlex.shlex(editor, posix=True) sp.whitespace_split = True - cmdargs = list(sp) + [temp_cover.name] + cmdargs = list(sp) + [temp_fpath] logger.debug('Running %s' % ' '.join(cmdargs)) sp = subprocess.Popen(cmdargs) sp.wait() - new_cover = temp_cover.read().decode(errors='replace').strip() + + with open(temp_fpath, 'rb') as temp_cover: + new_cover = temp_cover.read().decode(errors='replace').strip() if new_cover == cover: logger.info('Cover letter unchanged.') @@ -1001,7 +1006,7 @@ return msgid_tpt -def get_cover_dests(cbody: str, hide: bool = True) -> Tuple[List, List, str]: +def get_cover_dests(cbody: str) -> Tuple[List, List, str]: htrs, cmsg, mtrs, basement, sig = b4.LoreMessage.get_body_parts(cbody) tos = list() ccs = list() @@ -1012,8 +1017,7 @@ elif mtr.lname == 'cc': ccs.append(mtr.addr) mtrs.remove(mtr) - if hide: - cbody = b4.LoreMessage.rebuild_message(htrs, cmsg, mtrs, basement, sig) + cbody = b4.LoreMessage.rebuild_message(htrs, cmsg, mtrs, basement, sig) return tos, ccs, cbody @@ -1113,8 +1117,7 @@ return usercfg.get('name'), usercfg.get('email') -def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, - addtracking: bool = True, hide_cover_to_cc: bool = False +def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtracking: bool = True ) -> Tuple[List, List, str, List[Tuple[str, email.message.Message]]]: cover, tracking = load_cover(strip_comments=True) @@ -1176,10 +1179,10 @@ ztracking = gzip.compress(bytes(json.dumps(tracking), 'utf-8')) b64tracking = base64.b64encode(ztracking).decode() # A little trick for pretty wrapping - wrapped = textwrap.wrap('X-B4-Tracking: v=1; b=' + b64tracking, width=75) - thdata = ' '.join(wrapped).replace('X-B4-Tracking: ', '') + wrapped = textwrap.wrap('X-B4-Tracking: v=1; b=' + b64tracking, subsequent_indent=' ', width=75) + thdata = ''.join(wrapped).replace('X-B4-Tracking: ', '') - alltos, allccs, cbody = get_cover_dests(cover_letter, hide=hide_cover_to_cc) + alltos, allccs, cbody = get_cover_dests(cover_letter) if len(patches) == 1: mixin_cover(cbody, patches) else: @@ -1192,8 +1195,7 @@ return alltos, allccs, tag_msg, patches -def get_sent_tag_as_patches(tagname: str, revision: int, hide_cover_to_cc: bool = False - ) -> Tuple[List, List, List[Tuple[str, email.message.Message]]]: +def get_sent_tag_as_patches(tagname: str, revision: int) -> Tuple[List, List, List[Tuple[str, email.message.Message]]]: cover, base_commit, change_id = get_base_changeid_from_tag(tagname) csubject, cbody = get_cover_subject_body(cover) @@ -1210,7 +1212,7 @@ seriests=seriests, mailfrom=mailfrom) - alltos, allccs, cbody = get_cover_dests(cbody, hide=hide_cover_to_cc) + alltos, allccs, cbody = get_cover_dests(cbody) if len(patches) == 1: mixin_cover(cbody, patches) else: @@ -1251,8 +1253,6 @@ mybranch = b4.git_get_current_branch() config = b4.get_main_config() - if not cmdargs.hide_cover_to_cc and config.get('send-hide-cover-to-cc', '').lower() in {'yes', 'true', '1'}: - cmdargs.hide_cover_to_cc = True tag_msg = None cl_msgid = None @@ -1264,8 +1264,7 @@ sys.exit(1) try: - todests, ccdests, patches = get_sent_tag_as_patches(tagname, revision=revision, - hide_cover_to_cc=cmdargs.hide_cover_to_cc) + todests, ccdests, patches = get_sent_tag_as_patches(tagname, revision=revision) except RuntimeError as ex: logger.critical('CRITICAL: Failed to convert tag to patches: %s', ex) sys.exit(1) @@ -1282,9 +1281,14 @@ logger.info('---') sys.exit(1) + status = b4.git_get_repo_status() + if len(status): + logger.critical('CRITICAL: Repository contains uncommitted changes.') + logger.critical(' Stash or commit them first.') + sys.exit(1) + try: - todests, ccdests, tag_msg, patches = get_prep_branch_as_patches( - hide_cover_to_cc=cmdargs.hide_cover_to_cc) + todests, ccdests, tag_msg, patches = get_prep_branch_as_patches() except RuntimeError as ex: logger.critical('CRITICAL: Failed to convert range to patches: %s', ex) sys.exit(1) @@ -1314,13 +1318,11 @@ continue if btr.addr[1] in seen: continue - if commit and btr.lname == 'cc': - # CC's in individual patches don't get added to global Cc's, - # we use the pcss dict to track them. + if commit: if commit not in pccs: pccs[commit] = list() - pccs[commit].append(btr.addr) - # Doesn't get added to seen, in case a later patch puts it into global + if btr.addr not in pccs[commit]: + pccs[commit].append(btr.addr) continue seen.add(btr.addr[1]) if btr.lname == 'to': @@ -1381,15 +1383,20 @@ pathlib.Path(cmdargs.output_dir).mkdir(parents=True, exist_ok=True) sconfig = b4.get_sendemail_config() - endpoint = config.get('send-endpoint-web', '') - if not re.search(r'^https?://', endpoint): - endpoint = None - if not endpoint and not sconfig.get('smtpserver'): - # Use the default endpoint if we are in the kernel repo - topdir = b4.git_get_toplevel() - if os.path.exists(os.path.join(topdir, 'Kconfig')): - logger.debug('No sendemail configs found, will use the default web endpoint') - endpoint = DEFAULT_ENDPOINT + # If we have an smtp server defined, always use that instead of the endpoint + # we may make this configurable in the future, but this almost always makes sense + endpoint = None + if not sconfig.get('smtpserver'): + endpoint = config.get('send-endpoint-web', '') + if not re.search(r'^https?://', endpoint): + logger.debug('Endpoint does not start with https, ignoring: %s', endpoint) + endpoint = None + if not endpoint: + # Use the default endpoint if we are in the kernel repo + topdir = b4.git_get_toplevel() + if os.path.exists(os.path.join(topdir, 'Kconfig')): + logger.debug('No sendemail configs found, will use the default web endpoint') + endpoint = DEFAULT_ENDPOINT # Give the user the last opportunity to bail out if not cmdargs.dryrun: @@ -1821,32 +1828,19 @@ logger.debug('added %s to seen', ltr.addr[1]) extras.append(ltr) - tos, ccs, tag_msg, patches = get_prep_branch_as_patches() + try: + tos, ccs, tag_msg, patches = get_prep_branch_as_patches() + except RuntimeError: + logger.info('No commits in branch') + return + logger.info('Collecting To/Cc addresses') # Go through the messages to make to/cc headers for commit, msg in patches: - if not msg: - continue - payload = msg.get_payload(decode=True).decode() - parts = b4.LoreMessage.get_body_parts(payload) - for ltr in parts[2]: - if ltr.lname == 'cc': - # We treat Cc: in individual patches differently from all other trailers: - # they only receive individual patches, not the entire series. - continue - if not ltr.addr: - continue - if ltr.addr[1] in seen: - continue - seen.add(ltr.addr[1]) - logger.debug('added %s to seen', ltr.addr[1]) - # Make it a Cc: trailer - ltr.name = 'Cc' - extras.append(ltr) - - if not commit: + if not msg or not commit: continue + logger.debug('Collecting from: %s', msg.get('subject')) msgbytes = msg.as_bytes() for tname, pairs in (('To', get_addresses_from_cmd(tocmd, msgbytes)), ('Cc', get_addresses_from_cmd(cccmd, msgbytes))): @@ -1854,6 +1848,7 @@ if pair[1] not in seen: seen.add(pair[1]) ltr = b4.LoreTrailer(name=tname, value=b4.format_addrs([pair])) + logger.debug(' => %s', ltr.as_string()) extras.append(ltr) if not extras: @@ -1895,12 +1890,6 @@ logger.critical(' Stash or commit them first.') sys.exit(1) - if cmdargs.edit_cover: - return edit_cover() - - if cmdargs.auto_to_cc: - return auto_to_cc() - if cmdargs.reroll: msgid = cmdargs.reroll msgs = b4.get_pi_thread_by_msgid(msgid, onlymsgids={msgid}, nocache=True) @@ -1927,23 +1916,30 @@ if cmdargs.show_info: return show_info() - if cmdargs.force_revision: - return force_revision(cmdargs.force_revision) - if cmdargs.format_patch: return format_patch(cmdargs.format_patch) if cmdargs.compare_to: return compare(cmdargs.compare_to) + if cmdargs.enroll_base or cmdargs.new_series_name: + if is_prep_branch(): + logger.critical('CRITICAL: This appears to already be a b4-prep managed branch.') + sys.exit(1) + + start_new_series(cmdargs) + + if cmdargs.force_revision: + force_revision(cmdargs.force_revision) + if cmdargs.set_prefixes: - return set_prefixes(cmdargs.set_prefixes) + set_prefixes(cmdargs.set_prefixes) - if is_prep_branch(): - logger.critical('CRITICAL: This appears to already be a b4-prep managed branch.') - sys.exit(1) + if cmdargs.auto_to_cc: + auto_to_cc() - return start_new_series(cmdargs) + if cmdargs.edit_cover: + return edit_cover() def cmd_trailers(cmdargs: argparse.Namespace) -> None: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/b4/mbox.py new/b4-0.12.0/b4/mbox.py --- old/b4-0.11.2/b4/mbox.py 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/b4/mbox.py 2023-01-20 16:31:00.000000000 +0100 @@ -638,6 +638,37 @@ return msgs +def is_maildir(dest: str) -> bool: + if (os.path.isdir(os.path.join(dest, 'new')) + and os.path.isdir(os.path.join(dest, 'cur')) + and os.path.isdir(os.path.join(dest, 'tmp'))): + return True + return False + + +def refetch(dest: str) -> None: + if is_maildir(dest): + mbox = mailbox.Maildir(dest) + else: + mbox = mailbox.mbox(dest) + + by_msgid = dict() + for key, msg in mbox.items(): + msgid = b4.LoreMessage.get_clean_msgid(msg) + if msgid not in by_msgid: + amsgs = b4.get_pi_thread_by_msgid(msgid, nocache=True) + for amsg in amsgs: + amsgid = b4.LoreMessage.get_clean_msgid(amsg) + if amsgid not in by_msgid: + by_msgid[amsgid] = amsg + if msgid in by_msgid: + mbox.update(((key, by_msgid[msgid]),)) + logger.info('Refetched: %s', msg.get('Subject')) + else: + logger.warn('WARNING: Message-id not known: %s', msgid) + mbox.close() + + def main(cmdargs: argparse.Namespace) -> None: if cmdargs.subcmd == 'shazam': # We force some settings @@ -657,6 +688,9 @@ # Force nocache mode cmdargs.nocache = True + if cmdargs.subcmd == 'mbox' and cmdargs.refetch: + return refetch(cmdargs.refetch) + try: msgid, msgs = b4.retrieve_messages(cmdargs) except LookupError as ex: @@ -676,13 +710,11 @@ logger.info('%s messages in the thread', len(msgs)) if cmdargs.outdir == '-': logger.info('---') - b4.save_git_am_mbox(msgs, sys.stdout.buffer) + b4.save_mboxrd_mbox(msgs, sys.stdout.buffer, mangle_from=False) return # Check if outdir is a maildir - if (os.path.isdir(os.path.join(cmdargs.outdir, 'new')) - and os.path.isdir(os.path.join(cmdargs.outdir, 'cur')) - and os.path.isdir(os.path.join(cmdargs.outdir, 'tmp'))): + if is_maildir(cmdargs.outdir): mdr = mailbox.Maildir(cmdargs.outdir) have_msgids = set() added = 0 @@ -721,6 +753,6 @@ return with open(savename, 'wb') as fh: - b4.save_git_am_mbox(msgs, fh) + b4.save_mboxrd_mbox(msgs, fh, mangle_from=True) logger.info('Saved %s', savename) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/docs/config.rst new/b4-0.12.0/docs/config.rst --- old/b4-0.11.2/docs/config.rst 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/docs/config.rst 2023-01-20 16:31:00.000000000 +0100 @@ -339,12 +339,6 @@ Default: ``no`` -``b4.send-hide-cover-to-cc`` (v0.10+) - Always hide To: and Cc: trailers from the cover letter, just include - them into the corresponding message recipient headers. - - Default: ``no`` - ``b4.send-auto-to-cmd`` (v0.10+) Command to use to generate the list of To: recipients. Has no effect if the specified script is not found in the repository. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/docs/contributor/send.rst new/b4-0.12.0/docs/contributor/send.rst --- old/b4-0.11.2/docs/contributor/send.rst 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/docs/contributor/send.rst 2023-01-20 16:31:00.000000000 +0100 @@ -220,14 +220,6 @@ or Cc:. This is usually handy for testing purposes, in case you want to send a set of patches to a test address (also see ``--reflect``). -``--hide-cover-to-cc`` - It is common for the ``To:`` and ``Cc:`` sections in cover letters to - be pretty large on large patch sets. Passing this flag will remove - these trailers from the cover letter, but still add the addresses to - the corresponding To: and Cc: headers. This can be made permanent in - the configuration file using the ``b4.send-hide-cover-to-cc`` option - (see :ref:`contributor_settings`). - ``--to`` Add any more email addresses to include into the To: header here (comma-separated). Can be set in the configuration file using the diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/docs/maintainer/mbox.rst new/b4-0.12.0/docs/maintainer/mbox.rst --- old/b4-0.11.2/docs/maintainer/mbox.rst 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/docs/maintainer/mbox.rst 2023-01-20 16:31:00.000000000 +0100 @@ -79,6 +79,14 @@ that aren't already present. Note, that this uses simple message-id matching and no other checks for correctness are performed. +``-r MBOX, --refetch MBOX`` **(v0.12+)** + This allows you to refetch all messages in the provided mailbox from + the upstream public-inbox server. For example, this is useful when you + have a .mbx file prepared by ``b4 am`` and you want to send a + response to one of the patches. Performing a refetch will restore the + original message headers that may have been dropped or modified by + ``b4 am``. + Using with mutt --------------- If you are a mutt or neomutt user and your mail is stored locally, you diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/man/b4.5 new/b4-0.12.0/man/b4.5 --- old/b4-0.11.2/man/b4.5 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/man/b4.5 2023-01-20 16:31:00.000000000 +0100 @@ -27,7 +27,7 @@ .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "B4" 5 "2022-12-05" "0.11.0" "" +.TH "B4" 5 "2023-01-19" "0.12.0" "" .SH NAME B4 \- Work with code submissions in a public-inbox archive .SH SYNOPSIS @@ -141,6 +141,9 @@ .TP .B \-f\fP,\fB \-\-filter\-dupes When adding messages to existing maildir, filter out duplicates +.TP +.BI \-r \ MBOX\fR,\fB \ \-\-refetch \ MBOX +Refetch all messages in specified mbox with their original headers .UNINDENT .UNINDENT .sp @@ -601,7 +604,7 @@ .INDENT 0.0 .TP .B usage: -b4 send [\-h] [\-d] [\-o OUTPUT_DIR] [\-\-reflect] [\-\-no\-trailer\-to\-cc] [\-\-hide\-cover\-to\-cc] [\-\-to TO [TO ...]] [\-\-cc CC [CC ...]] [\-\-not\-me\-too] [\-\-resend RESEND] [\-\-no\-sign] [\-\-web\-auth\-new] [\-\-web\-auth\-verify VERIFY_TOKEN] +b4 send [\-h] [\-d] [\-o OUTPUT_DIR] [\-\-reflect] [\-\-no\-trailer\-to\-cc] [\-\-to TO [TO ...]] [\-\-cc CC [CC ...]] [\-\-not\-me\-too] [\-\-resend RESEND] [\-\-no\-sign] [\-\-web\-auth\-new] [\-\-web\-auth\-verify VERIFY_TOKEN] .TP .B options: .INDENT 7.0 @@ -620,9 +623,6 @@ .TP .B \-\-no\-trailer\-to\-cc Do not add any addresses found in the cover or patch trailers to To: or Cc: -.TP -.B \-\-hide\-cover\-to\-cc -Hide To: and Cc: entries from the cover letter trailers (but still send to them) .UNINDENT .INDENT 7.0 .TP diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/man/b4.5.rst new/b4-0.12.0/man/b4.5.rst --- old/b4-0.11.2/man/b4.5.rst 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/man/b4.5.rst 2023-01-20 16:31:00.000000000 +0100 @@ -5,10 +5,10 @@ ---------------------------------------------------- :Author: mri...@kernel.org -:Date: 2022-12-05 +:Date: 2023-01-19 :Copyright: The Linux Foundation and contributors :License: GPLv2+ -:Version: 0.11.0 +:Version: 0.12.0 :Manual section: 5 SYNOPSIS @@ -91,7 +91,9 @@ Save as maildir (avoids mbox format ambiguities) -f, --filter-dupes When adding messages to existing maildir, filter out duplicates - + -r MBOX, --refetch MBOX + Refetch all messages in specified mbox with their original headers +  *Example*: b4 mbox 20200313231252.64999-1-keesc...@chromium.org @@ -386,7 +388,7 @@ b4 send ~~~~~~~ usage: - b4 send [-h] [-d] [-o OUTPUT_DIR] [--reflect] [--no-trailer-to-cc] [--hide-cover-to-cc] [--to TO [TO ...]] [--cc CC [CC ...]] [--not-me-too] [--resend RESEND] [--no-sign] [--web-auth-new] [--web-auth-verify VERIFY_TOKEN] + b4 send [-h] [-d] [-o OUTPUT_DIR] [--reflect] [--no-trailer-to-cc] [--to TO [TO ...]] [--cc CC [CC ...]] [--not-me-too] [--resend RESEND] [--no-sign] [--web-auth-new] [--web-auth-verify VERIFY_TOKEN] options: -h, --help show this help message and exit @@ -400,9 +402,6 @@ --no-trailer-to-cc Do not add any addresses found in the cover or patch trailers to To: or Cc: - --hide-cover-to-cc - Hide To: and Cc: entries from the cover letter trailers (but still send to them) - --to TO [TO ...] Addresses to add to the To: list --cc CC [CC ...] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/tests/conftest.py new/b4-0.12.0/tests/conftest.py --- old/b4-0.11.2/tests/conftest.py 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/tests/conftest.py 2023-01-20 16:31:00.000000000 +0100 @@ -38,7 +38,7 @@ bfile = os.path.join(sampledir, 'gitdir.bundle') assert os.path.exists(bfile) dest = os.path.join(tmp_path, 'repo') - args = ['clone', bfile, dest] + args = ['clone', '--branch', 'master', bfile, dest] out, logstr = b4.git_run_command(None, args) assert out == 0 b4.git_set_config(dest, 'user.name', b4.USER_CONFIG['name']) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/b4-0.11.2/tests/test___init__.py new/b4-0.12.0/tests/test___init__.py --- old/b4-0.11.2/tests/test___init__.py 2023-01-05 17:30:41.000000000 +0100 +++ new/b4-0.12.0/tests/test___init__.py 2023-01-20 16:31:00.000000000 +0100 @@ -22,8 +22,9 @@ @pytest.mark.parametrize('source,regex,flags,ismbox', [ (None, r'^From git@z ', 0, False), (None, r'\n\nFrom git@z ', 0, False), - ('save-8bit-clean', r'Unicôdé', 0, True), - ('save-7bit-clean', r'=\?utf-8\?q\?S=C3=BBbject\?=', 0, True), + ('save-7bit-clean', r'From: Unicôdé', 0, True), + # mailbox.mbox does not properly handle 8bit-clean headers + ('save-8bit-clean', r'From: Unicôdé', 0, False), ]) def test_save_git_am_mbox(sampledir, tmp_path, source, regex, flags, ismbox): import re @@ -113,24 +114,81 @@ assert ifh.getvalue().decode() == fh.read() -@pytest.mark.parametrize('headers,verify', [ - ({ - 'From': 'Unicode Nâme <unicode-n...@example.com', - 'To': 'Ascii Name <ascii-n...@example.com>, ' - 'Unicôde Firstname <unicode-firstn...@example.com>, ' - 'Unicode Lâstname <unicode-lastn...@example.com>', - 'Subject': 'Subject with unicôde that is randomly interspérsed thrôughout the wrapped subject', - }, 'sevenbitify-1') +@pytest.mark.parametrize('hval,verify,tr', [ + ('short-ascii', 'short-ascii', 'encode'), + ('short-unicôde', '=?utf-8?q?short-unic=C3=B4de?=', 'encode'), + # Long ascii + (('Lorem ipsum dolor sit amet consectetur adipiscing elit ' + 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'), + ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n' + ' eiusmod tempor incididunt ut labore et dolore magna aliqua'), 'encode'), + # Long unicode + (('Lorem îpsum dolor sit amet consectetur adipiscing elît ' + 'sed do eiusmod tempôr incididunt ut labore et dolôre magna aliqua'), + ('=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n' + ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n' + ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?='), 'encode'), + # Exactly 75 long + ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', + 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', 'encode'), + # Unicode that breaks on escape boundary + ('Lorem ipsum dolor sit amet consectetur adipiscin elît', + '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n =?utf-8?q?=C3=AEt?=', 'encode'), + # Unicode that's just 1 too long + ('Lorem ipsum dolor sit amet consectetur adipi elît', + '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n =?utf-8?q?t?=', 'encode'), + # A single address + ('f...@example.com', 'f...@example.com', 'encode'), + # Two addresses + ('f...@example.com, b...@example.com', 'f...@example.com, b...@example.com', 'encode'), + # Mixed addresses + ('f...@example.com, Foo Bar <b...@example.com>', 'f...@example.com, Foo Bar <b...@example.com>', 'encode'), + # Mixed Unicode + ('f...@example.com, Foo Bar <b...@example.com>, Fôo Baz <b...@example.com>', + 'f...@example.com, Foo Bar <b...@example.com>, \n =?utf-8?q?F=C3=B4o_Baz?= <b...@example.com>', 'encode'), + ('f...@example.com, Foo Bar <b...@example.com>, Fôo Baz <b...@example.com>, "Quux, Foo" <q...@example.com>', + ('f...@example.com, Foo Bar <b...@example.com>, \n' + ' =?utf-8?q?F=C3=B4o_Baz?= <b...@example.com>, "Quux, Foo" <q...@example.com>'), 'encode'), + ('01234567890123456789012345678901234567890123456789012345678...@example.org, ä <f...@example.org>', + ('01234567890123456789012345678901234567890123456789012345678...@example.org, \n' + ' =?utf-8?q?=C3=A4?= <f...@example.org>'), 'encode'), + # Test for https://github.com/python/cpython/issues/100900 + ('f...@example.com, Foo Bar <b...@example.com>, Fôo Baz <b...@example.com>, "Quûx, Foo" <q...@example.com>', + ('f...@example.com, Foo Bar <b...@example.com>, \n' + ' =?utf-8?q?F=C3=B4o_Baz?= <b...@example.com>, \n =?utf-8?q?Qu=C3=BBx=2C_Foo?= <q...@example.com>'), 'encode'), + # Test preserve + ('f...@example.com, Foo Bar <b...@example.com>, Fôo Baz <b...@example.com>, "Quûx, Foo" <q...@example.com>', + 'f...@example.com, Foo Bar <b...@example.com>, Fôo Baz <b...@example.com>, \n "Quûx, Foo" <q...@example.com>', + 'preserve'), + # Test decode + ('f...@example.com, Foo Bar <b...@example.com>, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <q...@example.com>', + 'f...@example.com, Foo Bar <b...@example.com>, \n "Quûx, Foo" <q...@example.com>', + 'decode'), ]) -def test_sevenbitify_headers(sampledir, headers, verify): - msg = email.message.Message(policy=b4.emlpolicy) - for header, value in headers.items(): - msg[header] = value - msg.set_payload('Unicôde Côntent in the body.\n', charset='utf-8') - msg.set_charset('utf-8') - msg = b4.sevenbitify_headers(msg) - odata = msg.as_bytes(policy=email.policy.SMTP) - vfile = os.path.join(sampledir, f'{verify}.verify') - with open(vfile, 'rb') as fh: - vdata = fh.read() - assert odata.decode() == vdata.decode() +def test_header_wrapping(sampledir, hval, verify, tr): + hname = 'To' if '@' in hval else "X-Header" + wrapped = b4.LoreMessage.wrap_header((hname, hval), transform=tr) + assert wrapped.decode() == f'{hname}: {verify}' + wname, wval = wrapped.split(b':', maxsplit=1) + if tr != 'decode': + cval = b4.LoreMessage.clean_header(wval.decode()) + assert cval == hval + + +@pytest.mark.parametrize('pairs,verify,clean', [ + ([('', 'f...@example.com'), ('Foo Bar', 'b...@example.com')], + 'f...@example.com, Foo Bar <b...@example.com>', True), + ([('', 'f...@example.com'), ('Foo, Bar', 'b...@example.com')], + 'f...@example.com, "Foo, Bar" <b...@example.com>', True), + ([('', 'f...@example.com'), ('Fôo, Bar', 'b...@example.com')], + 'f...@example.com, "Fôo, Bar" <b...@example.com>', True), + ([('', 'f...@example.com'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'q...@example.com')], + 'f...@example.com, Quûx Foo <q...@example.com>', True), + ([('', 'f...@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'q...@example.com')], + 'f...@example.com, "Quûx, Foo" <q...@example.com>', True), + ([('', 'f...@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'q...@example.com')], + 'f...@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <q...@example.com>', False), +]) +def test_format_addrs(pairs, verify, clean): + formatted = b4.format_addrs(pairs, clean) + assert formatted == verify