Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion emails/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions emails/backend/smtp/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 []

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions emails/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,15 @@ 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
if text:
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:

Expand Down
18 changes: 10 additions & 8 deletions emails/signers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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]
14 changes: 7 additions & 7 deletions emails/store/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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]:
Expand Down
5 changes: 3 additions & 2 deletions emails/store/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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)
Expand Down
23 changes: 12 additions & 11 deletions emails/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -266,21 +267,21 @@ 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:
self.encoding = encoding
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})

Expand All @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
[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 stubs
[mypy-requests.*]
ignore_missing_imports = true

# 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 =
Expand Down
4 changes: 4 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ deps =
-rrequirements/tests.txt


[testenv:typecheck]
deps = mypy
commands = mypy emails/

[testenv:style]
deps = pre-commit
skip_install = true
Expand Down
Loading