Skip to content
Merged
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
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
include README.rst LICENSE requirements.txt
include emails/py.typed
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
10 changes: 5 additions & 5 deletions emails/backend/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ 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.status_text: bytes | 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:
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
Expand Down
12 changes: 6 additions & 6 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['port'] # always set as int two lines above
self.fail_silently = fail_silently
self.mail_options = mail_options or []

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -131,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
Expand Down
11 changes: 8 additions & 3 deletions emails/backend/smtp/client.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand Down Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions emails/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down 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 Expand Up @@ -343,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
Expand All @@ -354,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


Expand Down
33 changes: 21 additions & 12 deletions emails/signers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, to_native


class DKIMSigner:
Expand All @@ -28,27 +27,32 @@ 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))
privkey_parsed = parse_pem_private_key(privkey_bytes)
except UnparsableKeyError as exc:
raise DKIMException(exc)

self._sign_params.update({'privkey': privkey,
'domain': to_bytes(domain),
'selector': to_bytes(selector)})
self._sign_params.update({'privkey': privkey_parsed,
'domain': domain.encode(),
'selector': selector.encode()})

def get_sign_string(self, message: bytes) -> bytes | None:
try:
# 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)
result: bytes = dkim.sign(message=message, **self._sign_params)
return result
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 +61,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) = s.decode().split(': ', 1)
if value.endswith("\r\n"):
value = value[:-2]
return header, value
return None

def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart:
"""
Expand All @@ -71,9 +76,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(msg.as_string().encode())
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 +90,16 @@ 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(message_string.encode())
if s:
return s.decode() + 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
if s:
return s + message_bytes
return message_bytes
30 changes: 19 additions & 11 deletions emails/store/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@ 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.
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 = 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)
self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None)
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')
Expand All @@ -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()
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
Expand Down Expand Up @@ -142,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))
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)
Expand Down Expand Up @@ -186,7 +189,12 @@ def fetch(self) -> None:

def get_data(self) -> bytes | str:
self.fetch()
return self._data or ''
data = self._data
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:
self._data = v
Expand All @@ -196,7 +204,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 self.get_mime_type()

@property
def headers(self) -> dict[str, str]:
Expand Down
6 changes: 4 additions & 2 deletions emails/store/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -66,7 +66,8 @@ def unique_filename(self, filename: str | None, uri: str | None = None) -> str:
if filename not in self._filenames:
break

self._filenames[filename] = uri
if filename is not None:
self._filenames[filename] = uri

return filename

Expand Down Expand Up @@ -94,6 +95,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
Loading
Loading