From 39ef060f60320df05aaf8b2a5fd195f1d50a8dbd Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:31:05 +0300 Subject: [PATCH 01/16] Fix type hints issues from review 1. Include py.typed in published package: add package_data in setup.py and include in MANIFEST.in (without this, PEP 561 support was not actually working) 2. Widen date callback type from Callable[[], str] to Callable[..., str | datetime | float] to match existing behavior (callbacks can return datetime/float, not just str) 3. Fix SMTPResponse annotations: esmtp_opts and rcpt_options are lists (not str), and rename refused_recipient to refused_recipients to match actual usage in client.py --- MANIFEST.in | 1 + emails/backend/response.py | 6 +++--- emails/message.py | 4 ++-- setup.py | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 645a28c..615d03f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include README.rst LICENSE requirements.txt +include emails/py.typed diff --git a/emails/backend/response.py b/emails/backend/response.py index 20dfb29..44bacb7 100644 --- a/emails/backend/response.py +++ b/emails/backend/response.py @@ -37,13 +37,13 @@ def __init__(self, exception: Exception | None = None, backend: Any = None) -> N self.responses: list[list] = [] - self.esmtp_opts: str | None = None - self.rcpt_options: str | None = None + self.esmtp_opts: list[str] | None = None + self.rcpt_options: list[str] | None = None self.status_code: int | None = None self.status_text: str | None = None self.last_command: str | None = None - self.refused_recipient: dict[str, tuple[int, str]] = {} + self.refused_recipients: dict[str, tuple[int, bytes]] = {} def set_status(self, command: str, code: int, text: str, **kwargs: Any) -> None: self.responses.append([command, code, text, kwargs]) diff --git a/emails/message.py b/emails/message.py index 758de8e..3216354 100644 --- a/emails/message.py +++ b/emails/message.py @@ -39,7 +39,7 @@ class BaseMessage: def __init__(self, charset: str | None = None, message_id: str | MessageID | bool | None = None, - date: str | datetime | float | bool | Callable[[], str] | None = None, + date: str | datetime | float | bool | Callable[..., str | datetime | float] | None = None, subject: str | None = None, mail_from: _Address = None, mail_to: _AddressList = None, @@ -158,7 +158,7 @@ def get_subject(self) -> str | None: def render(self, **kwargs: Any) -> None: self.render_data = kwargs - def set_date(self, value: str | datetime | float | bool | Callable[[], str] | None) -> None: + def set_date(self, value: str | datetime | float | bool | Callable[..., str | datetime | float] | None) -> None: self._date = value def get_date(self) -> str | None: diff --git a/setup.py b/setup.py index a1dac98..f73614b 100644 --- a/setup.py +++ b/setup.py @@ -126,6 +126,7 @@ def find_version(*file_paths): 'emails.packages', 'emails.packages.dkim' ], + package_data={'emails': ['py.typed']}, scripts=['scripts/make_rfc822.py'], python_requires='>=3.10', install_requires=['python-dateutil'], From 43a3f8fcedd4d6b3449d66a6fad556fb471133d9 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:44:08 +0300 Subject: [PATCH 02/16] Add mypy type checking to CI Configure mypy in setup.cfg with per-module overrides: - emails.message: suppress mixin pattern false positives - emails.packages: ignore vendored DKIM code - emails.backend.smtp.client: private smtplib attrs - emails.template, emails.django: optional deps Add targeted type: ignore comments for known safe patterns. Add tox typecheck environment and CI job. mypy now passes clean on 26 source files. --- .github/workflows/tests.yaml | 18 ++++++++++++++++++ emails/__init__.py | 2 +- emails/backend/smtp/backend.py | 6 +++--- emails/message.py | 2 ++ emails/signers.py | 18 ++++++++++-------- emails/store/file.py | 14 +++++++------- emails/store/store.py | 5 +++-- emails/utils.py | 23 ++++++++++++----------- setup.cfg | 30 ++++++++++++++++++++++++++++++ tox.ini | 4 ++++ 10 files changed, 90 insertions(+), 32 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 50d054b..217e777 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,6 +36,24 @@ jobs: - name: run tests run: tox -e ${{ matrix.tox }} -- -m "not e2e" + typecheck: + name: "typecheck" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - run: pip install tox + - name: run mypy + run: tox -e typecheck + e2e: name: "e2e / ${{ matrix.name }}" runs-on: ${{ matrix.os }} diff --git a/emails/__init__.py b/emails/__init__.py index 2633ffb..57e8a20 100644 --- a/emails/__init__.py +++ b/emails/__init__.py @@ -42,7 +42,7 @@ __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2013-2026 Sergey Lavrinenko' -USER_AGENT = 'python-emails/%s' % __version__ +USER_AGENT: str = 'python-emails/%s' % __version__ from .message import Message, html from .utils import MessageID diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index a196756..e3ff855 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -34,7 +34,7 @@ class SMTPBackend: def __init__(self, ssl: bool = False, fail_silently: bool = True, mail_options: list[str] | None = None, **kwargs: Any) -> None: - self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls + self.smtp_cls = self.connection_ssl_cls if ssl else self.connection_cls self.ssl = ssl self.tls = kwargs.get('tls') @@ -50,7 +50,7 @@ def __init__(self, ssl: bool = False, fail_silently: bool = True, self.smtp_cls_kwargs = kwargs self.host: str | None = kwargs.get('host') - self.port: int = kwargs.get('port') + self.port: int = kwargs.get('port') # type: ignore[assignment] self.fail_silently = fail_silently self.mail_options = mail_options or [] @@ -111,7 +111,7 @@ def _send(self, **kwargs: Any) -> SMTPResponse: response.raise_if_needed() return response else: - return client.sendmail(**kwargs) + return client.sendmail(**kwargs) # type: ignore[no-any-return] def sendmail(self, from_addr: str, to_addrs: str | list[str], msg: Any, mail_options: list[str] | None = None, diff --git a/emails/message.py b/emails/message.py index 3216354..3d4210f 100644 --- a/emails/message.py +++ b/emails/message.py @@ -280,6 +280,7 @@ def _build_html_part(self) -> SafeMIMEText | None: p = SafeMIMEText(text, 'html', charset=self.charset) p.set_charset(self.charset) return p + return None def _build_text_part(self) -> SafeMIMEText | None: text = self.text_body @@ -287,6 +288,7 @@ def _build_text_part(self) -> SafeMIMEText | None: p = SafeMIMEText(text, 'plain', charset=self.charset) p.set_charset(self.charset) return p + return None def build_message(self, message_cls: type | None = None) -> SafeMIMEMultipart: diff --git a/emails/signers.py b/emails/signers.py index 5eb9c90..da39a70 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -30,7 +30,7 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No # Compile private key try: - privkey = parse_pem_private_key(to_bytes(privkey)) + privkey = parse_pem_private_key(to_bytes(privkey)) # type: ignore[arg-type] except UnparsableKeyError as exc: raise DKIMException(exc) @@ -43,12 +43,13 @@ def get_sign_string(self, message: bytes) -> bytes | None: # pydkim module parses message and privkey on each signing # this is not optimal for mass operations # TODO: patch pydkim or use another signing module - return dkim.sign(message=message, **self._sign_params) + return dkim.sign(message=message, **self._sign_params) # type: ignore[no-any-return] except DKIMException: if self.ignore_sign_errors: logging.exception('Error signing message') else: raise + return None def get_sign_bytes(self, message: bytes) -> bytes | None: return self.get_sign_string(message) @@ -57,10 +58,11 @@ def get_sign_header(self, message: bytes) -> tuple[str, str] | None: # pydkim returns string, so we should split s = self.get_sign_string(message) if s: - (header, value) = to_native(s).split(': ', 1) + (header, value) = to_native(s).split(': ', 1) # type: ignore[union-attr] if value.endswith("\r\n"): value = value[:-2] return header, value + return None def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: """ @@ -71,9 +73,9 @@ def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: # but py3 smtplib requires str to send DATA command (# # so we have to convert msg.as_string - dkim_header = self.get_sign_header(to_bytes(msg.as_string())) + dkim_header = self.get_sign_header(to_bytes(msg.as_string())) # type: ignore[arg-type] if dkim_header: - msg._headers.insert(0, dkim_header) + msg._headers.insert(0, dkim_header) # type: ignore[attr-defined] return msg def sign_message_string(self, message_string: str) -> str: @@ -85,12 +87,12 @@ def sign_message_string(self, message_string: str) -> str: # but py3 smtplib requires str to send DATA command # so we have to convert message_string - s = self.get_sign_string(to_bytes(message_string)) - return s and to_native(s) + message_string or message_string + s = self.get_sign_string(to_bytes(message_string)) # type: ignore[arg-type] + return s and to_native(s) + message_string or message_string # type: ignore[operator] def sign_message_bytes(self, message_bytes: bytes) -> bytes: """ Insert DKIM header to message bytes """ s = self.get_sign_bytes(message_bytes) - return s and to_bytes(s) + message_bytes or message_bytes + return s and to_bytes(s) + message_bytes or message_bytes # type: ignore[operator] diff --git a/emails/store/file.py b/emails/store/file.py index 4cd4ff8..7ef7023 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -35,10 +35,10 @@ def __init__(self, **kwargs: Any) -> None: if no filename set, filename extracted from uri. if no uri, but filename set, then uri==filename """ - self.uri: str | None = kwargs.get('uri', None) + self.uri: str | None = kwargs.get('uri', None) # type: ignore[no-redef] self.absolute_url: str | None = kwargs.get('absolute_url', None) or self.uri - self.filename: str | None = kwargs.get('filename', None) - self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None) + self.filename: str | None = kwargs.get('filename', None) # type: ignore[no-redef] + self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None) # type: ignore[no-redef] self._mime_type: str | None = kwargs.get('mime_type') self._headers: dict[str, str] = kwargs.get('headers', {}) self._content_id: str | None = kwargs.get('content_id') @@ -56,7 +56,7 @@ def get_data(self) -> bytes | str | None: if isinstance(_data, str): return _data elif hasattr(_data, 'read'): - return _data.read() + return _data.read() # type: ignore[union-attr, no-any-return] else: return _data @@ -142,7 +142,7 @@ def mime(self) -> MIMEBase | None: if p is None: filename_header = encode_header(self.filename) p = MIMEBase(*self.mime_type.split('/', 1), name=filename_header) - p.set_payload(to_bytes(self.data)) + p.set_payload(to_bytes(self.data)) # type: ignore[arg-type] encode_base64(p) if 'content-disposition' not in self._headers: p.add_header('Content-Disposition', self.content_disposition, filename=filename_header) @@ -186,7 +186,7 @@ def fetch(self) -> None: def get_data(self) -> bytes | str: self.fetch() - return self._data or '' + return self._data or '' # type: ignore[return-value] def set_data(self, v: bytes | str | IO[bytes] | None) -> None: self._data = v @@ -196,7 +196,7 @@ def set_data(self, v: bytes | str | IO[bytes] | None) -> None: @property def mime_type(self) -> str: self.fetch() - return super(LazyHTTPFile, self).mime_type + return super(LazyHTTPFile, self).mime_type # type: ignore[no-any-return] @property def headers(self) -> dict[str, str]: diff --git a/emails/store/store.py b/emails/store/store.py index 67cd6b8..1f7bd84 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -66,9 +66,9 @@ def unique_filename(self, filename: str | None, uri: str | None = None) -> str: if filename not in self._filenames: break - self._filenames[filename] = uri + self._filenames[filename] = uri # type: ignore[index] - return filename + return filename # type: ignore[return-value] def add(self, value: BaseFile | dict[str, Any], replace: bool = False) -> BaseFile: @@ -94,6 +94,7 @@ def by_filename(self, filename: str) -> BaseFile | None: uri = self._filenames.get(filename) if uri: return self.by_uri(uri) + return None def __getitem__(self, uri: str) -> BaseFile | None: return self.by_uri(uri) or self.by_filename(uri) diff --git a/emails/utils.py b/emails/utils.py index 97b82ea..3e678a8 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -17,7 +17,8 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import Header, decode_header as decode_header_ -from email.utils import parseaddr, formatdate, escapesre, specialsre +from email.utils import parseaddr, formatdate +from email.utils import escapesre, specialsre # type: ignore[attr-defined] # private but stable from . import USER_AGENT from .exc import HTTPLoaderError @@ -40,8 +41,8 @@ def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(), if not isinstance(x, bytes): return str(x) if charset is None and allow_none_charset: - return x - return x.decode(charset, errors) + return x # type: ignore[return-value] # returns bytes when allow_none_charset=True + return x.decode(charset, errors) # type: ignore[arg-type] # charset is str here def to_bytes(x: str | bytes | bytearray | memoryview | None, @@ -127,8 +128,8 @@ def get_fqdn(self) -> str: def decode_header(value: str | bytes, default: str = "utf-8", errors: str = 'strict') -> str: """Decode the specified header value""" - value = to_native(value, charset=default, errors=errors) - return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)]) + value = to_native(value, charset=default, errors=errors) # type: ignore[assignment] + return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)]) # type: ignore[misc, arg-type] class MessageID: @@ -212,7 +213,7 @@ def parse_name_and_email(obj: str | tuple[str | None, str] | list[str], def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> str: if parse: - _, addr = parseaddr(to_unicode(addr)) + _, addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type] try: addr.encode('ascii') except UnicodeEncodeError: # IDN @@ -228,7 +229,7 @@ def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> s def sanitize_address(addr: str | tuple[str, str], encoding: str = 'ascii') -> str: if isinstance(addr, str): - addr = parseaddr(to_unicode(addr)) + addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type] nm, addr = addr # This try-except clause is needed on Python 3 < 3.2.4 # http://bugs.python.org/issue14291 @@ -266,13 +267,13 @@ def as_bytes(self, unixfrom: bool = False, linesep: str = '\n') -> bytes: return fp.getvalue() -class SafeMIMEText(MIMEMixin, MIMEText): +class SafeMIMEText(MIMEMixin, MIMEText): # type: ignore[misc] # intentional override def __init__(self, text: str, subtype: str, charset: str) -> None: self.encoding = charset MIMEText.__init__(self, text, subtype, charset) -class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): +class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): # type: ignore[misc] # intentional override def __init__(self, _subtype: str = 'mixed', boundary: str | None = None, _subparts: list[Any] | None = None, encoding: str | None = None, **_params: Any) -> None: @@ -280,7 +281,7 @@ def __init__(self, _subtype: str = 'mixed', boundary: str | None = None, MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params) -DEFAULT_REQUESTS_PARAMS = dict(allow_redirects=True, +DEFAULT_REQUESTS_PARAMS: dict[str, Any] = dict(allow_redirects=True, verify=False, timeout=10, headers={'User-Agent': USER_AGENT}) @@ -299,7 +300,7 @@ def fetch_url(url: str, valid_http_codes: tuple[int, ...] = (200, ), def encode_header(value: str | Any, charset: str = 'utf-8') -> str | Any: if isinstance(value, str): - value = to_unicode(value, charset=charset).rstrip() + value = to_unicode(value, charset=charset).rstrip() # type: ignore[union-attr] _r = Header(value, charset) return str(_r) else: diff --git a/setup.cfg b/setup.cfg index 7aed5d8..602cc2a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,33 @@ +[mypy] +python_version = 3.10 +ignore_missing_imports = true +warn_return_any = true +warn_unused_ignores = true +exclude = emails/(packages|testsuite)/ + +# Mixin classes access attributes defined in BaseMessage via MRO; +# mypy checks each mixin in isolation and reports false attr-defined errors. +# Also suppress arg-type/misc/union-attr/return-value/no-any-return which +# stem from the same mixin pattern (self is typed as the mixin, not Message). +[mypy-emails.message] +disable_error_code = attr-defined, arg-type, misc, union-attr, return-value, no-any-return, assignment + +# Vendored DKIM package +[mypy-emails.packages.*] +ignore_errors = true + +# Uses private smtplib attrs (_have_ssl) and conditional class definitions +[mypy-emails.backend.smtp.client] +disable_error_code = attr-defined, no-redef + +# Optional dependency, not always installed +[mypy-emails.template.*] +ignore_errors = true + +# Django integration, not always installed +[mypy-emails.django] +ignore_errors = true + [tool:pytest] norecursedirs = .* {arch} *.egg *.egg-info dist build requirements markers = diff --git a/tox.ini b/tox.ini index 8d6735f..83d7093 100644 --- a/tox.ini +++ b/tox.ini @@ -30,6 +30,10 @@ deps = -rrequirements/tests.txt +[testenv:typecheck] +deps = mypy +commands = mypy emails/ + [testenv:style] deps = pre-commit skip_install = true From 1467ee81bfc21fcc4201a037f49265aac034b992 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:46:37 +0300 Subject: [PATCH 03/16] Fix mypy CI: add per-module ignore for requests stubs The global ignore_missing_imports does not suppress import-untyped for inline imports. Add explicit per-module override for requests. --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index 602cc2a..8d31f4d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,6 +20,10 @@ ignore_errors = true [mypy-emails.backend.smtp.client] disable_error_code = attr-defined, no-redef +# Optional dependency stubs +[mypy-requests.*] +ignore_missing_imports = true + # Optional dependency, not always installed [mypy-emails.template.*] ignore_errors = true From f367a1024bb6c79915fd603e3d68ab576c6aa7ce Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:55:57 +0300 Subject: [PATCH 04/16] Remove redundant type annotations on property-backed attributes The uri, filename, and data attributes are defined as properties via get/set + property(). The type annotation in __init__ conflicted with the property descriptor, causing mypy no-redef errors. Removing the annotation lets mypy infer the type from the property. --- emails/store/file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/emails/store/file.py b/emails/store/file.py index 7ef7023..0d421ae 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -35,10 +35,10 @@ def __init__(self, **kwargs: Any) -> None: if no filename set, filename extracted from uri. if no uri, but filename set, then uri==filename """ - self.uri: str | None = kwargs.get('uri', None) # type: ignore[no-redef] + self.uri = kwargs.get('uri', None) self.absolute_url: str | None = kwargs.get('absolute_url', None) or self.uri - self.filename: str | None = kwargs.get('filename', None) # type: ignore[no-redef] - self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None) # type: ignore[no-redef] + self.filename = kwargs.get('filename', None) + self.data = kwargs.get('data', None) self._mime_type: str | None = kwargs.get('mime_type') self._headers: dict[str, str] = kwargs.get('headers', {}) self._content_id: str | None = kwargs.get('content_id') From 22fc286e6627cb07a7c1155b5e8e2570c340b9b6 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:57:43 +0300 Subject: [PATCH 05/16] Replace getattr/hasattr with direct access and isinstance in BaseFile.get_data Use self._data directly (type known from setter) and isinstance checks that mypy can narrow, removing type: ignore comments. --- emails/store/file.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/emails/store/file.py b/emails/store/file.py index 0d421ae..683c47f 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -29,6 +29,8 @@ class BaseFile: Store base "attachment-file" information. """ + _data: bytes | str | IO[bytes] | None + def __init__(self, **kwargs: Any) -> None: """ uri and filename are connected properties. @@ -52,13 +54,13 @@ def as_dict(self, fields: tuple[str, ...] | None = None) -> dict[str, Any]: return dict([(k, getattr(self, k)) for k in fields]) def get_data(self) -> bytes | str | None: - _data = getattr(self, '_data', None) - if isinstance(_data, str): + _data = self._data + if isinstance(_data, (str, bytes)): return _data - elif hasattr(_data, 'read'): - return _data.read() # type: ignore[union-attr, no-any-return] + elif _data is None: + return None else: - return _data + return _data.read() def set_data(self, value: bytes | str | IO[bytes] | None) -> None: self._data = value From a7182d0de95f79e61b78d75bc5afcc482d0653f3 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:01:42 +0300 Subject: [PATCH 06/16] Reduce type: ignore comments from 26 to 14 Replace suppressions with proper fixes where possible: - Use isinstance checks instead of hasattr for type narrowing - Use assert for None-safety where input guarantees non-None - Access self._data directly instead of getattr - Remove redundant to_unicode calls on already-str values - Use explicit if/else instead of tricky and/or expressions - Use dict[] instead of .get() where key is guaranteed present Remaining 14 ignores are genuine mypy limitations: vendored dkim API, private stdlib attrs, intentional method overrides, and decorator typing. --- emails/backend/smtp/backend.py | 2 +- emails/signers.py | 22 +++++++++++++++++----- emails/store/store.py | 7 ++++--- emails/utils.py | 11 ++++++----- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index e3ff855..9a806b8 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -50,7 +50,7 @@ def __init__(self, ssl: bool = False, fail_silently: bool = True, self.smtp_cls_kwargs = kwargs self.host: str | None = kwargs.get('host') - self.port: int = kwargs.get('port') # type: ignore[assignment] + self.port: int = kwargs['port'] # always set as int two lines above self.fail_silently = fail_silently self.mail_options = mail_options or [] diff --git a/emails/signers.py b/emails/signers.py index da39a70..e8331d4 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -58,7 +58,9 @@ def get_sign_header(self, message: bytes) -> tuple[str, str] | None: # pydkim returns string, so we should split s = self.get_sign_string(message) if s: - (header, value) = to_native(s).split(': ', 1) # type: ignore[union-attr] + native = to_native(s) + assert native is not None # s is bytes, to_native always returns str + (header, value) = native.split(': ', 1) if value.endswith("\r\n"): value = value[:-2] return header, value @@ -73,7 +75,9 @@ def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: # but py3 smtplib requires str to send DATA command (# # so we have to convert msg.as_string - dkim_header = self.get_sign_header(to_bytes(msg.as_string())) # type: ignore[arg-type] + msg_bytes = to_bytes(msg.as_string()) + assert msg_bytes is not None + dkim_header = self.get_sign_header(msg_bytes) if dkim_header: msg._headers.insert(0, dkim_header) # type: ignore[attr-defined] return msg @@ -87,12 +91,20 @@ def sign_message_string(self, message_string: str) -> str: # but py3 smtplib requires str to send DATA command # so we have to convert message_string - s = self.get_sign_string(to_bytes(message_string)) # type: ignore[arg-type] - return s and to_native(s) + message_string or message_string # type: ignore[operator] + msg_bytes = to_bytes(message_string) + assert msg_bytes is not None + s = self.get_sign_string(msg_bytes) + if s: + header = to_native(s) + assert header is not None + return header + message_string + return message_string def sign_message_bytes(self, message_bytes: bytes) -> bytes: """ Insert DKIM header to message bytes """ s = self.get_sign_bytes(message_bytes) - return s and to_bytes(s) + message_bytes or message_bytes # type: ignore[operator] + if s: + return s + message_bytes + return message_bytes diff --git a/emails/store/store.py b/emails/store/store.py index 1f7bd84..ae6e4c1 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -54,7 +54,7 @@ def remove(self, uri: BaseFile | str) -> None: del self._filenames[filename] del self._files[uri] - def unique_filename(self, filename: str | None, uri: str | None = None) -> str: + def unique_filename(self, filename: str | None, uri: str | None = None) -> str | None: if filename in self._filenames: n = 1 @@ -66,9 +66,10 @@ def unique_filename(self, filename: str | None, uri: str | None = None) -> str: if filename not in self._filenames: break - self._filenames[filename] = uri # type: ignore[index] + if filename is not None: + self._filenames[filename] = uri - return filename # type: ignore[return-value] + return filename def add(self, value: BaseFile | dict[str, Any], replace: bool = False) -> BaseFile: diff --git a/emails/utils.py b/emails/utils.py index 3e678a8..ed89067 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -128,8 +128,9 @@ def get_fqdn(self) -> str: def decode_header(value: str | bytes, default: str = "utf-8", errors: str = 'strict') -> str: """Decode the specified header value""" - value = to_native(value, charset=default, errors=errors) # type: ignore[assignment] - return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)]) # type: ignore[misc, arg-type] + native = to_native(value, charset=default, errors=errors) + assert native is not None # to_native returns None only for None input + return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(native)]) # type: ignore[misc] class MessageID: @@ -213,7 +214,7 @@ def parse_name_and_email(obj: str | tuple[str | None, str] | list[str], def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> str: if parse: - _, addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type] + _, addr = parseaddr(addr) try: addr.encode('ascii') except UnicodeEncodeError: # IDN @@ -229,7 +230,7 @@ def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> s def sanitize_address(addr: str | tuple[str, str], encoding: str = 'ascii') -> str: if isinstance(addr, str): - addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type] + addr = parseaddr(addr) nm, addr = addr # This try-except clause is needed on Python 3 < 3.2.4 # http://bugs.python.org/issue14291 @@ -300,7 +301,7 @@ def fetch_url(url: str, valid_http_codes: tuple[int, ...] = (200, ), def encode_header(value: str | Any, charset: str = 'utf-8') -> str | Any: if isinstance(value, str): - value = to_unicode(value, charset=charset).rstrip() # type: ignore[union-attr] + value = value.rstrip() _r = Header(value, charset) return str(_r) else: From f9566a624eca5a3d2411df4b4229028898d5ffa6 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:10:40 +0300 Subject: [PATCH 07/16] Add tests for changed logic, fix as_bytes DKIM signing bug New tests: - BaseFile.get_data() with str, bytes, IO, and None - SMTPResponse: defaults, set_status, success, refused_recipients - Message.as_bytes() with DKIM signing The as_bytes test uncovered a bug: as_bytes() called sign_string() (expects str) instead of sign_bytes() (expects bytes), causing TypeError when DKIM signing was enabled. Fixed. --- emails/message.py | 2 +- emails/testsuite/message/test_dkim.py | 10 +++++ emails/testsuite/smtp/test_smtp_response.py | 45 +++++++++++++++++++++ emails/testsuite/store/test_store.py | 24 ++++++++++- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/emails/message.py b/emails/message.py index 3d4210f..310e4de 100644 --- a/emails/message.py +++ b/emails/message.py @@ -356,7 +356,7 @@ def as_bytes(self, message_cls: type | None = None) -> bytes: """ r = self.build_message(message_cls=message_cls).as_bytes() if self._signer: - r = self.sign_string(r) + r = self.sign_bytes(r) return r diff --git a/emails/testsuite/message/test_dkim.py b/emails/testsuite/message/test_dkim.py index 4c9eafd..c3ea16f 100644 --- a/emails/testsuite/message/test_dkim.py +++ b/emails/testsuite/message/test_dkim.py @@ -132,6 +132,16 @@ def test_dkim_error(): m.as_message() +def test_dkim_as_bytes(): + + priv_key, pub_key = _generate_key(length=1024) + message = Message(**common_email_data()) + message.dkim(key=priv_key, selector='_dkim', domain='somewhere.net') + result = message.as_bytes() + assert isinstance(result, bytes) + assert b'DKIM-Signature: ' in result + + def test_dkim_sign_twice(): # Test #44: diff --git a/emails/testsuite/smtp/test_smtp_response.py b/emails/testsuite/smtp/test_smtp_response.py index e69de29..50d9f17 100644 --- a/emails/testsuite/smtp/test_smtp_response.py +++ b/emails/testsuite/smtp/test_smtp_response.py @@ -0,0 +1,45 @@ +# encoding: utf-8 +from emails.backend.response import SMTPResponse + + +def test_smtp_response_defaults(): + r = SMTPResponse() + assert r.status_code is None + assert r.status_text is None + assert r.refused_recipients == {} + assert r.esmtp_opts is None + assert r.rcpt_options is None + assert not r.success + assert r.error is None + + +def test_smtp_response_set_status(): + r = SMTPResponse() + r.set_status('mail', 250, 'OK') + assert r.status_code == 250 + assert r.status_text == 'OK' + assert r.last_command == 'mail' + assert len(r.responses) == 1 + + +def test_smtp_response_success(): + r = SMTPResponse() + r.set_status('data', 250, 'OK') + assert not r.success # _finished is False + r._finished = True + assert r.success + + +def test_smtp_response_refused_recipients(): + r = SMTPResponse() + r.refused_recipients = {} + r.refused_recipients['bad@example.com'] = (550, b'User unknown') + assert 'bad@example.com' in r.refused_recipients + assert r.refused_recipients['bad@example.com'] == (550, b'User unknown') + + +def test_smtp_response_exception(): + exc = Exception('connection failed') + r = SMTPResponse(exception=exc) + assert r.error is exc + assert not r.success diff --git a/emails/testsuite/store/test_store.py b/emails/testsuite/store/test_store.py index 30a5ada..462f113 100644 --- a/emails/testsuite/store/test_store.py +++ b/emails/testsuite/store/test_store.py @@ -1,8 +1,10 @@ # encoding: utf-8 +from io import BytesIO + import pytest import emails import emails.store -from emails.store.file import fix_content_type +from emails.store.file import BaseFile, fix_content_type def test_fix_content_type(): @@ -47,6 +49,26 @@ def test_store_unique_name(): assert f2.content_id != f3.content_id +def test_get_data_str(): + f = BaseFile(data='hello') + assert f.data == 'hello' + + +def test_get_data_bytes(): + f = BaseFile(data=b'hello') + assert f.data == b'hello' + + +def test_get_data_filelike(): + f = BaseFile(data=BytesIO(b'hello')) + assert f.data == b'hello' + + +def test_get_data_none(): + f = BaseFile() + assert f.data is None + + def test_store_commons2(): store = emails.store.MemoryFileStore() f1 = store.add({'uri': '/a/c.gif'}) From 2a47fd9e911368a3c94c40fba35fc91831104c10 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:11:55 +0300 Subject: [PATCH 08/16] =?UTF-8?q?Fix=20SMTPResponse.status=5Ftext=20type:?= =?UTF-8?q?=20str=20=E2=86=92=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smtplib.SMTP.mail/rcpt/data return (int, bytes), so status_text and the text parameter of set_status() should be bytes, not str. --- emails/backend/response.py | 4 ++-- emails/testsuite/smtp/test_smtp_response.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/emails/backend/response.py b/emails/backend/response.py index 44bacb7..dd10832 100644 --- a/emails/backend/response.py +++ b/emails/backend/response.py @@ -41,11 +41,11 @@ def __init__(self, exception: Exception | None = None, backend: Any = None) -> N self.rcpt_options: list[str] | None = None self.status_code: int | None = None - self.status_text: str | None = None + self.status_text: bytes | None = None self.last_command: str | None = None self.refused_recipients: dict[str, tuple[int, bytes]] = {} - def set_status(self, command: str, code: int, text: str, **kwargs: Any) -> None: + def set_status(self, command: str, code: int, text: bytes, **kwargs: Any) -> None: self.responses.append([command, code, text, kwargs]) self.status_code = code self.status_text = text diff --git a/emails/testsuite/smtp/test_smtp_response.py b/emails/testsuite/smtp/test_smtp_response.py index 50d9f17..47ddab3 100644 --- a/emails/testsuite/smtp/test_smtp_response.py +++ b/emails/testsuite/smtp/test_smtp_response.py @@ -15,16 +15,16 @@ def test_smtp_response_defaults(): def test_smtp_response_set_status(): r = SMTPResponse() - r.set_status('mail', 250, 'OK') + r.set_status('mail', 250, b'OK') assert r.status_code == 250 - assert r.status_text == 'OK' + assert r.status_text == b'OK' assert r.last_command == 'mail' assert len(r.responses) == 1 def test_smtp_response_success(): r = SMTPResponse() - r.set_status('data', 250, 'OK') + r.set_status('data', 250, b'OK') assert not r.success # _finished is False r._finished = True assert r.success From 82d14c1be70a09a48cce9bc76f61161f6c128bc7 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:26:19 +0300 Subject: [PATCH 09/16] Reduce type: ignore from 14 to 5 - store/file.py: handle None payload explicitly, use get_mime_type() instead of super().mime_type, narrow LazyHTTPFile.get_data types - signers.py: normalize privkey to bytes before parsing, use cast(bytes, ...) for vendored dkim.sign, rewrite sign methods with explicit if/else - backend/smtp/client.py: add return type annotation for sendmail, add from __future__ import annotations - backend/smtp/backend.py: remove no-any-return ignore (now typed via client.py), assert sendmail result is not None - utils.py: add overloads for to_unicode, use cast(F, wrapper) for renderable decorator, build decode_header result with explicit loop and assert Remaining 5 ignores are private API access (msg._headers, email.utils internals), MIMEMixin inheritance, and a dead code path in to_unicode. --- emails/backend/smtp/backend.py | 4 +++- emails/backend/smtp/client.py | 11 ++++++++--- emails/signers.py | 11 +++++++---- emails/store/file.py | 10 +++++++--- emails/utils.py | 26 +++++++++++++++++++++----- setup.cfg | 5 +++-- 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index 9a806b8..edc36b7 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -111,7 +111,9 @@ def _send(self, **kwargs: Any) -> SMTPResponse: response.raise_if_needed() return response else: - return client.sendmail(**kwargs) # type: ignore[no-any-return] + result = client.sendmail(**kwargs) + assert result is not None # to_addrs validated by caller + return result def sendmail(self, from_addr: str, to_addrs: str | list[str], msg: Any, mail_options: list[str] | None = None, diff --git a/emails/backend/smtp/client.py b/emails/backend/smtp/client.py index fb0b5cb..3798c06 100644 --- a/emails/backend/smtp/client.py +++ b/emails/backend/smtp/client.py @@ -1,10 +1,13 @@ # encoding: utf-8 +from __future__ import annotations + __all__ = ['SMTPClientWithResponse', 'SMTPClientWithResponse_SSL'] import smtplib -from smtplib import _have_ssl, SMTP +from smtplib import _have_ssl, SMTP # noqa: private API import logging -from ... utils import sanitize_email +from ..response import SMTPResponse +from ...utils import sanitize_email logger = logging.getLogger(__name__) @@ -55,7 +58,9 @@ def _rset(self): except smtplib.SMTPServerDisconnected: pass - def sendmail(self, from_addr, to_addrs, msg, mail_options=None, rcpt_options=None): + def sendmail(self, from_addr: str, to_addrs: list[str] | str, + msg: bytes, mail_options: list[str] | None = None, + rcpt_options: list[str] | None = None) -> SMTPResponse | None: if not to_addrs: return None diff --git a/emails/signers.py b/emails/signers.py index e8331d4..317ba8b 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -4,7 +4,7 @@ import logging from email.mime.multipart import MIMEMultipart -from typing import IO +from typing import IO, cast from .packages import dkim from .packages.dkim import DKIMException, UnparsableKeyError @@ -28,13 +28,16 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No if privkey and hasattr(privkey, 'read'): privkey = privkey.read() + # Normalize to bytes before parsing + privkey_bytes = privkey if isinstance(privkey, bytes) else str(privkey).encode() + # Compile private key try: - privkey = parse_pem_private_key(to_bytes(privkey)) # type: ignore[arg-type] + privkey_parsed = parse_pem_private_key(privkey_bytes) except UnparsableKeyError as exc: raise DKIMException(exc) - self._sign_params.update({'privkey': privkey, + self._sign_params.update({'privkey': privkey_parsed, 'domain': to_bytes(domain), 'selector': to_bytes(selector)}) @@ -43,7 +46,7 @@ def get_sign_string(self, message: bytes) -> bytes | None: # pydkim module parses message and privkey on each signing # this is not optimal for mass operations # TODO: patch pydkim or use another signing module - return dkim.sign(message=message, **self._sign_params) # type: ignore[no-any-return] + return cast(bytes, dkim.sign(message=message, **self._sign_params)) except DKIMException: if self.ignore_sign_errors: logging.exception('Error signing message') diff --git a/emails/store/file.py b/emails/store/file.py index 683c47f..e346937 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -144,7 +144,8 @@ def mime(self) -> MIMEBase | None: if p is None: filename_header = encode_header(self.filename) p = MIMEBase(*self.mime_type.split('/', 1), name=filename_header) - p.set_payload(to_bytes(self.data)) # type: ignore[arg-type] + payload = to_bytes(self.data) or b'' + p.set_payload(payload) encode_base64(p) if 'content-disposition' not in self._headers: p.add_header('Content-Disposition', self.content_disposition, filename=filename_header) @@ -188,7 +189,10 @@ def fetch(self) -> None: def get_data(self) -> bytes | str: self.fetch() - return self._data or '' # type: ignore[return-value] + data = self._data + if data is None or isinstance(data, (str, bytes)): + return data or '' + return data.read() def set_data(self, v: bytes | str | IO[bytes] | None) -> None: self._data = v @@ -198,7 +202,7 @@ def set_data(self, v: bytes | str | IO[bytes] | None) -> None: @property def mime_type(self) -> str: self.fetch() - return super(LazyHTTPFile, self).mime_type # type: ignore[no-any-return] + return self.get_mime_type() @property def headers(self) -> dict[str, str]: diff --git a/emails/utils.py b/emails/utils.py index ed89067..5454b56 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -10,7 +10,7 @@ from functools import wraps from io import StringIO, BytesIO from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any, TypeVar, cast, overload import email.charset from email import generator @@ -33,6 +33,16 @@ def to_native(x: str | bytes | None, charset: str = sys.getdefaultencoding(), return x.decode(charset, errors) +@overload +def to_unicode(x: None, charset: str | None = ..., errors: str = ..., + allow_none_charset: bool = ...) -> None: ... +@overload +def to_unicode(x: str | bytes, charset: str | None = ..., errors: str = ..., + allow_none_charset: bool = ...) -> str: ... +@overload +def to_unicode(x: Any, charset: str | None = ..., errors: str = ..., + allow_none_charset: bool = ...) -> str | None: ... + def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(), errors: str = 'strict', allow_none_charset: bool = False) -> str | None: @@ -41,8 +51,9 @@ def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(), if not isinstance(x, bytes): return str(x) if charset is None and allow_none_charset: - return x # type: ignore[return-value] # returns bytes when allow_none_charset=True - return x.decode(charset, errors) # type: ignore[arg-type] # charset is str here + return x # type: ignore[return-value] # bytes returned when allow_none_charset=True + assert charset is not None # guaranteed when allow_none_charset is False (default) + return x.decode(charset, errors) def to_bytes(x: str | bytes | bytearray | memoryview | None, @@ -130,7 +141,12 @@ def decode_header(value: str | bytes, default: str = "utf-8", errors: str = 'str """Decode the specified header value""" native = to_native(value, charset=default, errors=errors) assert native is not None # to_native returns None only for None input - return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(native)]) # type: ignore[misc] + parts: list[str] = [] + for text, charset in decode_header_(native): + decoded = to_unicode(text, charset or default, errors) + assert decoded is not None + parts.append(decoded) + return "".join(parts) class MessageID: @@ -319,7 +335,7 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: else: return r - return wrapper # type: ignore[return-value] + return cast(F, wrapper) def format_date_header(v: datetime | float | None, localtime: bool = True) -> str: diff --git a/setup.cfg b/setup.cfg index 8d31f4d..b3840e7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,9 +16,10 @@ disable_error_code = attr-defined, arg-type, misc, union-attr, return-value, no- [mypy-emails.packages.*] ignore_errors = true -# Uses private smtplib attrs (_have_ssl) and conditional class definitions +# Uses private smtplib attrs (_have_ssl), conditional class definitions, +# and overrides smtplib.SMTP.sendmail with incompatible return type [mypy-emails.backend.smtp.client] -disable_error_code = attr-defined, no-redef +disable_error_code = attr-defined, no-redef, override, no-any-return, assignment # Optional dependency stubs [mypy-requests.*] From c3c76bfb43bac386bc542c3b8a5153fa28bfbee7 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:28:21 +0300 Subject: [PATCH 10/16] Simplify LazyHTTPFile.get_data control flow --- emails/store/file.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/emails/store/file.py b/emails/store/file.py index e346937..1fbc0f5 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -190,8 +190,10 @@ def fetch(self) -> None: def get_data(self) -> bytes | str: self.fetch() data = self._data - if data is None or isinstance(data, (str, bytes)): - return data or '' + if data is None: + return '' + if isinstance(data, (str, bytes)): + return data return data.read() def set_data(self, v: bytes | str | IO[bytes] | None) -> None: From 65e2d96c86b6669e396d38baee723626af1cd92c Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:30:22 +0300 Subject: [PATCH 11/16] Simplify decode_header: remove to_native/to_unicode wrappers Use direct isinstance check and bytes.decode() instead of to_native/to_unicode. Clearer control flow, no type: ignore needed. --- emails/utils.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/emails/utils.py b/emails/utils.py index 5454b56..fed9222 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -139,13 +139,14 @@ def get_fqdn(self) -> str: def decode_header(value: str | bytes, default: str = "utf-8", errors: str = 'strict') -> str: """Decode the specified header value""" - native = to_native(value, charset=default, errors=errors) - assert native is not None # to_native returns None only for None input + if isinstance(value, bytes): + value = value.decode(default, errors) parts: list[str] = [] - for text, charset in decode_header_(native): - decoded = to_unicode(text, charset or default, errors) - assert decoded is not None - parts.append(decoded) + for text, charset in decode_header_(value): + if isinstance(text, bytes): + parts.append(text.decode(charset or default, errors)) + else: + parts.append(text) return "".join(parts) From 9b5b2200cd3101116eb22dead35ba9235f1dee3b Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:31:40 +0300 Subject: [PATCH 12/16] Remove allow_none_charset from to_unicode Dead code path: never called with allow_none_charset=True anywhere in the codebase. It also violated its own return type (returned bytes while promising str | None). Removing it simplifies the function and eliminates the last type: ignore in to_unicode. --- emails/utils.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/emails/utils.py b/emails/utils.py index fed9222..de2e6b3 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -34,25 +34,18 @@ def to_native(x: str | bytes | None, charset: str = sys.getdefaultencoding(), @overload -def to_unicode(x: None, charset: str | None = ..., errors: str = ..., - allow_none_charset: bool = ...) -> None: ... +def to_unicode(x: None, charset: str = ..., errors: str = ...) -> None: ... @overload -def to_unicode(x: str | bytes, charset: str | None = ..., errors: str = ..., - allow_none_charset: bool = ...) -> str: ... +def to_unicode(x: str | bytes, charset: str = ..., errors: str = ...) -> str: ... @overload -def to_unicode(x: Any, charset: str | None = ..., errors: str = ..., - allow_none_charset: bool = ...) -> str | None: ... +def to_unicode(x: Any, charset: str = ..., errors: str = ...) -> str | None: ... -def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(), - errors: str = 'strict', - allow_none_charset: bool = False) -> str | None: +def to_unicode(x: Any, charset: str = sys.getdefaultencoding(), + errors: str = 'strict') -> str | None: if x is None: return None if not isinstance(x, bytes): return str(x) - if charset is None and allow_none_charset: - return x # type: ignore[return-value] # bytes returned when allow_none_charset=True - assert charset is not None # guaranteed when allow_none_charset is False (default) return x.decode(charset, errors) From 1fcb40a2f641f874fe255908d9d28e3f53fb6198 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:35:01 +0300 Subject: [PATCH 13/16] Remove to_native/to_unicode calls from message, signers, utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were Python 2 compatibility helpers. On Python 3: - to_native(s) where s is str → noop, removed - to_native(s) where s is bytes → replaced with s.decode() - to_unicode(s) where s is str → noop, removed - to_unicode(value) for non-str → replaced with explicit bytes.decode() / str() branching Functions kept in utils.py for now (still used by loader/). --- emails/message.py | 6 +++--- emails/signers.py | 10 +++------- emails/utils.py | 2 +- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/emails/message.py b/emails/message.py index 310e4de..15c39fe 100644 --- a/emails/message.py +++ b/emails/message.py @@ -6,7 +6,7 @@ from email.utils import getaddresses from typing import Any, IO -from .utils import (formataddr, to_unicode, to_native, +from .utils import (formataddr, SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email, load_email_charsets, encode_header as encode_header_, @@ -231,7 +231,7 @@ def set_header(self, msg: SafeMIMEMultipart, key: str, return if not isinstance(value, str): - value = to_unicode(value) + value = value.decode() if isinstance(value, bytes) else str(value) # Prevent header injection if '\n' in value or '\r' in value: @@ -345,7 +345,7 @@ def as_string(self, message_cls: type | None = None) -> str: Note: this method costs one less message-to-string conversions for dkim in compare to self.as_message().as_string() """ - r = to_native(self.build_message(message_cls=message_cls).as_string()) + r = self.build_message(message_cls=message_cls).as_string() if self._signer: r = self.sign_string(r) return r diff --git a/emails/signers.py b/emails/signers.py index 317ba8b..c2566e5 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -9,7 +9,7 @@ from .packages import dkim from .packages.dkim import DKIMException, UnparsableKeyError from .packages.dkim.crypto import parse_pem_private_key -from .utils import to_bytes, to_native +from .utils import to_bytes class DKIMSigner: @@ -61,9 +61,7 @@ def get_sign_header(self, message: bytes) -> tuple[str, str] | None: # pydkim returns string, so we should split s = self.get_sign_string(message) if s: - native = to_native(s) - assert native is not None # s is bytes, to_native always returns str - (header, value) = native.split(': ', 1) + (header, value) = s.decode().split(': ', 1) if value.endswith("\r\n"): value = value[:-2] return header, value @@ -98,9 +96,7 @@ def sign_message_string(self, message_string: str) -> str: assert msg_bytes is not None s = self.get_sign_string(msg_bytes) if s: - header = to_native(s) - assert header is not None - return header + message_string + return s.decode() + message_string return message_string def sign_message_bytes(self, message_bytes: bytes) -> bytes: diff --git a/emails/utils.py b/emails/utils.py index de2e6b3..78f5a3b 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -219,7 +219,7 @@ def parse_name_and_email(obj: str | tuple[str | None, str] | list[str], else: raise ValueError("Can not parse_name_and_email from %s" % obj) - return to_unicode(name, encoding) or None, to_unicode(email, encoding) or None + return name or None, email or None def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> str: From baa35c226f63b827301ae68aa1bed18305b7617f Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:36:57 +0300 Subject: [PATCH 14/16] Remove to_bytes calls from signers.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All inputs are known str — use .encode() directly instead of the Python 2 compatibility wrapper. signers.py no longer imports anything from utils. --- emails/signers.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/emails/signers.py b/emails/signers.py index c2566e5..a79cb25 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -9,7 +9,6 @@ from .packages import dkim from .packages.dkim import DKIMException, UnparsableKeyError from .packages.dkim.crypto import parse_pem_private_key -from .utils import to_bytes class DKIMSigner: @@ -38,8 +37,8 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No raise DKIMException(exc) self._sign_params.update({'privkey': privkey_parsed, - 'domain': to_bytes(domain), - 'selector': to_bytes(selector)}) + 'domain': domain.encode(), + 'selector': selector.encode()}) def get_sign_string(self, message: bytes) -> bytes | None: try: @@ -76,9 +75,7 @@ def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: # but py3 smtplib requires str to send DATA command (# # so we have to convert msg.as_string - msg_bytes = to_bytes(msg.as_string()) - assert msg_bytes is not None - dkim_header = self.get_sign_header(msg_bytes) + dkim_header = self.get_sign_header(msg.as_string().encode()) if dkim_header: msg._headers.insert(0, dkim_header) # type: ignore[attr-defined] return msg @@ -92,9 +89,7 @@ def sign_message_string(self, message_string: str) -> str: # but py3 smtplib requires str to send DATA command # so we have to convert message_string - msg_bytes = to_bytes(message_string) - assert msg_bytes is not None - s = self.get_sign_string(msg_bytes) + s = self.get_sign_string(message_string.encode()) if s: return s.decode() + message_string return message_string From d802a5a96e827f8596c914ddce5acd0ba5c9d95c Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:40:09 +0300 Subject: [PATCH 15/16] Remove assert in _send, let None propagate honestly _send() can return None when client.sendmail() gets empty to_addrs. Instead of asserting this away, widen the return type to SMTPResponse | None and propagate through retry_on_disconnect and sendmail. --- emails/backend/smtp/backend.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index edc36b7..3f7231d 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -80,9 +80,9 @@ def close(self) -> None: def make_response(self, exception: Exception | None = None) -> SMTPResponse: return self.response_cls(backend=self, exception=exception) - def retry_on_disconnect(self, func: Callable[..., SMTPResponse]) -> Callable[..., SMTPResponse]: + def retry_on_disconnect(self, func: Callable[..., SMTPResponse | None]) -> Callable[..., SMTPResponse | None]: @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse: + def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse | None: try: return func(*args, **kwargs) except smtplib.SMTPServerDisconnected: @@ -92,7 +92,7 @@ def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse: return func(*args, **kwargs) return wrapper - def _send(self, **kwargs: Any) -> SMTPResponse: + def _send(self, **kwargs: Any) -> SMTPResponse | None: response = None try: @@ -111,9 +111,7 @@ def _send(self, **kwargs: Any) -> SMTPResponse: response.raise_if_needed() return response else: - result = client.sendmail(**kwargs) - assert result is not None # to_addrs validated by caller - return result + return client.sendmail(**kwargs) def sendmail(self, from_addr: str, to_addrs: str | list[str], msg: Any, mail_options: list[str] | None = None, @@ -133,7 +131,7 @@ def sendmail(self, from_addr: str, to_addrs: str | list[str], mail_options=mail_options or self.mail_options, rcpt_options=rcpt_options) - if not self.fail_silently: + if response and not self.fail_silently: response.raise_if_needed() return response From 2c11f7a722157168b654d7991dc21fd05694f6f9 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 18:45:10 +0300 Subject: [PATCH 16/16] Replace cast with typed variable for dkim.sign result --- emails/signers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/emails/signers.py b/emails/signers.py index a79cb25..71c8365 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -4,7 +4,7 @@ import logging from email.mime.multipart import MIMEMultipart -from typing import IO, cast +from typing import IO from .packages import dkim from .packages.dkim import DKIMException, UnparsableKeyError @@ -45,7 +45,8 @@ def get_sign_string(self, message: bytes) -> bytes | None: # pydkim module parses message and privkey on each signing # this is not optimal for mass operations # TODO: patch pydkim or use another signing module - return cast(bytes, dkim.sign(message=message, **self._sign_params)) + result: bytes = dkim.sign(message=message, **self._sign_params) + return result except DKIMException: if self.ignore_sign_errors: logging.exception('Error signing message')