From 75317070346c3c268de4b5163a1ea0c95ef82a81 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 17 Sep 2024 10:19:22 +0200 Subject: [PATCH 1/4] compose: Store addresses in parsed format co- Properly split addresses into name and email - Deal with them as (name, email) tuples internally, as a (quoted) name can contain a comma - Format them properly again when inserting into the message Fixes #75 --- dodo/compose.py | 24 ++++++++---------------- dodo/util.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/dodo/compose.py b/dodo/compose.py index 0be0c5e..a47b2d5 100644 --- a/dodo/compose.py +++ b/dodo/compose.py @@ -78,18 +78,8 @@ def __init__(self, a: app.Dodo, mode: str='', msg: Optional[dict]=None, parent: self.message_string = '' if msg: - senders: List[str] = [] - recipients: List[str] = [] - - email_sep = re.compile(r'\s*,\s*') - if 'Reply-To' in msg['headers']: - senders.append(msg['headers']['Reply-To']) - elif 'From' in msg['headers']: - senders.append(msg["headers"]["From"]) - if 'To' in msg['headers']: - recipients += email_sep.split(msg['headers']['To']) - if 'Cc' in msg['headers']: - recipients += email_sep.split(msg['headers']['Cc']) + senders = util.get_header_addresses(msg['headers'], ['From', 'Reply-To']) + recipients = util.get_header_addresses(msg['headers'], ['To', 'Cc']) # Select current_account by checking which smtp_account's address # is found first in the headers. Start with the recipient headers @@ -98,7 +88,7 @@ def __init__(self, a: app.Dodo, mode: str='', msg: Optional[dict]=None, parent: if isinstance(settings.email_address, dict): self.current_account = next( ( - util.email_smtp_account_index(m) for m in + util.email_smtp_account_index(m) for _, m in recipients + senders if util.email_smtp_account_index(m) is not None ), 0) @@ -121,15 +111,17 @@ def __init__(self, a: app.Dodo, mode: str='', msg: Optional[dict]=None, parent: self.raw_message_string += '\n\n\n' elif msg and (mode == 'reply' or mode == 'replyall'): - send_to = [e for e in senders + recipients if not util.email_is_me(e)] + send_to = [(name, e) for name, e in senders + recipients if not util.email_is_me(e)] # put the first non-me email in To if len(send_to) != 0: - self.raw_message_string += f'To: {send_to.pop(0)}\n' + to_value = email.utils.formataddr(send_to.pop(0)) + self.raw_message_string += f'To: {to_value}\n' # for replyall, put the rest of the emails in Cc if len(send_to) != 0 and mode == 'replyall': - self.raw_message_string += f'Cc: {", ".join(send_to)}\n' + cc_values = [email.utils.formataddr(pair) for pair in send_to] + self.raw_message_string += f'Cc: {", ".join(cc_values)}\n' if 'Subject' in msg['headers']: subject = msg['headers']['Subject'] diff --git a/dodo/util.py b/dodo/util.py index 559f6e5..2756c33 100644 --- a/dodo/util.py +++ b/dodo/util.py @@ -89,6 +89,19 @@ def linkify(s: str) -> str: # get preference over email addresses return lnk_email.linkify(lnk.linkify(s)) + +def get_header_addresses( + headers: Dict[str, str], header_keys: List[str] +) -> List[Tuple[str, str]]: + """Extract realnames and email addresses from message headers. + + The given header_keys are considered, e.g. ['From', 'Reply-To'] to get senders, + or ['To', 'Cc'] to get recipients. + """ + header_values = [headers[key] for key in header_keys if key in headers] + return email.utils.getaddresses(header_values) + + html2html = lambda s : s """Function used to process HTML messages From f8b0b06519d6b9907c4a23140a5aab300c48b19a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 17 Sep 2024 10:21:06 +0200 Subject: [PATCH 2/4] util: Improve types for email_smtp_account_index --- dodo/settings.py | 4 ++-- dodo/util.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dodo/settings.py b/dodo/settings.py index f40aa56..caef3fc 100644 --- a/dodo/settings.py +++ b/dodo/settings.py @@ -34,10 +34,10 @@ """ from . import themes -from typing import Literal +from typing import Literal, Dict, Union # functional -email_address = '' +email_address: Union[str, Dict[str, str]] = '' """Your email address (REQUIRED) This is used both to populate the 'From' field of emails and to (mostly) diff --git a/dodo/util.py b/dodo/util.py index 2756c33..07e521d 100644 --- a/dodo/util.py +++ b/dodo/util.py @@ -17,7 +17,7 @@ # along with Dodo. If not, see . from __future__ import annotations -from typing import Iterator, List, Tuple, Dict, Union +from typing import Iterator, List, Tuple, Dict, Optional from PyQt6.QtCore import Qt from PyQt6.QtGui import QKeyEvent @@ -311,14 +311,14 @@ def email_is_me(e: str) -> bool: return False -def email_smtp_account_index(e: str) -> Union[int, None]: +def email_smtp_account_index(e: str) -> Optional[int]: """Index in settings.smtp_accounts of account having the provided email address This method is used e.g. by :class:`dodo.compose.Compose` to autmatically select the account to be used when replying to a mail. It returns the index of first matching account or None if provided email does not match any smtp account. """ - + assert isinstance(settings.email_address, dict) return next( (i for i, acc in enumerate(settings.smtp_accounts) if strip_email_address(e) == From dc88758e9a8211b51263bb64d5c0e0fb678487b9 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 17 Sep 2024 10:21:27 +0200 Subject: [PATCH 3/4] util: Simplify email_is_me --- dodo/util.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/dodo/util.py b/dodo/util.py index 07e521d..2ff3499 100644 --- a/dodo/util.py +++ b/dodo/util.py @@ -298,18 +298,16 @@ def email_is_me(e: str) -> bool: :class:`dodo.compose.Compose` to filter out the user's own email when forming a "reply-to-all" message. """ - - e_strip = strip_email_address(e) - if isinstance(settings.email_address, dict): - for v in settings.email_address.values(): - if strip_email_address(v) == e_strip: - return True + addresses = [ + strip_email_address(v) for v in settings.email_address.values() + ] else: - if strip_email_address(settings.email_address) == e_strip: - return True + addresses = [email.utils.parseaddr(settings.email_address)[1]] - return False + # nb: strip_email_address(e) is unnecessary with how this is used in compose.py, + # but doing it avoids a future footgun, and it is idempotent. + return strip_email_address(e) in addresses def email_smtp_account_index(e: str) -> Optional[int]: """Index in settings.smtp_accounts of account having the provided email address From 780dfffd1c508fee3da9174df0503030adcf7e0b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Tue, 17 Sep 2024 10:21:42 +0200 Subject: [PATCH 4/4] util: Use email.utils.parseaddr for strip_email this handles quoted values correctly and we avoid reinventing the wheel. --- dodo/util.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/dodo/util.py b/dodo/util.py index 2ff3499..955ef80 100644 --- a/dodo/util.py +++ b/dodo/util.py @@ -284,11 +284,7 @@ def strip_email_address(e: str) -> str: E.g. "First Last " -> "me@domain.com" """ - - # TODO proper handling of quoted strings - head = re.compile(r'^.*<') - tail = re.compile(r'>.*$') - return tail.sub('', head.sub('', e)) + return email.utils.parseaddr(e)[1] def email_is_me(e: str) -> bool: """Check whether the provided email is me